Repository navigation
feat(coding-agent): session-bound scheduled tasks with one-shot and interval schedules - #352
Merged
Merged
Conversation
A task could carry both a one-shot time (`runAt`) and a repeating interval, and `update()` preferred whichever it found first. Converting a one-shot task into a repeating one therefore kept the old absolute time, so the next fire was computed from a schedule that no longer applied; converting back lost the interval. Reject a mixed shape at both ends and clear the field that no longer applies. Other defects fixed in the same pass, all in the shared cron runtime: - `runAt` and `expiresAt` are validated everywhere. An unvalidated value used to reach the runtime and made the whole record unreadable after a restart. - An expired or completed task reopens only when an edit restores a future schedule. Re-enabling a one-shot whose time has passed requires a new time, because the next tick would otherwise deliver it immediately. - `CRON_MAX_PROMPT_LENGTH` and `CRON_MAX_TITLE_LENGTH` are exported so a host and the agent tools enforce the same cap. A task the agent could create must also be editable in a UI that validates it. - `ownsJob` lets a host deliver and mutate only the records it owns. A CLI session uses it to ignore tasks bound to a desktop conversation, which it could neither see nor deliver into. - Approval is a host capability (`requireApproval`). The CLI and headless modes keep scheduling; a host that asks but has no dialog surface is refused rather than silently allowed. - `ExtensionUIDialogOptions.once` keeps a standing session grant from covering a later dialog about a durable object. - Timer callbacks read the extension context through `withLiveContext`. A context made stale by a session replacement throws on access, and an uncaught throw in a timer takes down the host instead of skipping one notification. - Remove `scheduled-session.ts`, which only widened a session `origin` type for a value no consumer used. Session headers keep the child-agent marker. Tests cover the schedule-shape conversions, time validation, reopening a finished task, host ownership boundaries, and the shared limits.
liuedcson
requested review from
MelodyVAR,
Uking-xxx and
ZouR-Ma
as code owners
November 7, 2026 11:42
The previous commit narrowed `CRON_MAX_PROMPT_LENGTH` from 16,000 to 8 KiB and `CRON_MAX_TITLE_LENGTH` from 200 to 30, on the reasoning that a host form should not enforce a stricter cap than the agent tools. The reasoning was right; the change was in the wrong layer. Both constants were 16,000 / 200 since the runtime was first written, and `parsePersistedJob` validates loaded records against the same prompt value. A task saved before the change with a 10 KB prompt would therefore be rejected as an unsupported record on the next start and silently stop being scheduled — the narrowing would have deleted data without saying so. A product that wants a shorter prompt for its own form applies that cap at its own boundary. The kernel keeps the value the reader shares. Also record what the two do on overflow, which was never written down: an oversized prompt is rejected, an oversized title is truncated. A title is presentation; a prompt is the work.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Cron jobs were expression-only and tied to the session that created them. A host
that wants a task to act as a reminder in an existing conversation needs a task
bound to a session id, one-shot absolute times, and explicit intervals. Those
capabilities land here, together with the fixes found while reviewing the first
host that used them.
The three feature commits are the kernel side of that work. The last two
commits correct the defects the review found; each is a behavior change in the
shared runtime, not a host workaround.
Implementation
1. Task metadata (
feat/step-cron.ts,core/session-manager.ts)CronJobgains optional fields a host may persist:sessionId,workspace,title,status,inputs,model,delivery,expiresAt,origin,intervalHours,runAt.schemaVersion: 2marks a record that carries them;a v1 record still loads unchanged.
SessionManager.persistNewSessionHeader()writes a host-created session before its first turn so it can be reopened after
a restart.
2. Session-bound delivery and intervals (
feat/step-cron.ts)runAtschedules one absolute fire time andintervalHoursan explicitinterval, both overriding the expression for scheduling while the expression is
still validated. A task with a
sessionIdis delivered through the host'sCronRuntimeLikefacade instead of the extension's own message channel.3. One schedule shape per task
Before: a task could hold
runAtandintervalHoursat once, andupdate()computed the next fire from whichever it found first. A one-shot task converted
to a daily one kept the old absolute time, so it either fired immediately or not
until that time passed.
After:
createandupdatereject the mixed shape, and converting clears thefield that no longer applies.
runAtandexpiresAtare validated as finitenumbers; a one-shot time must be in the future.
4. Lifecycle
An expired or completed task reopens only when the same edit restores a future
schedule. Re-enabling a one-shot whose time has passed requires a new time
instead of delivering on the next tick.
5. Host boundaries
ownsJobrestricts delivery, listing and mutation to records the host owns; theCLI session uses it to ignore tasks bound to a desktop conversation it cannot
see.
requireApprovalmakes the approval gate a host capability: the CLI andheadless modes keep scheduling, and a host that asks but has no dialog surface
is refused.
ExtensionUIDialogOptions.oncekeeps a standing session grant fromcovering a later dialog about a durable object.
6. Cleanup
withLiveContextreads the extension context from timer callbacks withoutthrowing when a session replacement made it stale; an uncaught throw there took
down the host.
scheduled-session.tsis removed: it widened a sessionorigintype for a value no consumer read, and session headers keep the child-agent
marker.
Limits stay where they were
CRON_MAX_PROMPT_LENGTH(16,000) andCRON_MAX_TITLE_LENGTH(200) are exportedso a host can share them, and they keep their original values. Narrowing the
prompt ceiling would have been a data loss:
parsePersistedJobvalidates loadedrecords against the same constant, so an existing task with a longer prompt
would be rejected as an unsupported record and silently stop being scheduled.
A product that wants a shorter prompt for its own form applies that cap at its
own boundary. The kernel keeps the value its reader shares.
The two also differ on overflow, which was never written down: an oversized
prompt is rejected, while an oversized title is truncated. A title is
presentation; a prompt is the work.
Verification
pnpm exec vitest --run test/step-cron.test.ts test/subagent-child-env.test.ts test/step-permissions.test.tsfrompackages/coding-agent: 113 passed.tsgo -p tsconfig.json --noEmit: clean.checkand every root guard (check:ts-imports,check:layer-direction,check:coding-agent-entry-freeze,check:public-boundary, and the rest ofpnpm run check's guard list): pass. The pre-commit hook runs them.once <-> repeatingconversion in both directions, a repeating task rejectingrunAt, past/non-finite times on create and update, reopening an expired task by extending its expiry, reopening a completed one-shot by giving it a new time, host ownership boundaries, the kernel limits and their truncate-vs-reject difference, and the approval gate being opt-in per host.Boundary
This change is the kernel only. The storage location, migration from an older
layout, the desktop scheduler, and the task UI live in the desktop branch and
are not part of this PR.
cron_updatestays unregistered on the CLI, so its toolsurface is unchanged.