Files
handler/frontend/components/sections/SchedulesSection.tsx
T
Claude 8541b4b7c0 Agents can hand work to agents: dispatch_agent
A schedule is a time trigger, and only the first step of a pipeline is really
waiting on time — every later step waits on the previous step's result. Modeling
"watch a source -> write a spec -> implement it" as three schedules made each fire
blind: on a quiet day the coding agent still spawned, paid a full model run to find
there was nothing to do, and left an empty run in Activity.

So an agent can start the next step itself. `dispatch_agent` (a tool on the bundled
MCP server, and on the pi bridge through the same --call seam) enqueues an ordinary
spawn command in the agent's own project, tagged requested_by=agent:<id> — so a
handoff is visible in Activity with no new surface to build.

- Project-scoped by construction: project_id is read from the spawn environment and
  never from the tool arguments.
- Bounded rather than gated: MAX_DISPATCH_PER_RUN counts the command rows the agent
  already wrote; MAX_DISPATCH_DEPTH rides in the spawn payload and is recovered by
  spawn._dispatch_depth, so a chain keeps its place across a resume and a cycle
  terminates instead of fanning out.
- New scout and planner roles, with built-in skills (handler-scout, handler-planner,
  handler-dispatch) carrying the judgment code can't: dedupe against a memory-note
  watermark, treat "nothing new" as a complete run, and write a task the receiving
  cold-start agent can act on.
- A scout ending on a clean tree skips the test gate and records the new
  tests_status='skipped' (migration 0017, additive CHECK widening). The gate promises
  `done` means tests passed for the work that shipped; nothing shipped.

Rejected a `condition` field on schedules: "is this paper new and does it matter
here?" is a semantic judgment, so it belongs to a model, not a scheduler column. The
scout is the condition; dispatch is how it reports true — one mechanism that covers
future pipelines too.

426 tests (14 new for dispatch, 3 for the gate exemption).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcbDevyMcJWE6qPA56C7mZ
2026-08-19 22:46:06 +00:00

227 lines
8.4 KiB
TypeScript

