From 771893066d1e25c8832d2d13f99c3feaaaedff99 Mon Sep 17 00:00:00 2001 From: Gregory Fong Date: Sat, 21 Mar 2026 02:56:18 -0700 Subject: [PATCH 1/2] feat(autopilot): continuous mode, hourly cron, plan IDs, cycle API - Daily vs continuous autopilot_mode on PROJECT; cycle_* + next_check_at fields - Continuous: timed cycles, auto-approve plans, next_check_hours pacing, failure circuit breaker, human-only pause, Discord webhook (DISCORD_WEBHOOK_URL on EC2) - Plan SK: PLAN#YYYY-MM-DDTHH:MM:SS UTC (legacy date SK still works) - Lambda hourly; daily projects only dispatched at 07 UTC (Lambda + EC2 guard) - API: /plans/:planId/*, POST .../cycle/start|stop|review - run_task.py: --plan-suffix for regenerate; SSM passes optional suffix - UI: Off/Daily/Continuous, cycle controls, plan timeline (continuous) Made-with: Cursor --- .cursor/rules/project-conventions.mdc | 16 +- AGENTS.md | 2 +- docs/dynamo-schema.md | 17 +- frontend/AGENTS.md | 4 +- frontend/src/lib/api.ts | 45 +- frontend/src/lib/types.ts | 13 +- frontend/src/pages/ProjectCreate.tsx | 28 +- frontend/src/pages/ProjectDetail.tsx | 491 ++++++++++++++++++---- infra/packages/api/src/lib/dynamo.ts | 104 ++++- infra/packages/api/src/lib/ssm.ts | 11 +- infra/packages/api/src/lib/types.ts | 23 +- infra/packages/api/src/routes/projects.ts | 165 ++++++-- infra/packages/autopilot/src/index.ts | 16 +- infra/sst.config.ts | 4 +- run_task.py | 24 +- src/AGENTS.md | 4 +- src/autopilot.py | 471 +++++++++++++++++++-- src/projects_dynamo.py | 62 ++- 18 files changed, 1278 insertions(+), 222 deletions(-) diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index a83a548..544cc0c 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -19,7 +19,7 @@ There is no bare `python` or `python3` in `$PATH` that has project dependencies. ## Architecture - `frontend/` — React SPA (Vite + TypeScript + Tailwind + shadcn/ui) -- `infra/` — SST IaC: DynamoDB table, Hono API Lambda (TypeScript), Watchdog Lambda, Digest Lambda (daily cron → Discord), Autopilot Lambda (daily plan proposal) +- `infra/` — SST IaC: DynamoDB table, Hono API Lambda (TypeScript), Watchdog Lambda, Digest Lambda (daily cron → Discord), Autopilot Lambda (hourly tick; daily-mode projects filtered to 07 UTC) - `src/web.py` — FastAPI backend: JSON API + SPA serving (EC2, being replaced by Lambda API) - `src/bot.py` — Discord bot with slash commands (discord.py) - `src/task_store.py` — Shared task types (`Task`, enums, `Comment`) @@ -35,7 +35,7 @@ There is no bare `python` or `python3` in `$PATH` that has project dependencies. - `run_task.py` — Runner entry point (runs a single task or reply) - `run_task.py --directive ` — Directive decomposition entry point (called by SSM) - `run_task.py --daily-cycle ` — Daily observe/reflect/propose cycle entry point (called by Metrics Lambda via SSM) -- `run_task.py --propose-plan [--regenerate]` — Autopilot plan proposal entry point (called by Autopilot Lambda via SSM) +- `run_task.py --propose-plan [--regenerate] [--plan-suffix ]` — Autopilot plan proposal entry point (called by Autopilot Lambda via SSM; `plan-suffix` for regenerating a specific `PLAN#` row in continuous mode) - `run_poller.py` — Polling daemon: checks DynamoDB every `POLL_INTERVAL` seconds for pending tasks and reply-pending comments, spawns `run_task.py` subprocesses (systemd service) - `run_heartbeat.py` — Heartbeat dispatcher entry point (called by cron) @@ -141,7 +141,7 @@ Projects are long-lived entities (`pk=PROJECT#`, `sk=PROJECT`) with a title, - `project-list-index` (`proj_status`, `project_updated`) — list projects by status ### Hono project routes (`infra/packages/api/src/routes/projects.ts`) -`GET /api/projects`, `GET /api/projects/:id`, `POST /api/projects`, `PATCH /api/projects/:id`, `DELETE /api/projects/:id`, `POST /api/projects/:id/directive`, `GET /api/projects/:id/snapshots`, `GET /api/projects/:id/proposals`, `PATCH /api/projects/:id/proposals/:propSk`, `GET /api/projects/:id/requests`, `PATCH /api/projects/:id/requests/:reqSk`, `GET /api/projects/:id/plans`, `GET /api/projects/:id/plans/:date`, `PATCH /api/projects/:id/plans/:date`, `POST /api/projects/:id/plans/:date/approve`, `POST /api/projects/:id/plans/:date/regenerate` +`GET /api/projects`, `GET /api/projects/:id`, `POST /api/projects`, `PATCH /api/projects/:id`, `DELETE /api/projects/:id`, `POST /api/projects/:id/directive`, `GET /api/projects/:id/snapshots`, `GET /api/projects/:id/proposals`, `PATCH /api/projects/:id/proposals/:propSk`, `GET /api/projects/:id/requests`, `PATCH /api/projects/:id/requests/:reqSk`, `GET /api/projects/:id/plans`, `GET /api/projects/:id/plans/:planId`, `PATCH /api/projects/:id/plans/:planId`, `POST /api/projects/:id/plans/:planId/approve`, `POST /api/projects/:id/plans/:planId/regenerate`, `POST /api/projects/:id/cycle/start`, `POST /api/projects/:id/cycle/stop`, `POST /api/projects/:id/cycle/review` ### Digest Lambda `infra/packages/digest/` — scheduled Lambda (cron `0 14 * * ? *`) posts a daily summary of task activity and project progress to Discord via webhook. Includes KPI briefings, reflections, and pending proposals for projects with KPIs. @@ -150,7 +150,7 @@ Projects are long-lived entities (`pk=PROJECT#`, `sk=PROJECT`) with a title, `infra/packages/metrics/` — scheduled Lambda (cron `0 6 * * ? *`) fetches metrics for active projects with KPIs. Adapters: PageSpeed Insights (free, no auth), GitHub (existing token). Writes `SNAPSHOT#` records to DynamoDB, updates KPI current values, triggers `run_daily_cycle` on EC2 via SSM. ### Autopilot Lambda -`infra/packages/autopilot/` — scheduled Lambda (cron `0 7 * * ? *`, 7 AM UTC — after metrics/daily cycle) queries active projects with `autopilot: true` and triggers `run_task.py --propose-plan` for each on EC2 via SSM. +`infra/packages/autopilot/` — scheduled Lambda (cron hourly) queries active projects with `autopilot: true`. **Continuous** mode (`autopilot_mode: continuous`, not `cycle_paused`): triggers `run_task.py --propose-plan` on EC2. **Daily** mode: triggers only at **07:00 UTC** (same hourly cron; filtered in Lambda). EC2 `src/autopilot.py` enforces the same rules. ### Autonomous Daily Cycle Projects can define KPIs (`kpis` array on the PROJECT record). When KPIs are present, the Metrics Lambda collects data daily and triggers `run_daily_cycle()` in `src/objectives.py` on EC2. The cycle: loads 14 days of snapshots + proposals + recent tasks + human tasks in review → calls the agent (opus) with a reflection prompt → parses structured JSON → creates PROP records (proposals) and `assignee: "human"` tasks. The agent also reviews human tasks marked `in_review`, either completing them or returning them to `pending` with a comment. @@ -162,9 +162,13 @@ DynamoDB record types under `PROJECT#`: `PROJECT`, `DIR#`, `SNAPSHOT#` on EC2 via SSM. `src/autopilot.py` loads project context (spec, recent tasks, approved proposals, prior plan outcomes) and calls the agent (opus) to propose 3–6 work items. Plans are stored as `PLAN#` records with `status=proposed`. +Projects with `autopilot: true` use `autopilot_mode`: **`daily`** (default) or **`continuous`**. -**Plan lifecycle**: proposed → human reviews/edits in UI → approved (creates tasks with `directive_sk=PLAN#`) → batch runs via poller → completed (outcome summary tracked). If a human sends a directive mid-day, pending plan tasks are auto-cancelled. Stale proposed plans from prior days block new proposals until approved or regenerated. The `--regenerate` flag allows re-proposing today's plan. +- **Daily**: Plan proposal at **07:00 UTC** (Lambda + EC2 guard). One `PLAN#YYYY-MM-DD` per day; human approves in UI; tasks use `directive_sk=PLAN#…`. Stale proposed plans from prior days block new proposals until handled. `--regenerate` re-proposes today’s plan (`--plan-suffix` for a specific id in continuous). + +- **Continuous**: Human starts a **cycle** via API (`cycle_started_at`, `cycle_max_hours`). Hourly Lambda tick; EC2 may auto-approve plans, create tasks immediately, and use `PLAN#YYYY-MM-DDTHH:MM:SS` keys. Pauses when the time window expires, failure streak triggers, fully blocked on human tasks, or manual stop — Discord webhook + UI. `next_check_hours` / `next_check_at` defer planner calls when the agent returns empty items. + +**Plan lifecycle**: proposed → (daily: human approve | continuous: auto-approve) → tasks with `directive_sk=PLAN#…` → poller → batch completed. Directives cancel pending `PLAN#` tasks. Stale daily proposed plans block as before. Autopilot coexists with directives and the autonomous objectives (KPI) cycle. The daily cycle's proposals and human task outcomes feed into the autopilot's context. Directives take priority — issuing a directive cancels any pending plan tasks. diff --git a/AGENTS.md b/AGENTS.md index 478fc7f..910c6a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ Tasks flow through: **create worktree → plan subtasks → execute → update d **Autonomous objectives pipeline**: Projects with KPIs get a daily autonomous cycle. The Metrics Lambda (`infra/packages/metrics/`) runs at 6 AM UTC, fetches metrics (PageSpeed Insights, GitHub, optionally GA4/Search Console), writes SNAPSHOT records, and triggers `run_task.py --daily-cycle ` on EC2 via SSM. The daily cycle (`src/objectives.py`) loads metric history, proposals, and recent tasks, then calls the agent (opus) with a reflection prompt. The agent returns structured proposals and human requests. Proposals queue for human approval in the web UI; approved proposals become tasks in the existing pipeline. See `docs/autonomous-objectives.md`. -**Autopilot pipeline**: Projects with `autopilot: true` get a daily plan proposal. The Autopilot Lambda (`infra/packages/autopilot/`) runs at 7 AM UTC (after the metrics/daily cycle), queries active autopilot projects, and triggers `run_task.py --propose-plan ` on EC2 via SSM. `src/autopilot.py` loads the project spec, recent tasks, approved proposals, human tasks, and prior plan outcomes, then calls the agent (opus) to propose 3–6 work items for the day. The plan is stored as a `PLAN#` record with `status=proposed`. The human reviews and approves the plan in the web UI, which creates tasks that flow through the existing poller/pipeline. Plan tasks use `directive_sk=PLAN#` so the existing batch finalization detects completion. If a human sends a directive mid-day, pending plan tasks are auto-cancelled. +**Autopilot pipeline**: Projects with `autopilot: true` use `autopilot_mode` **`daily`** (default) or **`continuous`**. The Autopilot Lambda (`infra/packages/autopilot/`) runs **hourly**; **daily** projects are only dispatched at **07:00 UTC** (filtered in Lambda; EC2 enforces the same). **Continuous** projects run while a cycle is active (`cycle_started_at`, `cycle_max_hours`, not `cycle_paused`). `run_task.py --propose-plan [--regenerate] [--plan-suffix]` on EC2 calls `src/autopilot.py`, which uses the agent (opus) and `./ctx`. **Daily**: `PLAN#YYYY-MM-DD`, human approves in the UI. **Continuous**: `PLAN#YYYY-MM-DDTHH:MM:SS` UTC, auto-approved tasks, pauses + Discord on time window, failures, or human-only backlog. Plan tasks use `directive_sk=PLAN#…`. Directives cancel pending plan tasks. Every pipeline event is logged to `pipeline.log` (structured JSONL) with timestamps, task IDs, stages, runtimes, and model info. The Activity page (`/activity`) exposes this in the UI. diff --git a/docs/dynamo-schema.md b/docs/dynamo-schema.md index 318990f..c60336f 100644 --- a/docs/dynamo-schema.md +++ b/docs/dynamo-schema.md @@ -158,7 +158,14 @@ Project metadata. `id` is 8-char hex. | awaiting_next_directive | bool? | All tasks in current batch are terminal | | active_directive_sk | string? | Currently executing `DIR#` or `PLAN#` sk | | kpis | list\? | KPI definitions (id, name, source, metric, target, direction, current) | -| autopilot | bool? | Enable daily plan proposals | +| autopilot | bool? | Enable autopilot plan proposals | +| autopilot_mode | string? | `daily` (human approve, 07 UTC) or `continuous` (auto-approve, hourly tick while cycle active) | +| cycle_started_at | string? | ISO start of current continuous cycle | +| cycle_max_hours | number? | Wall-clock hours per cycle (default 24) | +| cycle_paused | bool? | Waiting for human review / manual stop | +| cycle_pause_reason | string? | `time_expired` · `blocked` · `failures` · `manual` | +| cycle_feedback | string? | Human notes for next planner pass | +| next_check_at | string? | Agent-requested deferral (ISO); empty if none | **GSI:** project-list-index (`proj_status`, `project_updated`). @@ -233,13 +240,13 @@ Durable notes written by the daily-cycle agent via `./ctx memory save` (max 50 p --- -### PLAN (Daily Plan) — `pk=PROJECT#{id} sk=PLAN#{YYYY-MM-DD}` +### PLAN (Autopilot Plan) — `pk=PROJECT#{id} sk=PLAN#…` -Autopilot daily plans. One per project per day. +Autopilot plans. Sort key is either legacy `PLAN#YYYY-MM-DD` (one per calendar day) or `PLAN#YYYY-MM-DDTHH:MM:SS` (UTC, multiple per day in continuous mode). `plan_date` is always calendar `YYYY-MM-DD` for grouping. | Attribute | Type | Description | |-----------|------|-------------| -| plan_date | string | YYYY-MM-DD | +| plan_date | string | YYYY-MM-DD (calendar day for the plan) | | status | string | `proposed` · `approved` · `executing` · `completed` | | reflection | string? | Agent's reasoning | | human_notes | string? | Human notes on approval | @@ -252,7 +259,7 @@ Autopilot daily plans. One per project per day. | outcome_summary | map? | `{completed: N, in_review: N, failed: N, cancelled: N}` | **Access:** -- Get: `GetItem(pk=PROJECT#id, sk=PLAN#date)` +- Get: `GetItem(pk=PROJECT#id, sk=PLAN#)` — suffix is date or `YYYY-MM-DDTHH:MM:SS` - List: `Query pk, sk begins_with "PLAN#"` — `ScanIndexForward=false, Limit=14` --- diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 873c7c1..8ad0108 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -28,9 +28,9 @@ src/ pages/Stats.tsx — Usage & cost (today / all-time tokens, budget bar, 14-day chart via GET /api/stats and /api/budget) pages/ProjectList.tsx — Project list with status, task progress, last directive, target repo pages/ProjectCreate.tsx — New project form: title, spec (markdown) with optional “Generate from prompt” (Bedrock via API), priority, target repo, autopilot toggle - pages/ProjectDetail.tsx — Spec editor (Generate while editing), progress bar, autopilot toggle + today’s plan section (proposed/approve/progress/completed), directive timeline, active tasks, directive composer; polls 3s while busy + pages/ProjectDetail.tsx — Spec editor (Generate while editing), progress bar, autopilot Off/Daily/Continuous + plan timeline (continuous), cycle start/stop/review, daily plan approve/regenerate, directive timeline, active tasks; polls 3s while busy pages/Login.tsx — Sign-in page - lib/api.ts — Fetch client (credentials: "include", auto-reload on 401); exports fetchRoles() → GET /api/roles; project CRUD (fetchProjects, createProject, patchProject, postProjectDirective, etc.); plan APIs (fetchPlans, fetchPlanDetail, approvePlan, regeneratePlan, patchPlanItems) + lib/api.ts — Fetch client; project CRUD; plan APIs (fetchPlans, fetchPlanDetail, approvePlan, regeneratePlan, patchPlanItems); continuous autopilot cycle (startAutopilotCycle, stopAutopilotCycle, reviewAutopilotCycle) lib/types.ts — TypeScript interfaces matching the /api/* responses; Task includes role: string, project_id, directive_sk; Project (with autopilot boolean), Directive, DailyPlan, PlanItem, ProjectListItem types lib/time.ts — timeAgo utility index.css — Tailwind imports, dark theme CSS vars, .prose-custom styles (colors use CSS var tokens, not hardcoded oklch literals) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7df082e..e151cb8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -245,6 +245,7 @@ export async function createProject(data: { target_repo?: string status?: ProjectStatus autopilot?: boolean + autopilot_mode?: "daily" | "continuous" }) { return request("/projects", { method: "POST", @@ -283,6 +284,8 @@ export async function patchProject( target_repo: string kpis: KPI[] autopilot: boolean + autopilot_mode: "daily" | "continuous" + cycle_max_hours: number }>, ) { return request(`/projects/${id}`, { @@ -354,33 +357,59 @@ export async function fetchPlans(projectId: string, limit = 14) { return request<{ plans: DailyPlan[] }>(`/projects/${projectId}/plans?limit=${limit}`) } -export async function fetchPlanDetail(projectId: string, dateStr: string) { +export async function fetchPlanDetail(projectId: string, planId: string) { return request<{ plan: DailyPlan; tasks: Task[] }>( - `/projects/${projectId}/plans/${encodeURIComponent(dateStr)}`, + `/projects/${projectId}/plans/${encodeURIComponent(planId)}`, ) } export async function patchPlanItems( projectId: string, - dateStr: string, + planId: string, body: { items: PlanItem[]; reflection?: string }, ) { - return request<{ plan: DailyPlan }>(`/projects/${projectId}/plans/${encodeURIComponent(dateStr)}`, { + return request<{ plan: DailyPlan }>(`/projects/${projectId}/plans/${encodeURIComponent(planId)}`, { method: "PATCH", body: JSON.stringify(body), }) } -export async function approvePlan(projectId: string, dateStr: string, humanNotes = "") { +export async function approvePlan(projectId: string, planId: string, humanNotes = "") { return request<{ ok: boolean; plan: DailyPlan; task_ids: string[] }>( - `/projects/${projectId}/plans/${encodeURIComponent(dateStr)}/approve`, + `/projects/${projectId}/plans/${encodeURIComponent(planId)}/approve`, { method: "POST", body: JSON.stringify({ human_notes: humanNotes }) }, ) } -export async function regeneratePlan(projectId: string, dateStr: string) { +export async function regeneratePlan(projectId: string, planId: string) { return request<{ ok: boolean }>( - `/projects/${projectId}/plans/${encodeURIComponent(dateStr)}/regenerate`, + `/projects/${projectId}/plans/${encodeURIComponent(planId)}/regenerate`, { method: "POST", body: JSON.stringify({}) }, ) } + +export async function startAutopilotCycle(projectId: string, maxHours?: number) { + return request<{ ok: boolean }>(`/projects/${projectId}/cycle/start`, { + method: "POST", + body: JSON.stringify( + maxHours != null && maxHours > 0 ? { max_hours: maxHours } : {}, + ), + }) +} + +export async function stopAutopilotCycle(projectId: string) { + return request<{ ok: boolean }>(`/projects/${projectId}/cycle/stop`, { + method: "POST", + body: JSON.stringify({}), + }) +} + +export async function reviewAutopilotCycle( + projectId: string, + opts: { feedback: string; restart?: boolean; max_hours?: number }, +) { + return request<{ ok: boolean }>(`/projects/${projectId}/cycle/review`, { + method: "POST", + body: JSON.stringify(opts), + }) +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d040524..4f50291 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -29,6 +29,10 @@ export interface Task { export type ProjectStatus = "active" | "paused" | "completed" +export type AutopilotMode = "daily" | "continuous" + +export type CyclePauseReason = "time_expired" | "blocked" | "failures" | "manual" | "" + export type KPIDirection = "up" | "down" | "maintain" export interface KPI { @@ -76,8 +80,15 @@ export interface Project { awaiting_next_directive: boolean active_directive_sk: string kpis: KPI[] - /** Daily autopilot plan (Lambda 7 AM UTC); omitted on older API responses */ + /** Autopilot enabled; omitted on older API responses */ autopilot?: boolean + autopilot_mode?: AutopilotMode + cycle_started_at?: string + cycle_max_hours?: number + cycle_paused?: boolean + cycle_pause_reason?: CyclePauseReason + cycle_feedback?: string + next_check_at?: string } export interface ProjectListItem extends Project { diff --git a/frontend/src/pages/ProjectCreate.tsx b/frontend/src/pages/ProjectCreate.tsx index 1bd84c3..1dce0ff 100644 --- a/frontend/src/pages/ProjectCreate.tsx +++ b/frontend/src/pages/ProjectCreate.tsx @@ -21,7 +21,9 @@ export default function ProjectCreate() { const [genOpen, setGenOpen] = useState(false) const [genPrompt, setGenPrompt] = useState("") const [generating, setGenerating] = useState(false) - const [autopilot, setAutopilot] = useState(false) + const [autopilotModeCreate, setAutopilotModeCreate] = useState<"off" | "daily" | "continuous">( + "off", + ) useEffect(() => { fetchRepos() @@ -42,7 +44,8 @@ export default function ProjectCreate() { spec: spec.trim(), priority, target_repo: targetRepo.trim(), - autopilot, + autopilot: autopilotModeCreate !== "off", + autopilot_mode: autopilotModeCreate === "continuous" ? "continuous" : "daily", }) toast.success("Project created") navigate(`/projects/${p.id}`) @@ -163,15 +166,18 @@ export default function ProjectCreate() {
- + +
diff --git a/frontend/src/pages/ProjectDetail.tsx b/frontend/src/pages/ProjectDetail.tsx index 3c8dd2f..c6c1854 100644 --- a/frontend/src/pages/ProjectDetail.tsx +++ b/frontend/src/pages/ProjectDetail.tsx @@ -16,6 +16,7 @@ import { Sparkles, RotateCcw, } from "lucide-react" +import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Textarea } from "@/components/ui/textarea" @@ -38,8 +39,12 @@ import { generateProjectSpec, generateProjectKPIs, fetchPlanDetail, + fetchPlans, approvePlan, regeneratePlan, + startAutopilotCycle, + stopAutopilotCycle, + reviewAutopilotCycle, } from "@/lib/api" import type { Snapshot } from "@/lib/api" import type { Project, Directive, Task, TaskStatus, KPI, DailyPlan } from "@/lib/types" @@ -186,7 +191,12 @@ export default function ProjectDetail() { const [planTasks, setPlanTasks] = useState([]) const [planNotes, setPlanNotes] = useState("") const [planActionLoading, setPlanActionLoading] = useState(false) - const [planDateUtc, setPlanDateUtc] = useState("") + /** Plan id suffix (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) for API calls */ + const [focusedPlanId, setFocusedPlanId] = useState("") + const [recentPlans, setRecentPlans] = useState([]) + const [cycleHoursDraft, setCycleHoursDraft] = useState("24") + const [reviewFeedback, setReviewFeedback] = useState("") + const [cycleActionLoading, setCycleActionLoading] = useState(false) /** When true, polling must not overwrite the spec textarea (draft). */ const specEditingRef = useRef(false) useEffect(() => { @@ -201,7 +211,6 @@ export default function ProjectDetail() { fetchSnapshots(projectId), ]) .then(async ([d, s]) => { - setPlanDateUtc(today) setProject(d.project) setDirectives(d.directives) setTasks(d.tasks) @@ -210,16 +219,44 @@ export default function ProjectDetail() { setDraftSpec(d.project.spec) } setSnapshots(s.snapshots) + const apMode = d.project.autopilot_mode ?? "daily" if (d.project.autopilot) { - try { - const pl = await fetchPlanDetail(projectId, today) - setTodayPlan(pl.plan) - setPlanTasks(pl.tasks) - } catch { - setTodayPlan(null) - setPlanTasks([]) + if (apMode === "continuous") { + try { + const { plans } = await fetchPlans(projectId, 30) + setRecentPlans(plans) + const top = plans[0] + const suffix = top ? (top.sk ?? "").replace(/^PLAN#/, "") : "" + setFocusedPlanId(suffix) + if (suffix) { + const pl = await fetchPlanDetail(projectId, suffix) + setTodayPlan(pl.plan) + setPlanTasks(pl.tasks) + } else { + setTodayPlan(null) + setPlanTasks([]) + } + } catch { + setRecentPlans([]) + setFocusedPlanId("") + setTodayPlan(null) + setPlanTasks([]) + } + } else { + setRecentPlans([]) + setFocusedPlanId(today) + try { + const pl = await fetchPlanDetail(projectId, today) + setTodayPlan(pl.plan) + setPlanTasks(pl.tasks) + } catch { + setTodayPlan(null) + setPlanTasks([]) + } } } else { + setRecentPlans([]) + setFocusedPlanId("") setTodayPlan(null) setPlanTasks([]) } @@ -242,6 +279,24 @@ export default function ProjectDetail() { (todayPlan?.status === "approved" && planTasks.some((t) => t.status === "pending" || t.status === "in_progress")) + const humanTasksOpen = useMemo( + () => + tasks.filter( + (t) => + t.assignee === "human" && + t.status !== "completed" && + t.status !== "cancelled", + ), + [tasks], + ) + + const cycleRunning = Boolean( + project?.autopilot && + project.autopilot_mode === "continuous" && + (project.cycle_started_at ?? "").trim() && + !project.cycle_paused, + ) + useEffect(() => { if (!busy) return const id = window.setInterval(() => { @@ -250,6 +305,12 @@ export default function ProjectDetail() { return () => window.clearInterval(id) }, [busy, load]) + useEffect(() => { + if (project?.cycle_max_hours && project.cycle_max_hours > 0) { + setCycleHoursDraft(String(project.cycle_max_hours)) + } + }, [project?.cycle_max_hours]) + async function saveSpec() { if (!projectId || !project) return setSavingSpec(true) @@ -444,52 +505,299 @@ export default function ProjectDetail() { )} - {/* Autopilot — daily plan */} + {/* Autopilot */}

Autopilot

- +
+ {( + [ + { key: "off", label: "Off" }, + { key: "daily", label: "Daily" }, + { key: "continuous", label: "Continuous" }, + ] as const + ).map((tab) => { + const current = !project?.autopilot + ? "off" + : project.autopilot_mode === "continuous" + ? "continuous" + : "daily" + const active = current === tab.key + return ( + + ) + })} +
- {project.autopilot && ( -
- {!todayPlan ? ( +

+ {project?.autopilot && project.autopilot_mode === "continuous" + ? "While a cycle is running, the server may propose plans hourly and auto-start tasks. Pause for review when the window ends or work is blocked." + : project?.autopilot + ? "One proposed plan per calendar day at 07:00 UTC — approve to create tasks." + : "Choose Daily or Continuous to generate plans on a schedule."} +

+ + {humanTasksOpen.length > 0 && project?.autopilot_mode === "continuous" ? ( +
+ {humanTasksOpen.length} human-assigned task(s) open — autopilot may pause until they are + cleared or the agent can work in parallel. +
+ ) : null} + + {project?.autopilot && project.autopilot_mode === "continuous" ? ( +
+
+
+ Cycle max hours + setCycleHoursDraft(e.target.value)} + className="h-8 w-24 border-zinc-800 bg-zinc-950/50 text-[12px]" + /> +
+
+ {[24, 48, 168].map((h) => ( + + ))} +
+
+ {project.cycle_paused ? ( +
+ + Paused + {project.cycle_pause_reason + ? ` · ${project.cycle_pause_reason.replace(/_/g, " ")}` + : ""} + + {project.next_check_at ? ( +

+ Next planner check:{" "} + {project.next_check_at} +

+ ) : null} +