SIGN IN SIGN UP

AutoGPT is the vision of accessible AI for everyone, to use and to build on. Our mission is to provide the tools, so that you can focus on what matters.

0 0 73 Python

feat(backend/copilot): native scheduling for copilot turn followups (#13190)

## Why

AutoPilot has no first-class way to defer work. When the user asks
*"check the CI in 20 minutes and fix any issues"* or *"send that draft
at 7am"*, today the model either:

- tries to `sleep` / spin and the sandbox kills the turn before the
  time arrives,
- calls Claude Code's `ScheduleWakeup` built-in, which is a no-op
  outside a `/loop` runtime (disabled in #13187), or
- forces the user to leave the conversation, build a graph by hand,
  and schedule it — which strips the conversation context the
  follow-up actually needs.

We want a first-class way for the copilot to defer a turn against the
same session, so the conversation resumes with full history.

## What

Native scheduling for **two kinds** of jobs, unified under the
existing scheduler infrastructure:

- `kind="graph"` — existing graph executions. No behavior change for
  any caller. Back-compatible because old persisted rows have no
  `kind` field and default to `"graph"` on load.
- `kind="copilot_turn"` (NEW) — re-fires a copilot turn against a
  `session_id` with the original message. Supports both `cron`
  (recurring) and `run_at` (one-shot DateTrigger, auto-removed after
  fire).

New `schedule_followup` MCP tool the model calls to defer:

```text
schedule_followup(
  message="Check CI on PR #13187 and fix any failing checks.",
  delay_seconds=1200,    # OR cron="0 9 * * 1" for recurring
)
```

Tool description tells the model the turn ends after the call — no
more spinning while the time elapses.

## How

**Storage.** APScheduler with `SQLAlchemyJobStore` already persists
job kwargs as JSON in `apscheduler_jobs`. Added a `kind` discriminator
to the args models. Existing rows that predate the discriminator load
as `kind="graph"` via the default. No Prisma migration.

**Dispatch.** Sibling to `execute_graph` —
`execute_copilot_turn(**kwargs)`
which APScheduler invokes for copilot-turn jobs. Both at module level
because APScheduler serializes the function reference.

**Scheduler service endpoints:**
- `add_copilot_turn_schedule(...)` — NEW
- `delete_graph_execution_schedule` — refactored to be polymorphic
  (works on either kind); endpoint name kept for wire back-compat.
  Returns `GraphExecutionJobInfo | CopilotTurnJobInfo`.
- `get_graph_execution_schedules` — unchanged behavior, returns only
  graph kinds (for legacy typed callers).
- `get_execution_schedules` — NEW polymorphic endpoint, returns
  both kinds with optional `session_id` / `kind` filters.

**Client.** `SchedulerClient` gains `add_copilot_turn_schedule` and
`get_graph_execution_schedules`. `get_execution_schedules` rebound to
the new polymorphic endpoint. Existing graph-only callers
(`_fetch_schedule_info`, `_cleanup_schedules_for_graph`, two v1.py
schedule-list routes, four diagnostics readers) switched to
`get_graph_execution_schedules` so they keep their typed
`list[GraphExecutionJobInfo]` contract.

**Copilot tools.** `list_schedules` and `delete_schedule` are
polymorphic. `ScheduleSummary` carries `kind`, `run_at`, `session_id`,
`message` alongside the existing `graph_id`/`graph_version`.

### Operational hardening

These came out of bot review + E2E testing during the PR's lifecycle
and are worth calling out:

- **Stream registry + concurrency.** Scheduled turns route through
  `schedule_turn` (not raw `enqueue_copilot_turn`), so they acquire
  the per-user concurrency slot and register the session in the
  stream registry before publishing. Output is never orphaned.
- **Concurrency-cap retry.** If a one-shot schedule fires while the
  user is at their concurrency cap, `_reschedule_one_shot_after_cap`
  creates a new schedule 5 min out (single sentinel-bounded retry so
  it cannot loop forever). Cron schedules naturally retry on the
  next tick.
- **Dead-session cleanup.** Before enqueuing, `_execute_copilot_turn`
  checks `get_chat_session`. If the user deleted the conversation
  between scheduling and firing, the schedule self-deletes via
  `_self_delete_copilot_turn_schedule` — no orphan turn appears.
- **Tool empty-args UX.** The `_make_truncating_wrapper` empty-args
  guard now only triggers when the tool actually has required
  parameters. `list_schedules` (filters-only) is callable with no
  arguments, so the model can list everything in one shot.
- **Auth-on-malformed-delete.** `delete_graph_execution_schedule`
  refuses to remove rows whose kwargs parse as neither kind —
  there's no `user_id` to authorize against, so a 404 is safer than
  blindly removing by id.
- **Defensive `next_run_time`.** `_next_run_time_iso` helper returns
  `""` for jobs APScheduler has already cleared (the post-fire state
  of one-shot DateTrigger jobs), preventing AttributeError on
  delete after fire.
- **No payload leak in logs.** Schedule-creation log no longer
  echoes `input_data` — user-supplied prompts/PII don't reach
  unbounded-retention scheduler logs.
- **Local cron validation.** `schedule_followup` validates `cron`
  locally with `CronTrigger.from_crontab` before calling the
  scheduler RPC, so the model gets a clean `invalid_cron` error
  instead of an opaque `RemoteError`.
- **Telemetry.** `track_followup_scheduled` PostHog event mirrors
  `track_agent_scheduled` for adoption + abuse visibility.

## Out of scope (deliberately)

- Frontend rendering of copilot-turn schedules in the schedules UI
  (tools work end-to-end via list/delete; UI styling is a separate
  concern).
- Cancel-replace semantics ("actually do it at 8am, not 7am") —
  model can call `list_schedules` → `delete_schedule` →
  `schedule_followup` today; a one-call replace can be added if the
  pattern shows up.
- Per-session quota cap. APScheduler ID uniqueness is the only
  current rate limit; a per-user max can come later if abuse
  surfaces.

## Tests

- `backend/copilot/tools/schedule_followup_test.py` — auth, missing
  message, mutual exclusion of `cron`/`delay_seconds`, min-delay,
  one-shot success, recurring success.
- `backend/copilot/tools/manage_schedules_test.py` — extended:
  `list_schedules` returns graph kind, copilot_turn kind, and mixed.
- `backend/copilot/sdk/tool_adapter_test.py` — both branches of the
  truncation guard (`required_args=["x"]` triggers,
  `required_args=[]` allows).
- `backend/executor/scheduler_test.py` — extended: end-to-end
  `add_copilot_turn_schedule` → list → delete; polymorphic filter
  smoke; cron/run_at validation.

E2E manual test (`/pr-test`) verified: schedule_followup end-to-end
with `delay_seconds=60`, scheduled wake-up fires and enqueues a new
turn (verified in transcript), list_schedules returns both kinds,
delete works for both kinds, one-shot graph via `run_at` fires once
then auto-removes.

Schema char budget bumped 35500 → 36500 (LLM-decision-critical copy:
delay vs cron disambiguation, "ends your turn" caveat, example
message).

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Z
Zamil Majdy committed
7891eddb042f7d9dafbd01063ab0b77bb23b6c60
Parent: 37554ac
Committed by GitHub <noreply@github.com> on 5/23/2026, 10:35:27 AM