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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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. Its forge.json needs the print entry from docs/APPS.md, and its route needs the template's new src/once.ts helper.
  • 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 test on the one wide subset I ran (150 files) passed except test/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_plan tool, so no plan was kept on the issue.

What changed

  • Declaring. forge.json accepts schedules: five-field cron, an IANA timezone, path, env (production unless "preview"), timeout (default 300 s, at most 3600), retries (default 3) and catchUp ("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-key is <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 catchUp and is marked late. Past catchUp it is recorded as missed and skipped.
  • Runtime timers. POST /api/v1/timers (at or in, path, key, body up to 64 KB) and DELETE /api/v1/timers/:key work under the app's own token. The limit is FORGE_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}/schedules answers the same, and ?runs=N adds the history. The MCP app_deploy tool gained the actions schedules, run_schedule, pause_schedule and resume_schedule; test/mcp-size.test.ts passes.
  • 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.ts has fromForge(request, key) and once(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.body and timer_run.response are masked in the data browser. AGENTS.md names the in-memory inFlight set.
  • Docs. A "Time" section in docs/APPS.md and docs/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

Closes #11595

commented

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. Its forge.json needs the print entry from docs/APPS.md, and its route needs the template's new src/once.ts helper.
  • 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 test on the one wide subset I ran (150 files) passed except test/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_plan tool, so no plan was kept on the issue.

What changed

  • Declaring. forge.json accepts schedules: five-field cron, an IANA timezone, path, env (production unless "preview"), timeout (default 300 s, at most 3600), retries (default 3) and catchUp ("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-key is <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 catchUp and is marked late. Past catchUp it is recorded as missed and skipped.
  • Runtime timers. POST /api/v1/timers (at or in, path, key, body up to 64 KB) and DELETE /api/v1/timers/:key work under the app's own token. The limit is FORGE_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}/schedules answers the same, and ?runs=N adds the history. The MCP app_deploy tool gained the actions schedules, run_schedule, pause_schedule and resume_schedule; test/mcp-size.test.ts passes.
  • 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.ts has fromForge(request, key) and once(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.body and timer_run.response are masked in the data browser. AGENTS.md names the in-memory inFlight set.
  • Docs. A "Time" section in docs/APPS.md and docs/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

commented

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 for app_deploy. I regenerated it with bun scripts/mcp-table.ts.
  • src/http/api/timers.ts: the retry-after guard flagged its too_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.
  • timers job: the job-slices test requires every job run under during to be timed or listed as untimed. I listed timers as untimed, because the fixture has no apps for it to fire at.
  • Handover fixture, test fix: test/flow-handover-page.test.ts pinned its clock to 2026-11-11, while /flow reads 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 takes NOW from Date.now(). The test was wrong, not the code, and this failure was unrelated to the timers work.
commented

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.ts and bun x tsc --noEmit both came back clean.
  • bun test on test/flow-handover-page.test.ts plus the four timer test files passed: 43 pass, 0 fail.
  • I did not run the wider suite. scripts/fast.ts named 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.
Changes requested — before the latest push

Two changes before this lands.

  1. 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 on scheduleRows and steerSchedule and add its entry. A test should show a guest sees nothing private and a writer can Run and Pause.
  1. Data leak. scheduleRows and scheduleRuns only require a reader, and they return response (the first 2 KB of what the app answered) and error for every run. On a public app any visitor can read a job's output, which is often a digest or an internal error. Return response only to a writer or admin (canAdmin/requireWriter) and null it for other readers, in the page, GET …/schedules and 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.

commented

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.ts is built on scheduleRows and steerSchedule. Its palette command, "Schedules", appears while a repository with a schedule is open. r runs the schedule now, and p pauses or resumes it.
  • Gates: a reader sees the rows and gets a refusal toast on r or p. A guest also gets a refusal on a public app, and sees nothing at all on a private one.
  • Live updates: steerSchedule now appends a schedule.steered event on every path (page, API, MCP, terminal). The app page's schedules section is a live region on that event and run.finished, and the terminal screen follows the same two. I added the event to the live client's kind list and to activity.ts.
  • Docs: the entry is in AGENTS.md and docs/features/timers.md.
  • Test: test/schedules-doors.test.ts covers 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/timers reads through readBounded, 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 calls readForm(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 --accent on every row. I set them to outline, 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.

commented

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.

commented

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.

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs