Skip to content

feat(core): gate scheduled prompts with shell checks - #60137

Merged
kitlangton merged 2 commits into
devfrom
schedules-shell-checks
Oct 29, 2026
Merged

kitlangton merged 2 commits into
devfrom
schedules-shell-checks

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Schedules can now run a shell check before waking a session: wait once with until ("wake me when CI passes") or gate a recurring schedule with if, without spending a model call on quiet polls.
Builds on #59401 and #59457. Everything stays behind experimental.schedules, and plain prompt schedules behave the same.

💡 Why
Waiting on a build, pull request check, or remote branch with a plain repeating schedule spends a full model call on every tick just to learn nothing changed.

🆕 Now you can

  • Wait once for a shell condition with until: { run: "gh pr checks 123" } (when defaults to every 30 seconds, ends.after defaults to 24 hours). If the check already passes at creation, schedule_create finishes immediately with its output instead of queuing a wake.
  • Gate a recurring schedule with if: { run: "...", changes: true } (or is / matches, down to a 15-second interval). Quiet checks record checked and leave ends.times untouched so times counts wakes, not silent polls.
  • Trust unattended execution: schedule_create scans subcommands and external directories up front, prompts at most once (without saving a project-wide grant), rechecks live deny rules on every tick, pauses after 3 failed checks (waking once with the error), and wakes with expired if an until deadline passes without matching.

🚦 How it ships
Off by default behind experimental.schedules, same as #59401 and #59457.

  schedule due
+   -> has `if` or `until`? run shell check with narrow unattended rules (no model call)
+      -> quiet (`wake: false`) -> save state, keep `ends.times`, advance (or wake `expired` at deadline)
+      -> 3 check errors in a row -> pause and wake once with the error
    -> wake -> send idempotent prompt `msg_<schedule>_<run>` with check output appended

📊 Proof: 40 core schedule and tool tests (8 new shell-check suites covering immediate t=0 match, default 30s/24h until, expired deadline wake, changes baseline seeding and wake-only ends.times, is/matches, one-time approval + revoked permission 3-strike pause, Plugin.awaitActivation + missing directory auto-pause, and input validation), 108 permission tests, 41 client tests, and 1 end-to-end server test (including immediate match and 403 on stateless HTTP ask).

↩️ Undo: revert isn't enough on its own once a database runs this build, because migration 20261029061045_schedule_checks adds nullable check and state plus checks and failures columns to schedule. Older builds ignore the extra columns unless experimental.schedules is turned on.

👀 Read: packages/core/src/schedule/check.ts (authorize and runShell) and fire in packages/core/src/schedule.ts. Creation runs without save so approving a check never writes a project-wide allow, and each tick re-evaluates permissions with Permission.Unattended after live deny rules. The rest is routine (schema, OpenAPI, generated client).

Planned feature · no user reports · not measured

Checks run
  • bun run script/test.ts test/schedule.test.ts test/tool-schedule.test.ts test/permission.test.ts (core): 148 pass, 0 fail
  • bun test test/schedule.test.ts (server): 1 pass, 0 fail
  • bun test test/promise.test.ts (client): 41 pass, 0 fail
  • bun typecheck in core, server, protocol and client: 0 errors
  • bun run script/migration.ts --check (core) and bun run check:generated (protocol): pass
  • oxlint, ast-grep Effect patterns on changed files, and bun script/sdk-docs.ts: 0 errors

Added by the opencode bot (AI-generated).

Public API: adds optional if/until to schedule create (makes when optional), adds lastOutput/if/until to Schedule.Info and checked/expired outcomes (additive; clients that match every outcome need the new values)

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shell-gated schedules mostly work as described. The core schedule, tool and permission tests pass (148), the server schedule test passes, and the core type check is clean. One creation-time behaviour should change before merge (see the inline comment): a check whose condition isn't met yet can be rejected at creation, and whether that happens depends on when. I confirmed this with a temporary test using the PR's fake shell.

The tests could also be smaller. The new suites rebuild a large part of the location layer (a fake spawner, Agent, Permission and FileAccess) and add about 340 lines to schedule.test.ts. Cutting them down to the main cases (immediate match, poll then wake, expired deadline, changes baseline, 3-failure pause) would make them easier to maintain.

Public API:

  • HTTP POST schedule and schedule_create: when changes from required to optional. New optional if and until ({ run, is?, matches?, changes? }). Creation can now return 403 when a check needs approval.
  • Schedule.Info: adds if, until and lastOutput.
  • Schedule.Outcome: adds "checked" and "expired".
  • Core: exports Permission.Unattended.
  • Database: migration adds the check, state, checks and failures columns.

These are additions, so existing callers keep working. Two exceptions: clients that switch over every Outcome need the two new values, and TypeScript code that reads Request.when or CreateInput.when must now handle undefined.

),
),
)
const shouldSeed =

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When a check uses is, matches or changes, a non-zero exit counts as a check error. Here, that error at creation makes create fail with InvalidError (line 657). Whether the check runs at creation depends on when, so the same check gives different results. With a command exiting 1 and if: { run, is: "up" }, every: "1 minute" is rejected but a cron is accepted.

The until case hurts most, since its job is to wait for a condition that isn't true yet. For example, until: { run: "curl -sf …", is: "ready" } can't be created while the service is down. Could a failed seed count as "not yet" (like a quiet tick), with creation rejecting only syntax and permission problems? Running the check at creation for if + every without changes doesn't seem needed either.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The new commit fixes the creation problem from my last review. A failed shell check when a schedule is created now counts as "not yet" instead of rejecting it, and if + every no longer runs the check at creation, so every and cron behave the same. Permission and syntax problems are still rejected earlier, during authorization. Core schedule and tool tests pass (40) and the core type check is clean. Nothing new to raise. The public API is unchanged since the last review.

@kitlangton
kitlangton force-pushed the schedules-shell-checks branch from 2f21615 to 64d6f83 Compare October 29, 2026 16:58

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This push is a rebase onto the latest dev: the only difference is that the schedule-checks migration was renamed to 20261029165540_schedule_checks and now comes after session_time_busy. The fix for my earlier point (a failed check at creation counts as "not yet") carries over unchanged. Core schedule, tool and permission tests (148) and the server schedule test pass, and the core type check is clean. Nothing new to raise; the public API is unchanged since the last review.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant