Repository navigation
Conversation
Schedules could only fire a prompt, so every firing was a model call. A
check action ({ type: "check", code, allow }) runs a small Code Mode
program on each firing with no model call and prompts the Session only
when it returns { wake: true, state, message? }. The program gets state
(what its previous run returned, initially null), now and runs, also as
input. Use case: "wake me when someone else replies in this thread",
checked every few minutes for near-zero cost.
- A check that does not wake advances the schedule like a firing and
records the new "checked" outcome; one that wakes goes through the
existing prompt path with the same idempotent msg_<schedule>_<run> id
and metadata, plus schedule.check: true.
- state persists in a new nullable schedule.state JSON column (migration
20261028210808_schedule_state), capped at 32 KB.
- A program error, timeout, tool failure or invalid result fails the
firing and reuses the existing failed/backoff path.
- Limits: 30 s, 20 tool calls, 64 KB output per run.
- Tool.check runs the program over only the allowlisted Code Mode tools
through the registry's normal execute path (plugin hooks included),
with no fetch. CodeModeTool.check shares the inventory-to-tools
conversion with the execute tool's runtime.
- Unattended permissions: Permission.Unattended (a Context.Reference
ruleset) settles requests that would ask. Tool.check sets it to allow
the allowlisted tools' own permissions, so they never stall on an ask,
and anything else that would ask is denied. Configured denies still
win: they are evaluated first, and wholly denied tools are not exposed.
- create rejects a check with empty code.
Verified: bun typecheck in packages/core and packages/schema;
test/schedule.test.ts (29 pass, --rerun-each 10 stable), including new
cases for a check that does not wake, one that wakes once with its
message and carries state, a non-allowlisted tool, a config deny, and
failure backoff; permission, codemode and mcp tests pass; bun run check
from the root passes. Removing the Unattended provision makes the
no-wake case hang on the ask, so it is what keeps checks unattended.
Review follow-up to the Code Mode check action. - Creation-time approval: Schedule.create now asserts each allowlisted tool's own permission (resources and save ["*"], metadata with the schedule name, code and allowlist) against the owning Session and its agent, in the Session's location. Allow passes silently, ask prompts the user once, deny or a rejection refuses creation, and an allowlist path that names no Code Mode tool is invalid. Before this, whoever created a check could get an "ask" tool run unattended without anyone approving it. It runs after the other validation, so nobody is asked about a schedule that would be rejected. Scheduled runs stay unattended as before. create's error type gains Permission.Error. - Due steps run concurrently, so a slow check no longer holds up other schedules in the same tick; MCP discovery now sits inside the check's 30 s timeout. - The program runs in a block after the input bindings, so it may declare its own state/now/runs while line numbers stay the author's. It must return its result: a final expression is no longer used. - An empty message wakes with the default text instead of an empty prompt; the prompt text moved to an early-return helper. - Removed the separate 32 KB state check: maxOutputBytes (now 32 KB) replaces an oversized result with a string, which fails decoding, so it already bounds the state. CheckError is replaced by InvalidError. - Documented the crash-before-update rerun next to the fire update. Verified: bun typecheck in core and schema; test/schedule.test.ts 34 pass, also with --rerun-each 5, with new cases for approval at create (ask approved once with the code and allowlist in metadata, rejection refuses), deny and unknown tool refusing creation, a nested ask for a different permission denied, state kept after a failed run, no run while the last wake is unread, a program declaring its own state, and the empty-message fallback; permission, codemode and mcp tests pass; bun run generate in packages/client gives no diff; bun run check from the root passes.
Reshapes the check to the design for scheduling: a condition on a prompt
schedule, schedule({ when, if: { type: "code", code, allow }, ends,
action: { type: "prompt", prompt } }), rather than a separate action, so
a shell condition ({ type: "shell", command }) can join the union later
without reshaping.
- Action is prompt-only again; CheckAction is removed. The new optional
`if` field (Schedule.Condition, a tagged union with only "code" now)
is on CreateInput and Info, named `if` to match the planned API; it is
a plain property name in TS and JSON.
- Storage: one migration (20261028213025_schedule_condition) adds the
nullable `if` and `state` JSON columns; the earlier state-only
migration is replaced since nothing is released. Regenerated with
bun run migration.
- Firing: with no `if` it is exactly as on dev. With `if`, the condition
runs first; wake: false records "checked", keeps its state and
advances without a prompt; wake: true sends the schedule's own prompt
with the condition's message after a blank line, and the message in
metadata as schedule.check.message, under the same msg_<schedule>_<run>
id. An empty message appends nothing; the default wake text is gone.
- Creation-time approval, unattended runs, concurrent ticks, state and
limits are unchanged and now key off if.type === "code". The firing,
state and outcome path (check, checked) is condition-neutral.
Verified: bun typecheck in core and schema; test/schedule.test.ts 34
pass, also with --rerun-each 5 (tests moved to the condition shape, and
assert the appended message and metadata); permission, codemode and mcp
tests pass (205); bun run generate in packages/client gives no diff;
bun run check from the root passes.
There was a problem hiding this comment.
The condition works as described: no wake records checked and saves its state, a wake sends the prompt with the message after it, tools outside the allowlist are unreachable, configured denies still win, and failures back off. I checked it with test/schedule.test.ts (34 pass), the permission, codemode, tool and mcp tests (552 pass, 0 fail) and tsc -b in packages/core (clean). I couldn't run it end to end because nothing user-facing creates schedules until #59457.
Public API: none of the public surfaces change (SDK, HTTP API, plugin, config, CLI, events). Schedule isn't served over HTTP yet. Internally, @opencode/schema's Schedule.CreateInput and Schedule.Info gain an optional if, Schedule.Outcome gains checked, and a migration adds the nullable schedule.if and schedule.state columns. All of this is backwards compatible.
One concern: approving a check with "always" also saves a project-wide allow for the tool on every resource (see inline).
| yield* permission.assert({ | ||
| action: tool.options?.permission ?? tool.id, | ||
| resources: ["*"], | ||
| save: ["*"], |
There was a problem hiding this comment.
With save: ["*"], if the user answers this prompt with "always", Permission.reply saves a project-wide allow for this tool on *. That allow then applies to every agent and session in the project, not just this schedule. For a tool like shell it would be a much wider grant than the user meant to give a check. The schedule doesn't need it, because runs already get their allow from Permission.Unattended. Could you drop save here, or limit the approval to this schedule?
There was a problem hiding this comment.
Good catch, dropped save from the creation approval in 6f7632f. Runs already get their allow from Permission.Unattended, so the prompt now only offers once/reject and never writes a project-wide allow. The approval test asserts the ask has no save.
…check Review on #60028: approving a check's allowlisted tool passed save: ["*"], so answering "always" saved a project-wide allow for that tool on every resource, applying to every agent and session. Checks don't need it: their runs already get the allow from Permission.Unattended. The approval now asks without save, so the prompt offers once/reject only. Verified: bun typecheck (core), test/schedule.test.ts 34 pass (asserts the creation ask carries no save), bun run check.
There was a problem hiding this comment.
The project-wide allow from an "always" approval is fixed: creation no longer passes save, so approving a check grants nothing beyond the schedule, and the test now checks that save is absent. test/schedule.test.ts passes (34 tests) and tsc -b in packages/core is clean. Public API: unchanged since the last review. I have no further concerns.
Resolves conflicts in generated migration files after #59457 and newer migrations landed on dev. The branch's migration is regenerated as 20261029140544_schedule_condition so it sorts after dev's migrations; it still only adds the nullable schedule.if and schedule.state columns. Verified: bun typecheck (core), test/schedule.test.ts and test/tool-schedule.test.ts (41 pass).
…route #59457 added the experimental schedule.create route, and Schedule.create can now fail with Permission.BlockedError or Permission.CorrectedError when a code condition's allowlisted tools are denied or rejected at creation. Map both to ForbiddenError so the route's error type stays within the API contract. Verified: bun typecheck (server, core), server test/schedule.test.ts.
There was a problem hiding this comment.
The earlier save point is still fixed after the merge from dev. The experimental create route now maps permission refusals to 403. test/schedule.test.ts passes in core (34 tests) and server, and tsc -b is clean in packages/server.
Public API: in the experimental /api/experimental/schedule routes, the returned Schedule.Info gains an optional if, and lastOutcome gains the value checked. Both are additive; strict clients that list every lastOutcome value will see the new one. The create request (Schedule.Request) doesn't accept if yet, so conditions can't be created over HTTP, and the new 403 mapping is never hit for now. When if is added, note that a plain rejection (Permission.DeclinedError) dies instead of being caught, so it would likely surface as a 500 rather than a 403.
Builds on the scheduling PR (#59401). A schedule can now take a condition,
if: { type: "code", code, allow }, that runs before each firing with no model call. The program sees only the allowlisted tools, getsstate,nowandruns, and returns{ wake, state, message? }. If it doesn't wake, the schedule recordschecked, saves its state and moves on. If it wakes, the schedule sends its own prompt with the message added after it. Failures back off like a failed prompt.The user approves each allowlisted tool once when the schedule is created. Later runs never ask, other permission asks are denied, and configured denies still win.
ifis a tagged union, so shell checks can be added later. This PR adds no agent tools; #59457 does.Verified with
bun typecheck,test/schedule.test.ts(also with--rerun-each 5), the permission, codemode and mcp tests, andbun run check.Added by the opencode bot (AI-generated).
Related: #41906, #49842
Public API: experimental schedule routes: Schedule.Info gains optional
ifand lastOutcomechecked(additive)