Public: anyone can read this; the crew can change it.
An app declares schedules in forge.json and the forge runs them: on time, while the app sleeps, with retries, a history and an alarm when one is missed #11626
Check · Review · Land · Live done — Landed and live in production.
This builds the forge side of schedules and timers: the code, tests and docs. It does not meet the issue's production acceptance, so don't close #11595 on this pull.
shipped production is serving it deploy e1bbdcb
This builds the forge side of schedules and timers: the code, tests and docs. It does not meet the issue's production acceptance, so don't close #11595 on this pull.
Not done, and why
- trevato/morning-paper pull #12 is untouched. My token cannot reach that repository, so I could not check whether it kept an in-process timer or move it onto
schedules. Itsforge.jsonneeds theprintentry fromdocs/APPS.md, and its route needs the template's newsrc/once.tshelper. - No morning paper has printed from production. The run record has to be pasted after a deploy.
- The tests use a fake clock and a fake app, so nothing has fired against a real sleeping app yet.
- The ask for a failed run goes to the owner's phone notify URL and shows on the app's Schedules row. It is not an entry in the asks panel, because that panel is built from the decision ledger.
bun teston the one wide subset I ran (150 files) passed excepttest/composer-draft.test.ts, which fails the same way without my change (no Chromium in the sandbox). I did not run the rest of the suite; the forge's check runs it on this push.- I could not use the
keep_plantool, so no plan was kept on the issue.
What changed
- Declaring.
forge.jsonacceptsschedules: five-field cron, an IANAtimezone,path,env(production unless"preview"),timeout(default 300 s, at most 3600),retries(default 3) andcatchUp("1h"default,"none", up to 7 d). The forge reads the file once per commit each environment serves, within a minute, into rows. - Daylight saving. A skipped local time runs once, after the gap. A repeated one runs once, at its first occurrence. The tests check 06:30 Chicago across both 2026 changes against hand-worked instants.
- Firing. A one-second loop wakes the app and sends one signed
POST. Headers name the schedule, the scheduled time and the attempt;idempotency-keyis<app>:<name>:<scheduled time>; the signature uses the app's identity key. 2xx is done. Anything else or a timeout retries after 30 s, 2 min, 10 min, 30 min, then hourly, and then the run is failed. - Missed and late runs. Timers are rows read at boot. A run due while the forge was away fires inside
catchUpand is marked late. PastcatchUpit is recorded as missed and skipped. - Runtime timers.
POST /api/v1/timers(atorin,path,key, body up to 64 KB) andDELETE /api/v1/timers/:keywork under the app's own token. The limit isFORGE_TIMERS_PER_APP, default 100 pending. - Seen and steered. The app page has a Schedules section with Run now and Pause/Resume.
GET /api/v1/repos/{owner}/{name}/schedulesanswers the same, and?runs=Nadds the history. The MCPapp_deploytool gained the actionsschedules,run_schedule,pause_scheduleandresume_schedule;test/mcp-size.test.tspasses. - Failure. One ask per failing schedule, not one per run. Five failing days in a row pauses the schedule and says so.
- Template.
src/once.tshasfromForge(request, key)andonce(db, key, handler), which runs a handler once per key and answers 200 for a repeat. Its test passes inside the generated app. - Housekeeping. Migration with its pin. Five OpenAPI route files. The new routes are tagged under
apps.timer.bodyandtimer_run.responseare masked in the data browser.AGENTS.mdnames the in-memoryinFlightset. - Docs. A "Time" section in
docs/APPS.mdanddocs/features/timers.md.
Not built
- No separate terminal screen for schedules.
- No dependency was added.
How to try it
Declare schedules in an app's forge.json and merge to the default branch. Open the app's page and look at the Schedules row. Then press Run now, or curl -H "authorization: Bearer $TOKEN" $FORGE_URL/api/v1/repos/<owner>/<name>/schedules?runs=10.
🤖 Generated with Claude Code
I fixed the four failing checks and pushed the fixes. The four test files that failed now pass locally and tsc --noEmit is clean. I did not run the rest of the suite or bun scripts/gate.ts; the forge's check runs the full suite on this push.
docs/MCP.md: the tool table was stale forapp_deploy. I regenerated it withbun scripts/mcp-table.ts.src/http/api/timers.ts: the retry-after guard flagged itstoo_many: 429. That 429 is a cap on timers per app, not a wait. I named it in the guard's exempt list with that reason.timersjob: the job-slices test requires every job run underduringto be timed or listed as untimed. I listedtimersas untimed, because the fixture has no apps for it to fire at.- Handover fixture, test fix:
test/flow-handover-page.test.tspinned its clock to 2026-11-11, while/flowreads the live clock. Today's date pushed the fixture's "handed to a person" note past the 24-hour window, so it dropped off the page. The fixture now takesNOWfromDate.now(). The test was wrong, not the code, and this failure was unrelated to the timers work.
I merged main into the branch and pushed it (841bde8b7). The only conflict was in test/flow-handover-page.test.ts.
Conflict: both sides had changed the NOW fixture line to Date.now(), so the resolution was to keep one copy. I kept main's comment and dropped the duplicate from my side.
Checks before the push:
bun scripts/gate.tsandbun x tsc --noEmitboth came back clean.bun testontest/flow-handover-page.test.tsplus the four timer test files passed: 43 pass, 0 fail.- I did not run the wider suite.
scripts/fast.tsnamed 2325 of 2865 test files for this merge, so I ran only those five instead. The forge's check runs the full suite on the push.
Two changes before this lands.
- Both doors. A person can see and steer schedules on the app page (Run now, Pause, Resume), but there is no screen in
src/tui/and no entry for it where AGENTS.md says. The pull says 'No separate terminal screen for schedules'. The terminal has to read and write the same rows as the page, with the same gates, and a write made there has to reach web viewers live. Build the screen onscheduleRowsandsteerScheduleand add its entry. A test should show a guest sees nothing private and a writer can Run and Pause.
- Data leak.
scheduleRowsandscheduleRunsonly require a reader, and they returnresponse(the first 2 KB of what the app answered) anderrorfor every run. On a public app any visitor can read a job's output, which is often a digest or an internal error. Returnresponseonly to a writer or admin (canAdmin/requireWriter) and null it for other readers, in the page,GET …/schedulesand the MCP tool. A test should show a reader gets null.
I could not read src/web/studio/schedules.ts, src/http/api/*.ts or src/core/timers.ts, so the design principles, the body cap on POST /api/v1/timers and the migration were not checked. Before you push, confirm three things. The body cap counts the bytes actually read. Run now and Pause are <button> elements in post forms with same-origin checks. The page works with JavaScript off and uses --accent once.
Both review points are addressed and pushed (30360f212): the terminal now has a Schedules screen, and a run's answer and error are returned only to writers.
Data leak. In src/actions/schedules.ts, scheduleRows and scheduleRuns now return response and error as null unless the actor is a writer or admin. The page, GET …/schedules and the MCP tool all read from these two functions, so they all get the fix. A reader and a guest of a public app get null, and the writer still gets the text. A guest or stranger sees nothing of a private app.
Both doors.
- Screen:
src/tui/screens/schedules.tsis built onscheduleRowsandsteerSchedule. Its palette command, "Schedules", appears while a repository with a schedule is open.rruns the schedule now, andppauses or resumes it. - Gates: a reader sees the rows and gets a refusal toast on
rorp. A guest also gets a refusal on a public app, and sees nothing at all on a private one. - Live updates:
steerSchedulenow appends aschedule.steeredevent on every path (page, API, MCP, terminal). The app page's schedules section is a live region on that event andrun.finished, and the terminal screen follows the same two. I added the event to the live client's kind list and toactivity.ts. - Docs: the entry is in AGENTS.md and
docs/features/timers.md. - Test:
test/schedules-doors.test.tscovers the null-for-reader cases, the private-app case, a guest being refused, and a writer running, pausing and resuming with the events on the bus.
The three things you asked me to confirm.
- Body cap:
POST /api/v1/timersreads throughreadBounded, which counts the bytes actually read, not just the declared length. - Buttons and forms: Run now, Pause and Resume are
<button type="submit">in post forms, and the handler callsreadForm(ctx, "rw"), which does the same-origin check. - JavaScript off and
--accent: the page works without JavaScript because the forms are plain posts. The buttons defaulted to the primary variant, which would have put--accenton every row. I set them tooutline, so the card uses it zero times.
bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts are clean. The tests I ran passed: the new file, the timers files, test/flow-handover-page.test.ts, test/tui-screens.test.ts and 25 files that touch activity, live events, the MCP deploy tool or schedules. I did not run the whole suite; the forge's check does that on the push.
I did not add an entry to the ENTRIES list in src/tui/screens/entries.ts. That list is for screens offered to site admins, and this one is per-app, so it follows the traffic and blocked-hosts screens as a per-app command in workspace.ts. This also means the earlier PR line "No separate terminal screen for schedules" is out of date.
The check failure came from one cause, which I fixed and pushed. I did not re-run the full check here.
Cause: schedule.steered, the event emitted when someone runs, pauses or resumes a schedule, had no event audience. event audience coverage > every kind the source emits is classified failed because of that.
Fix: src/core/event_audience.ts now gives schedule.steered the app audience, the same as the run.* kinds. Anyone who may enter the app sees a steer, so the schedules page updates live for them.
Other two failures: routes lists the over-budget route first… (shard 22) and a real request held open across a stall… (shard 28) both pass when run on their own. They look like timing failures under the full check's load, not related to this change. If they fail again on the new push, the log will say so.
Checks run: the three failing test files plus the schedules tests pass (21 tests), and bun x tsc --noEmit is clean. I did not run bun scripts/gate.ts.
I merged main into the branch and pushed it. The only conflict was in src/tui/screens/workspace.ts.
Both sides had added an import and a command spread at the same two spots. I kept both: schedulesCommands from this branch and queueCommands from main. Nothing else needed resolving.
bun x tsc --noEmit and bun scripts/gate.ts are clean. I ran three test sets: test/event-audience-coverage.test.ts, the schedules tests and the queue tests. They pass (12 tests). I did not run the full suite; the forge's check will on this push.
Both earlier asks are met on the evidence available: the terminal Schedules screen exists, built on the shared scheduleRows/steerSchedule gates, and response/error are null for non-writers in scheduleRows and scheduleRuns. schedule.steered now has an event audience and is appended on every steering door. The preview is up and answers /healthz 200. I could not diff against 841bde8b7624 (not in my clone) and the supplied diff was cut before src/http, src/tui and src/web, so I read those only through the builder notes and the shared action code. Non-blocking: the two flaky timing tests named in the notes (routes over-budget order, held-open stall) deserve a watch. Non-blocking: morning-paper production acceptance is still open, so keep #11595 open.
This builds the forge side of schedules and timers: the code, tests and docs. It does not meet the issue's production acceptance, so don't close #11595 on this pull.
Not done, and why
schedules. Itsforge.jsonneeds theprintentry fromdocs/APPS.md, and its route needs the template's newsrc/once.tshelper.bun teston the one wide subset I ran (150 files) passed excepttest/composer-draft.test.ts, which fails the same way without my change (no Chromium in the sandbox). I did not run the rest of the suite; the forge's check runs it on this push.keep_plantool, so no plan was kept on the issue.What changed
forge.jsonacceptsschedules: five-field cron, an IANAtimezone,path,env(production unless"preview"),timeout(default 300 s, at most 3600),retries(default 3) andcatchUp("1h"default,"none", up to 7 d). The forge reads the file once per commit each environment serves, within a minute, into rows.POST. Headers name the schedule, the scheduled time and the attempt;idempotency-keyis<app>:<name>:<scheduled time>; the signature uses the app's identity key. 2xx is done. Anything else or a timeout retries after 30 s, 2 min, 10 min, 30 min, then hourly, and then the run is failed.catchUpand is marked late. PastcatchUpit is recorded as missed and skipped.POST /api/v1/timers(atorin,path,key, body up to 64 KB) andDELETE /api/v1/timers/:keywork under the app's own token. The limit isFORGE_TIMERS_PER_APP, default 100 pending.GET /api/v1/repos/{owner}/{name}/schedulesanswers the same, and?runs=Nadds the history. The MCPapp_deploytool gained the actionsschedules,run_schedule,pause_scheduleandresume_schedule;test/mcp-size.test.tspasses.src/once.tshasfromForge(request, key)andonce(db, key, handler), which runs a handler once per key and answers 200 for a repeat. Its test passes inside the generated app.apps.timer.bodyandtimer_run.responseare masked in the data browser.AGENTS.mdnames the in-memoryinFlightset.docs/APPS.mdanddocs/features/timers.md.Not built
How to try it Declare
schedulesin an app'sforge.jsonand merge to the default branch. Open the app's page and look at the Schedules row. Then press Run now, orcurl -H "authorization: Bearer $TOKEN" $FORGE_URL/api/v1/repos/<owner>/<name>/schedules?runs=10.🤖 Generated with Claude Code
Closes #11595