mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-02 12:36:24 +00:00
8541b4b7c0
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
227 lines
8.4 KiB
TypeScript
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'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>
|
|
</>
|
|
);
|
|
}
|