/* Schedules — recurring agent spawns. Every interval the worker starts a fresh,
* stateless agent named <prefix>-<timestamp> with the stored prompt. The canonical
* pattern: keep state in a file in the repo and have the prompt continue from it. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input, Select, Textarea, Toggle } from "@/components/ui";
import { fmtFull } from "@/lib/format";
const ROLE_OPTS = [
{ value: "", label: "Role — none" },
{ value: "scout", label: "scout" },
{ value: "planner", label: "planner" },
{ value: "junior", label: "junior" },
{ value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" },
];
const INTERVAL_OPTS = [
{ value: "900", label: "every 15 minutes" },
{ value: "1800", label: "every 30 minutes" },
{ value: "3600", label: "every hour" },
{ value: "21600", label: "every 6 hours" },
{ value: "86400", label: "every day" },
{ value: "604800", label: "every week" },
];
const TASK_PLACEHOLDER =
"Read @notes.md and continue from where it left off. Before finishing, overwrite " +
"@notes.md with the current state so the next run can pick up from there.";
function intervalLabel(seconds: number): string {
const opt = INTERVAL_OPTS.find((o) => Number(o.value) === seconds);
if (opt) return opt.label;
if (seconds % 3600 === 0) return `every ${seconds / 3600}h`;
if (seconds % 60 === 0) return `every ${seconds / 60}m`;
return `every ${seconds}s`;
}
const emptyForm = { name_prefix: "", task: "", interval: "3600", role: "", model_id: "" };
export function SchedulesSection() {
const s = useDashboard();
const [form, setForm] = useState(emptyForm);
const projectOpts = useMemo(
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
[s.projects],
);
/* Same choice the spawn form offers: Claude subscription by default, plus every
* enabled backend from the Claude page's Models tab. Every fired run spawns on it. */
const modelOpts = useMemo(
() => [
{ value: "", label: "Claude (subscription)" },
...s.claudeModels
.filter((m) => m.enabled)
.map((m) => ({ value: String(m.id), label: `${m.name} (${m.model})` })),
],
[s.claudeModels],
);
const modelName = useMemo(() => {
const byId = new Map(s.claudeModels.map((m) => [m.id, m.name]));
return (id: number | null | undefined) => (id == null ? null : byId.get(id) ?? `#${id}`);
}, [s.claudeModels]);
const create = async () => {
const ok = await s.createSchedule(s.selectedProjectId, {
name_prefix: form.name_prefix,
task: form.task,
interval_seconds: Number(form.interval),
role: form.role,
model_id: form.model_id,
});
if (ok) setForm(emptyForm);
};
return (
<>
<div className="section-head">
<div className="section-title">Schedules</div>
<div className="section-desc">
Spawn a fresh agent on an interval. Each run is stateless keep continuity in a
file the prompt reads and overwrites.
</div>
</div>
<div className="section-body">
{s.projects.length === 0 ? (
<div className="empty">Register a repository first.</div>
) : (
<>
<div className="row">
<div style={{ width: 260 }}>
<Select
label="Repository"
value={s.selectedProjectId}
onChange={s.selectProject}
options={projectOpts}
/>
</div>
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
New schedule
</span>
</div>
<div className="form-grid">
<Input
label="Name prefix"
value={form.name_prefix}
onChange={(v) => setForm({ ...form, name_prefix: v })}
placeholder="nightly"
/>
<Select
label="Interval"
value={form.interval}
onChange={(v) => setForm({ ...form, interval: v })}
options={INTERVAL_OPTS}
/>
<Select
label="Role"
value={form.role}
onChange={(v) => setForm({ ...form, role: v })}
options={ROLE_OPTS}
/>
<Select
label="Model"
value={form.model_id}
onChange={(v) => setForm({ ...form, model_id: v })}
options={modelOpts}
/>
</div>
<div className="mt14">
<Textarea
label="Prompt (the task every run starts with)"
value={form.task}
onChange={(v) => setForm({ ...form, task: v })}
rows={3}
placeholder={TASK_PLACEHOLDER}
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
Runs are named <span className="mono">{form.name_prefix.trim() || "prefix"}-YYYYMMDD-HHMMSS</span>.
The repo is pulled before every run; the first run fires on the worker&apos;s next
pass.
</p>
<div className="hstack mt14">
<Button
variant="primary"
disabled={s.cmd.busy || !form.name_prefix.trim() || !form.task.trim()}
onClick={create}
>
Create schedule
</Button>
</div>
</Card>
{s.schedules.length === 0 ? (
<div className="empty">No schedules yet.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>On</th>
<th>Name</th>
<th>Repository</th>
<th>Interval</th>
<th>Prompt</th>
<th>Next run</th>
<th>Last run</th>
<th />
</tr>
</thead>
<tbody>
{s.schedules.map((sc) => (
<tr key={sc.id}>
<td>
<Toggle
on={sc.enabled}
onClick={() => s.updateSchedule(sc.id, { enabled: !sc.enabled })}
/>
</td>
<td className="mono">
{sc.name_prefix}
{sc.role ? (
<>
{" "}
<Badge tone="info">{sc.role}</Badge>
</>
) : null}
{sc.model_id != null ? (
<>
{" "}
<Badge tone="warning">{modelName(sc.model_id)}</Badge>
</>
) : null}
</td>
<td className="mono faint">{sc.project_id}</td>
<td className="nowrap">{intervalLabel(sc.interval_seconds)}</td>
<td className="faint" style={{ maxWidth: 340 }}>
<span className="truncate" style={{ display: "block" }} title={sc.task}>
{sc.task}
</span>
</td>
<td className="faint nowrap">{sc.enabled ? fmtFull(sc.next_run_at) : "paused"}</td>
<td className="faint nowrap">{fmtFull(sc.last_run_at)}</td>
<td className="nowrap">
<Button size="sm" variant="danger" onClick={() => s.deleteSchedule(sc.id)}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</>
);
}