Skip to content

feat(coding-agent): session-bound scheduled tasks with one-shot and interval schedules - #352

Merged
liuedcson merged 5 commits into
mainfrom
feat/desktop-cron-tasks
Nov 7, 2026
Merged

liuedcson merged 5 commits into
mainfrom
feat/desktop-cron-tasks

Conversation

@liuedcson

@liuedcson liuedcson commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

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)

CronJob gains optional fields a host may persist: sessionId, workspace,
title, status, inputs, model, delivery, expiresAt, origin,
intervalHours, runAt. schemaVersion: 2 marks 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)

runAt schedules one absolute fire time and intervalHours an explicit
interval, both overriding the expression for scheduling while the expression is
still validated. A task with a sessionId is delivered through the host's
CronRuntimeLike facade instead of the extension's own message channel.

3. One schedule shape per task

Before: a task could hold runAt and intervalHours at once, and update()
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: create and update reject the mixed shape, and converting clears the
field that no longer applies. runAt and expiresAt are validated as finite
numbers; 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

ownsJob restricts delivery, listing and mutation to records the host owns; the
CLI session uses it to ignore tasks bound to a desktop conversation it cannot
see. requireApproval makes the approval gate a host capability: the CLI and
headless modes keep scheduling, and a host that asks but has no dialog surface
is refused. ExtensionUIDialogOptions.once keeps a standing session grant from
covering a later dialog about a durable object.

6. Cleanup

withLiveContext reads the extension context from timer callbacks without
throwing when a session replacement made it stale; an uncaught throw there took
down the host. scheduled-session.ts is removed: it widened a session origin
type 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) and CRON_MAX_TITLE_LENGTH (200) are exported
so a host can share them, and they keep their original values. Narrowing the
prompt ceiling would have been a data loss: parsePersistedJob validates loaded
records 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.ts from packages/coding-agent: 113 passed.
  • tsgo -p tsconfig.json --noEmit: clean.
  • Biome check and every root guard (check:ts-imports, check:layer-direction, check:coding-agent-entry-freeze, check:public-boundary, and the rest of pnpm run check's guard list): pass. The pre-commit hook runs them.
  • New tests cover: once <-> repeating conversion in both directions, a repeating task rejecting runAt, 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_update stays unregistered on the CLI, so its tool
surface is unchanged.

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.
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.
@liuedcson
liuedcson merged commit 44587bf into main Nov 7, 2026
2 checks passed
@liuedcson
liuedcson deleted the feat/desktop-cron-tasks branch November 7, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant