From e036ea97a5d40305a9d33f26d20005f2044e13e6 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:46:38 +0000 Subject: [PATCH 01/69] fix(deps): bump ethnum to 1.5.3 to unbreak the build ethnum 1.5.2 fabricates a `TryFromIntError` with `unsafe { mem::transmute(()) }` (ethnum/src/error.rs:16), relying on std's private layout. rustc 1.97.1 changed that layout, so the crate fails to compile with E0512 "cannot transmute between types of different sizes". It reaches us transitively: jsonb <- lance-arrow <- lance <- lancedb. 1.5.3 fixes the transmute. Lock-file-only change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 66afb6e99..ef4e572ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2916,9 +2916,9 @@ dependencies = [ [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "euclid" From cc12621faf471990ab537de2838d134973b65abe Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:46:58 +0000 Subject: [PATCH 02/69] docs: correct README inaccuracies and drop the unimplemented Spacedrive section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited every load-bearing README claim against the source. Removed: - The "Spacebot + Spacedrive" section and its nav link. There is zero integration code — all 23 `spacedrive` hits in src/ are the GitHub org in repo URLs or test fixtures. The section described P2P device routing, remote execution, context nodes, and a Prompt Guard 2 classifier, none of which exist. - The matching roadmap bullet. Fixed: - Links to ARCHITECTURE.md (2 places). That file does not exist and never has; the content lives at docs/content/docs/(core)/architecture.mdx. - Broken link to (features)/mcp.mdx, which does not exist. MCP is documented inside tools.mdx. - "Four-level routing ... sub-millisecond prompt scorer" — no such code exists, and routing.mdx says the opposite outright: "Routing decisions in Spacebot are explicit, not inferred ... No keyword scoring, no LLM classifier, no content analysis." - Rig v0.31 -> v0.33 (Cargo.toml:20). - "Goals set direction" implied goals are scheduled entities. They are MemoryType::Goal memories, never executed. Section rewritten as "The Task Board" and the distinction stated. - "The autonomy channel" does not exist — zero hits in src/ or docs/. The real mechanism is the cortex pickup loop plus wake_one. Added: - protoc to the prerequisites in both README and quickstart. LanceDB's build scripts hard-fail without it; it was only listed in CONTRIBUTING.md. - Sections for the shipped-but-undocumented subsystems: projects/repos, wiki, multi-agent and the org graph, dashboard/portal/desktop, and observability. Docs table expanded from 11 to 25 entries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- README.md | 114 ++++++++++++------ docs/content/docs/(deployment)/roadmap.mdx | 1 - .../docs/(getting-started)/quickstart.mdx | 1 + 3 files changed, 78 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index db17399b0..99f72cbcb 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,8 @@

spacebot.shHow It Works • - Goals & Tasks • + Task BoardQuick Start • - SpacedriveDocs

@@ -103,19 +102,23 @@ Channel context hits 80% → Channel never interrupted ``` -For process capabilities, tool access by type, memory internals, cron, and multi-agent isolation, see [ARCHITECTURE.md](ARCHITECTURE.md). +For process capabilities, tool access by type, memory internals, cron, and multi-agent isolation, see the [architecture guide](). --- -## Goals and Tasks +## The Task Board -Spacebot is built around a task system. Goals set direction. Tasks carry work. The agent executes, remembers, and improves whether or not you're present. +Spacebot is built around an instance-level task board. Every agent on the instance shares it, every task carries a globally unique number, and it is the substrate through which agents delegate work to each other. -On a configured interval, the **autonomy channel** wakes with full context: identity, memory, working memory, the complete task state, active goals, and a summary of its last few runs. It picks the most important ready task, executes it with full tool access, and exits. +A task moves through five states — `pending_approval` → `backlog` → `ready` → `in_progress` → `done` — at one of four priorities. Two agent IDs sit on every task: the **owner** (who created it) and the **assignee** (who executes it). Reassigning is how work changes hands. -State lives in tasks. Progress notes go on the task itself. After a crash, the next wake reads task metadata and picks up where things left off. At the end of each run the autonomy channel writes a summary of what happened. On next wake, that summary is the first thing it reads. +**Agents delegate by creating tasks.** When one agent calls `send_agent_message` on another, that is not a chat message — it opens a task on the target agent's board, owned by the sender and assigned to the recipient, at `ready`. The board is the inter-agent protocol, and who may message whom is constrained by the communication graph. -**The agent proposes. You decide.** Tasks the autonomy channel creates land in `pending_approval`. Nothing runs autonomously until you approve it. +**Pickup is autonomous.** The cortex runs a background loop that claims the highest-priority `ready` task assigned to its agent, flips it to `in_progress`, and hands it to a worker with full tool access. The claim is a conditional `UPDATE ... WHERE status = 'ready'`, so two agents racing the same task can't both win it. Progress notes go on the task itself, so after a crash the next pass reads task metadata and resumes. + +**The agent proposes. You decide.** Tasks the cortex creates land in `pending_approval` and raise a dashboard notification. Nothing runs autonomously until you approve it. + +Goals live in the memory graph as `Goal`-typed memories rather than as board rows — they set direction and get recalled into context, but they aren't scheduled or executed the way tasks are. --- @@ -167,6 +170,47 @@ Workers come loaded with tools for real work: - **[OpenCode](https://opencode.ai)** — spawn a full coding agent as a persistent worker with codebase exploration, LSP awareness, and deep context management - **[Brave](https://brave.com/search/api/) web search** — search the web with freshness filters, localization, and configurable result count +### Projects and Repos + +Projects are how agents get pointed at real codebases. A project is a root directory registered once at the instance level and usable by every agent — no re-registering the same repo per agent. + +- **Multi-repo by design** — one project holds many git repos, each tracked with its remote URL, default branch, current branch, and disk usage +- **Worktrees** — git worktrees are first-class rows linked to their repo, so parallel branch work gets its own directory instead of fighting over one checkout +- **Worker targeting** — `spawn_worker` accepts a `project_id` or `worktree_id`; the worker's working directory and prompt context are set from it automatically +- **Sandbox integration** — registering a project refreshes the sandbox allowlist so workers can reach project paths outside the agent workspace + +### Wiki + +An instance-wide, permanently versioned knowledge base that sits alongside the memory graph. Memories are atomic facts that decay and get auto-injected into prompts; wiki pages are authored long-form documents that don't decay and are read on demand. + +- **Versioned forever** — every edit writes a new version, nothing is destructive +- **Wiki-link navigation** — pages reference each other and resolve as a graph +- **Agent-writable** — `wiki_create`, `wiki_edit`, `wiki_read`, `wiki_search`, `wiki_list`, `wiki_history` are available to workers and branches +- **Shared** — one wiki per instance, across all agents and humans + +### Multi-Agent and the Org Graph + +Run many agents on one binary. Each gets its own soul, workspace, databases, cortex, conversations, and messaging bindings — fully isolated. + +- **Communication graph** — explicit directed links define which agents may message which. Org-level humans are nodes too, so agents know who manages them +- **Agent factory** — agents create and configure other agents at runtime from presets, via `factory_*` tools +- **Per-agent permissions** — a `[permissions]` block per agent controls tool access and inter-agent data boundaries, checked at tool registration, at execution, and again on output. Denials return a structured error the LLM can reason about rather than a hidden tool or a silent failure + +### Dashboard, Portal, and Desktop + +- **Web dashboard** — React SPA embedded directly in the binary via `rust-embed`. Channels, workers, memories, tasks, cron, projects, wiki, cortex, skills, ingest, and settings +- **Notifications** — task approvals, worker failures, and cortex observations surface as actionable items pushed over SSE, each deep-linked to its source entity +- **Portal** — embeddable public chat with SSE streaming and per-agent session isolation +- **Desktop app** — Tauri 2 shell wrapping the same interface, with the `spacebot` binary bundled as a sidecar it can launch locally +- **Self-update** — checks GitHub releases and can update in place, including rewriting its own Docker image tag when `/var/run/docker.sock` is mounted + +### Observability + +- **Prometheus metrics** — LLM cost, token usage, agent activity, and memory operations, behind the `metrics` cargo feature so it compiles out entirely when unused +- **Token accounting** — per-call usage and cost estimates persisted to SQLite and surfaced in the dashboard +- **Worker transcripts** — every worker's turns and tool calls are recorded and replayable from the Workers tab +- **Loop guard** — programmatic detection of stuck tool loops (repeated identical calls, ping-pong patterns, global circuit breaker) with thresholds tuned per process type, so a spinning worker gets interrupted instead of burning budget + ### Messaging Native adapters for Discord, Slack, Telegram, Twitch, Signal, Mattermost, Email, and Webchat, plus a generic Webhook receiver: @@ -180,7 +224,7 @@ Native adapters for Discord, Slack, Telegram, Twitch, Signal, Mattermost, Email, ### Model Routing -Four-level routing picks the right model for every call. Channels get the best conversational model. Workers get something fast and cheap. Coding workers upgrade automatically. Simple user messages are downgraded to cheaper models by a sub-millisecond prompt scorer with no external calls. Voice messages route to a dedicated voice model. +Three-level routing picks the right model for every call, decided explicitly from the process type and task type — no keyword scoring, no classifier, no content analysis. Channels get the best conversational model. Workers, the compactor, and the cortex get something fast and cheap. Task-type overrides upgrade coding workers automatically. Per-model fallback chains retry on a different model when one fails, with rate-limit cooldown. Any OpenAI-compatible or Anthropic-compatible endpoint works, including Ollama for local models, Z.ai GLM models, Azure OpenAI, and custom providers. Built-in support for Kilo Gateway, NVIDIA, MiniMax, Moonshot AI, Gemini, GitHub Copilot, OpenCode Go, and more. @@ -217,41 +261,18 @@ Spacebot builds on itself over time through four specific mechanisms. **Memory deepens with every interaction.** Each conversation adds facts, preferences, decisions, and observations to a typed graph with importance scoring and graph edges. The cortex synthesizes this into a briefing every future conversation benefits from. -**Goals drive autonomous work between conversations.** The autonomy channel wakes on its interval, picks up ready tasks, and works through them. Working memory records what happened, so the next conversation picks up where things left off. +**The board drives autonomous work between conversations.** The cortex's pickup loop claims ready tasks and runs them through workers without anyone asking. Working memory records what happened, so the next conversation picks up where things left off. Dormant-mode agents skip the loop entirely and run only when an external trigger wakes them. Everything goes through typed tools into structured storage. Nothing drifts. --- -## Spacebot + Spacedrive - -Spacebot pairs with [Spacedrive](https://github.com/spacedriveapp/spacedrive), an open-source cross-platform file manager built on a virtual distributed filesystem. Neither requires the other. When paired, Spacebot is the only agent harness with direct integration into a cross-device filesystem. - -### What Pairing Enables Today - -**Multi-device access:** one Spacebot instance, all your devices. Talk to your agent from your phone while a worker executes on your server. Spacedrive's P2P layer (Iroh/QUIC) routes from every device through the paired node to Spacebot. No separate SDK, no separate auth. - -**Remote execution:** workers can target any device in your library. A task that needs your home server's GPU, your work laptop's local repos, or your phone's camera routes through Spacedrive's permission system to the target device. From the agent's perspective, the tool call is identical. - -**File system intelligence:** every directory can carry context nodes describing what it contains and what policies apply. When the agent navigates your filesystem it gets that context, not a blind listing. - -**Safe data access:** Spacedrive indexes external sources (Gmail, Slack, Obsidian, GitHub, Apple Notes, contacts, calendar, browser history) as searchable data the agent can query. Every record passes through a local prompt injection classifier (Prompt Guard 2) before reaching the agent. The agent can search your emails without a malicious email hijacking it. - -### Where This Is Going - -A company deploys Spacebot + Spacedrive on their infrastructure. Employees install Spacedrive on their devices and join the company library. The company agent has access to employee devices through Spacedrive's permission system, with individual-level controls. The org graph in Spacebot defines hierarchy and delegation: which agents report to which, who can approve what, how tasks flow. - -An employee talks to the company agent from their MacBook. The agent knows their projects, their device, their role, and can spawn workers on any authorized machine. They switch to their personal Spacedrive library and connect to their home Spacebot, with personal data and personal context. The app is the same. The agent is different. - -No other agent harness is building this. It's a category. - ---- - ## Quick Start ### Prerequisites - **Rust** 1.85+ ([rustup](https://rustup.rs/)) +- **protoc** (protobuf compiler) — required by LanceDB's build scripts. `apt install protobuf-compiler`, `brew install protobuf`, or use the included nix flake - An LLM API key from any supported provider (Anthropic, OpenAI, OpenRouter, Kilo Gateway, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, NVIDIA, MiniMax, Moonshot AI, Gemini, GitHub Copilot, OpenCode Zen, OpenCode Go), or use `spacebot auth login` for Anthropic OAuth ### Build and Run @@ -312,7 +333,7 @@ OAuth tokens are stored in `anthropic_oauth.json` and auto-refresh before each A | --------------- | --------------------------------------------------------------------------------------------------------------- | | Language | **Rust** (edition 2024) — single binary, no runtime dependencies, no GC pauses | | Async runtime | **Tokio** | -| LLM framework | **[Rig](https://github.com/0xPlaygrounds/rig)** v0.31 — agentic loop, tool execution, hooks | +| LLM framework | **[Rig](https://github.com/0xPlaygrounds/rig)** v0.33 — agentic loop, tool execution, hooks | | Relational data | **SQLite** (sqlx) — conversations, memory graph, tasks, goals, cron jobs | | Vector + FTS | **[LanceDB](https://lancedb.github.io/lancedb/)** — embeddings (HNSW), full-text (Tantivy), hybrid search (RRF) | | Key-value | **[redb](https://github.com/cberner/redb)** — settings, encrypted secrets | @@ -323,6 +344,9 @@ OAuth tokens are stored in `anthropic_oauth.json` and auto-refresh before each A | Telegram | **teloxide** — long-poll, media attachments, group/DM support | | Twitch | **twitch-irc** — chat integration with trigger prefix | | Browser | **Chromiumoxide** — headless Chrome via CDP | +| HTTP API | **axum** + **utoipa** — control API with a generated OpenAPI spec | +| Dashboard | **React** + **Vite**, embedded into the binary via **rust-embed** | +| Desktop | **Tauri 2** — native shell with the server bundled as a sidecar | | CLI | **Clap** — command line interface | Single binary, no server dependencies. All data lives in embedded databases in a local directory. @@ -335,16 +359,32 @@ Single binary, no server dependencies. All data lives in embedded databases in a | ------------------------------------------------------------------- | --------------------------------------------------------- | | [Quick Start]() | Setup, config, first run | | [Config Reference]() | Full `config.toml` reference | -| [Architecture](ARCHITECTURE.md) | Process types, tool access, memory internals, multi-agent | +| [Architecture]() | Process types, tool access, memory internals, multi-agent | | [Memory]() | Memory system design | | [Tools]() | All available LLM tools | | [Routing]() | Model routing and fallback chains | | [Secrets]() | Credential storage, encryption, output scrubbing | | [Sandbox]() | Process containment and environment sanitization | | [Cron Jobs]() | Scheduled recurring tasks | -| [MCP]() | External tool servers via Model Context Protocol | | [OpenCode]() | OpenCode as a worker backend | | [Messaging]() | Adapter architecture and platform setup | +| [Agents]() | Multi-agent setup, isolation, and the communication graph | +| [Tasks]() | The instance-level task board and autonomous pickup | +| [Workers]() | Worker lifecycle, segments, timeouts, and transcripts | +| [Projects]() | Repos, worktrees, and pointing workers at codebases | +| [Wiki]() | Versioned instance-wide knowledge base | +| [Permissions]() | Per-agent tool access and inter-agent boundaries | +| [Cortex]() | The system observer and working-memory assembly | +| [Compaction]() | How context stays under the window | +| [Prompts]() | Prompt templates and per-agent overrides | +| [Browser]() | Headless Chrome automation | +| [Ingestion]() | Bulk file import into the memory graph | +| [Portal]() | Embeddable public chat | +| [Notifications]() | Actionable events and SSE delivery | +| [Skills]() | Authoring, capture, and the skills.sh registry | +| [Desktop]() | Tauri desktop app and the sidecar binary | +| [Docker]() | Container deployment and updates | +| [Metrics]() | Prometheus metrics behind the `metrics` feature | --- diff --git a/docs/content/docs/(deployment)/roadmap.mdx b/docs/content/docs/(deployment)/roadmap.mdx index ee25d1a36..c3b9e3def 100644 --- a/docs/content/docs/(deployment)/roadmap.mdx +++ b/docs/content/docs/(deployment)/roadmap.mdx @@ -68,7 +68,6 @@ Token usage tracking is shipped. Remaining: warning thresholds, hard blocks when - **Hot reload agent topology** — adding/removing agents without restart - **Agent templates** — pre-built configurations for common use cases - **JsonSchema-derived tool definitions** — replace hand-written JSON schemas with the `JsonSchema` derive already on every Args struct -- **Spacedrive integration** — connect agents to terabytes of indexed, content-addressed file data across devices ## Decided Against diff --git a/docs/content/docs/(getting-started)/quickstart.mdx b/docs/content/docs/(getting-started)/quickstart.mdx index 4233de41c..d262144cb 100644 --- a/docs/content/docs/(getting-started)/quickstart.mdx +++ b/docs/content/docs/(getting-started)/quickstart.mdx @@ -46,6 +46,7 @@ You can also manage updates from **Settings → Updates** in the web UI. ### Prerequisites - **Rust 1.85+** — `rustup update stable` +- **protoc** (protobuf compiler) — required by LanceDB's build scripts. `apt install protobuf-compiler` or `brew install protobuf` - **Bun** (optional, for the web UI) — `curl -fsSL https://bun.sh/install | bash` - **An LLM API key** — Anthropic, OpenAI, OpenRouter, Kilo Gateway, or OpenCode Go From a16d7ef0d2ac2dd8bb27dbd8d493421c639cabb3 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:47:31 +0000 Subject: [PATCH 03/69] feat(tasks): add attempt log, failure budget, and project binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundational substrate for a task DAG. Two additive migrations, no existing migration touched. Every new column is nullable or defaulted, so existing rows are valid without backfill. ## Attempt log + failure budget (20260802000001) Fixes a live bug: cortex.rs routed a failed picked-up task straight back to Ready with no memory of having failed, so a permanently-failing task looped forever burning tokens. - `task_runs` table, one row per attempt (attempt#, worker_id, outcome, summary, error, timings). A task retried after a crash or timeout has several rows. - `consecutive_failures` / `max_retries` / `last_error` on tasks. `record_failure` increments and writes the status in one transaction, so the increment and the transition cannot interleave with a claim. On budget exhaustion the task is parked in `blocked` instead of requeued. - `TaskStatus::Blocked` added rather than overloading `pending_approval`, which means "the agent proposed work", not "this is stuck" — two different human queues. Typed `block_kind` lands in a later change. - `TaskRunOutcome::counts_as_failure()` excludes `RateLimited` on purpose: a provider quota outage must not trip the circuit breaker on otherwise-healthy tasks. Classification reuses `llm::routing::is_rate_limit_error`. - `legal_transitions()` exported as the single source of truth so the API and the dashboard's drag rules cannot disagree. - New endpoints: GET /tasks/{n}/runs, POST /tasks/{n}/retry. Manual retry clears the budget — a human looked at it, so it starts over. ## Project binding (20260802000002) Tasks were agent-scoped, never codebase-scoped. `projects` already models one project as many repos each with many worktrees, so binding tasks to it makes a dependency edge between two tasks in *different repos of the same project* expressible — the multi-repo case the board could not represent at all. - Nullable project_id / repo_id / worktree_id FKs. ON DELETE SET NULL, not CASCADE: deleting a project must not destroy the task history that referenced it. - `resolve_directory_from_project` now handles repo_id, with priority directory > worktree > repo > project root. Both `project_repos.path` and `project_worktrees.path` are relative to the project root (see projects/git.rs::DiscoveredRepo::relative_path). - spawn_worker and task_create gained repo_id; the API accepts the binding on create and update. An update that says nothing about the binding leaves it alone; `clear_binding` unbinds explicitly. - Cortex pickup resolves the bound directory into the task prompt so the worker knows which repo it is in. Deliberately no sandbox mutation during pickup. A task can only bind to a registered project (FK-enforced) whose root is already allowlisted via `refresh_project_paths`, and repo/worktree paths live under that root. Widening the sandbox as a side effect of task pickup would be a quiet privilege escalation. ## Refactors - Extracted `create_task_schema()` as the single definition of the test schema. It was hand-written in three places and had already drifted — a copy in cortex.rs failed on first run. - Gave `CreateTaskInput` a `Default` and converted 14 construction sites to `..Default::default()`, so later phases can add fields without touching every call site. 10 new tests. 885/885 lib tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- .../global/20260802000001_task_runs.sql | 46 + .../20260802000002_task_project_binding.sql | 23 + src/agent/cortex.rs | 279 +++++- src/api/server.rs | 2 + src/api/tasks.rs | 122 ++- src/tasks.rs | 5 +- src/tasks/store.rs | 856 +++++++++++++++++- src/tools/send_agent_message.rs | 1 + src/tools/spawn_worker.rs | 47 +- src/tools/task_create.rs | 30 + src/tools/task_update.rs | 4 + 11 files changed, 1334 insertions(+), 81 deletions(-) create mode 100644 migrations/global/20260802000001_task_runs.sql create mode 100644 migrations/global/20260802000002_task_project_binding.sql diff --git a/migrations/global/20260802000001_task_runs.sql b/migrations/global/20260802000001_task_runs.sql new file mode 100644 index 000000000..11897f33c --- /dev/null +++ b/migrations/global/20260802000001_task_runs.sql @@ -0,0 +1,46 @@ +-- Per-attempt execution log for tasks, plus a failure budget on the task itself. +-- +-- Before this, a failed picked-up task was requeued straight back to 'ready' +-- with no memory of having failed, so a permanently-failing task looped +-- forever. `consecutive_failures` bounds that; `task_runs` records why. +-- +-- One row per attempt. A task retried after a crash or timeout has multiple +-- rows, ordered by `attempt`. + +CREATE TABLE IF NOT EXISTS task_runs ( + id TEXT PRIMARY KEY NOT NULL, + task_number INTEGER NOT NULL, + attempt INTEGER NOT NULL, + + -- The worker that executed this attempt. NULL when the attempt failed + -- before a worker was spawned. + worker_id TEXT, + + -- completed | failed | timeout | cancelled | blocked | rate_limited + -- NULL while the attempt is still running. + outcome TEXT, + + -- Human-readable result or failure summary. + summary TEXT, + -- Raw error text when the attempt did not succeed. + error TEXT, + + started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + ended_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_task_runs_task ON task_runs(task_number, attempt); +CREATE INDEX IF NOT EXISTS idx_task_runs_worker ON task_runs(worker_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_task_runs_task_attempt + ON task_runs(task_number, attempt); + +-- Failure budget. Reset to 0 on any successful completion, and on an operator +-- retry (a human looked at it, so the budget starts over). +ALTER TABLE tasks ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0; + +-- Per-task override of the instance default failure limit. NULL = use default. +ALTER TABLE tasks ADD COLUMN max_retries INTEGER; + +-- Last failure text, kept on the task so the board can show why it is parked +-- without joining task_runs. +ALTER TABLE tasks ADD COLUMN last_error TEXT; diff --git a/migrations/global/20260802000002_task_project_binding.sql b/migrations/global/20260802000002_task_project_binding.sql new file mode 100644 index 000000000..cf8483e67 --- /dev/null +++ b/migrations/global/20260802000002_task_project_binding.sql @@ -0,0 +1,23 @@ +-- Bind tasks to the codebases they act on. +-- +-- `projects` already models one project as many repos, each with many +-- worktrees, so a dependency edge between two tasks in *different repos of the +-- same project* becomes expressible once tasks carry these columns. That is the +-- multi-repo / microservice case the task board previously could not represent +-- at all: tasks were agent-scoped, never codebase-scoped. +-- +-- All three are nullable. A task with no binding behaves exactly as before. +-- +-- ON DELETE SET NULL rather than CASCADE: deleting a project must not silently +-- destroy the task history that referenced it. The task survives, unbound. + +ALTER TABLE tasks ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL; +ALTER TABLE tasks ADD COLUMN repo_id TEXT REFERENCES project_repos(id) ON DELETE SET NULL; +ALTER TABLE tasks ADD COLUMN worktree_id TEXT REFERENCES project_worktrees(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id); +CREATE INDEX IF NOT EXISTS idx_tasks_repo ON tasks(repo_id); +CREATE INDEX IF NOT EXISTS idx_tasks_worktree ON tasks(worktree_id); + +-- Board queries filter by project and then group by repo. +CREATE INDEX IF NOT EXISTS idx_tasks_project_status ON tasks(project_id, status); diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index c560a759a..ae1df3276 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -3706,12 +3706,67 @@ enum DetachedRouting { Requeue, } +/// Emit the logging + events for a failed attempt whose status was already +/// persisted by [`TaskStore::record_failure`]. +/// +/// `record_failure` writes the status inside its own transaction so the +/// increment and the transition are atomic, which means this path deliberately +/// skips the normal `task_store.update` call. +#[allow(clippy::too_many_arguments)] +fn emit_requeue_outcome( + task: &crate::tasks::Task, + worker_id: WorkerId, + result_text: &str, + new_status: TaskStatus, + logger: &CortexLogger, + run_logger: &crate::conversation::history::ProcessRunLogger, + event_tx: &tokio::sync::broadcast::Sender, + agent_id: &str, + message: &str, + log_event: &str, + extra: Option, +) { + run_logger.log_worker_completed(worker_id, result_text, false); + + let _ = event_tx.send(ProcessEvent::TaskUpdated { + agent_id: Arc::from(agent_id), + task_number: task.task_number, + status: new_status.as_str().to_string(), + action: "updated".to_string(), + }); + + let mut payload = serde_json::json!({ + "task_number": task.task_number, + "worker_id": worker_id.to_string(), + "status": new_status.as_str(), + }); + if let (Some(serde_json::Value::Object(extra)), Some(target)) = (extra, payload.as_object_mut()) + { + for (key, value) in extra { + target.insert(key, value); + } + } + + logger.log(log_event, message, Some(payload)); + + let _ = event_tx.send(ProcessEvent::WorkerComplete { + agent_id: Arc::from(agent_id), + worker_id, + channel_id: None, + result: result_text.to_string(), + notify: true, + success: false, + }); +} + #[allow(clippy::too_many_arguments)] async fn handle_detached_completion( routing: DetachedRouting, task: &crate::tasks::Task, worker_id: WorkerId, result_text: &str, + run_id: Option<&str>, + run_outcome: crate::tasks::TaskRunOutcome, task_store: &Arc, run_logger: &crate::conversation::history::ProcessRunLogger, logger: &CortexLogger, @@ -3723,6 +3778,95 @@ async fn handle_detached_completion( injection_tx: &tokio::sync::mpsc::Sender, ) { let success = matches!(routing, DetachedRouting::Success); + + // Close the attempt row before touching task status, so the log reflects + // what happened even if the status write then fails. + if let Some(run_id) = run_id { + let (summary, error) = if success { + (Some(result_text), None) + } else { + (None, Some(result_text)) + }; + if let Err(error) = task_store + .finish_run(run_id, run_outcome, summary, error) + .await + { + tracing::warn!(%error, task_number = task.task_number, "failed to close task run row"); + } + } + + // Requeue no longer means "straight back to ready". The failure budget + // decides: retry while budget remains, otherwise park in `blocked` so a + // human sees it instead of the task hot-looping forever. + if matches!(routing, DetachedRouting::Requeue) { + let disposition = task_store + .record_failure(task.task_number, run_outcome, result_text) + .await; + + match disposition { + Ok(crate::tasks::FailureDisposition::Requeued { failures, limit }) => { + emit_requeue_outcome( + task, + worker_id, + result_text, + TaskStatus::Ready, + logger, + run_logger, + event_tx, + agent_id, + &format!( + "Picked-up task #{} failed (attempt {failures}/{limit}), requeued: {result_text}", + task.task_number + ), + "task_pickup_failed", + Some(serde_json::json!({ "failures": failures, "limit": limit })), + ); + return; + } + Ok(crate::tasks::FailureDisposition::Parked { failures, limit }) => { + emit_requeue_outcome( + task, + worker_id, + result_text, + TaskStatus::Blocked, + logger, + run_logger, + event_tx, + agent_id, + &format!( + "Picked-up task #{} exhausted its retry budget ({failures}/{limit}) and was blocked: {result_text}", + task.task_number + ), + "task_pickup_budget_exhausted", + Some(serde_json::json!({ "failures": failures, "limit": limit })), + ); + return; + } + Ok(crate::tasks::FailureDisposition::NotCounted) => { + // Rate limited. Requeue without spending budget. + tracing::info!( + task_number = task.task_number, + "task attempt hit a provider rate limit — requeueing without counting a failure" + ); + } + Ok(crate::tasks::FailureDisposition::TaskMissing) => { + tracing::warn!( + task_number = task.task_number, + "task row missing while recording failure — task may have been deleted" + ); + run_logger.log_worker_completed(worker_id, result_text, false); + return; + } + Err(error) => { + tracing::warn!( + %error, + task_number = task.task_number, + "failed to record task failure — falling through to plain requeue" + ); + } + } + } + let new_status = match routing { DetachedRouting::Success | DetachedRouting::Terminal => TaskStatus::Done, DetachedRouting::Requeue => TaskStatus::Ready, @@ -3739,6 +3883,15 @@ async fn handle_detached_completion( }, }; + // A clean completion clears the failure budget so a task that failed twice + // and then succeeded doesn't carry a hair trigger into its next run. + if success + && task.consecutive_failures > 0 + && let Err(error) = task_store.clear_failures(task.task_number).await + { + tracing::warn!(%error, task_number = task.task_number, "failed to clear failure budget"); + } + let update_result = task_store.update(task.task_number, update_input).await; let persisted = match update_result { Ok(Some(_)) => true, @@ -3996,6 +4149,30 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho } } + // A task bound to a project/repo/worktree names its working directory + // explicitly. Without this the worker only sees the global project listing + // and has to guess which repo the task is about — the whole point of the + // binding is that it doesn't have to. + if let Some(directory) = crate::tools::spawn_worker::resolve_directory_from_project( + deps, + None, + task.project_id.as_deref(), + task.repo_id.as_deref(), + task.worktree_id.as_deref(), + ) + .await + { + task_prompt.push_str("\n\nWorking directory: "); + task_prompt.push_str(&directory); + task_prompt.push_str("\nThis task is scoped to that directory. Work there unless the task explicitly says otherwise."); + + // Deliberately no sandbox mutation here. A task can only bind to a + // registered project (enforced by the FK), whose root is already in the + // allowlist via `refresh_project_paths`, and repo/worktree paths live + // under that root. Widening the sandbox as a side effect of task pickup + // would be a quiet privilege escalation. + } + let screenshot_dir = deps .runtime_config .workspace_dir @@ -4044,6 +4221,24 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho ) .await?; + // Open an attempt row so this execution is recorded even if the process + // dies before completion. A failure to log must not abort the run. + let run_id = match deps + .task_store + .start_run(task.task_number, Some(&worker_id.to_string())) + .await + { + Ok(run) => Some(run.id), + Err(error) => { + tracing::warn!( + %error, + task_number = task.task_number, + "failed to open task run row — continuing without attempt logging" + ); + None + } + }; + let _ = deps.event_tx.send(ProcessEvent::TaskUpdated { agent_id: deps.agent_id.clone(), task_number: task.task_number, @@ -4157,6 +4352,36 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho | Ok(WorkerOutcome::Blocked { .. }) => DetachedRouting::Terminal, Ok(WorkerOutcome::Failed { .. }) | Err(_) => DetachedRouting::Requeue, }; + // Classify for the attempt log. A rate-limited failure is + // recorded but deliberately excluded from the failure + // budget — a provider quota outage is not the task's fault. + let run_outcome = match &outcome_or_error { + Ok(WorkerOutcome::Success { .. }) | Ok(WorkerOutcome::Partial { .. }) => { + crate::tasks::TaskRunOutcome::Completed + } + Ok(WorkerOutcome::Cancelled { .. }) => { + crate::tasks::TaskRunOutcome::Cancelled + } + Ok(WorkerOutcome::Timeout { .. }) => crate::tasks::TaskRunOutcome::Timeout, + Ok(WorkerOutcome::Blocked { .. }) => crate::tasks::TaskRunOutcome::Blocked, + Ok(WorkerOutcome::Failed { reason }) => { + if crate::llm::routing::is_rate_limit_error(reason) { + crate::tasks::TaskRunOutcome::RateLimited + } else { + crate::tasks::TaskRunOutcome::Failed + } + } + Err(WorkerCompletionError::Cancelled { .. }) => { + crate::tasks::TaskRunOutcome::Cancelled + } + Err(WorkerCompletionError::Failed { message }) => { + if crate::llm::routing::is_rate_limit_error(message) { + crate::tasks::TaskRunOutcome::RateLimited + } else { + crate::tasks::TaskRunOutcome::Failed + } + } + }; let (result_text, _notify, _success) = map_worker_completion(outcome_or_error); let result_text = scrub(result_text); handle_detached_completion( @@ -4164,6 +4389,8 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho &task, worker_id, &result_text, + run_id.as_deref(), + run_outcome, &task_store, &run_logger, &logger, @@ -5534,31 +5761,7 @@ mod tests { .await .expect("failed to create sqlite memory pool"); - sqlx::query( - "CREATE TABLE tasks ( - id TEXT PRIMARY KEY, - task_number INTEGER NOT NULL UNIQUE, - title TEXT NOT NULL, - description TEXT, - status TEXT NOT NULL DEFAULT 'backlog', - priority TEXT NOT NULL DEFAULT 'medium', - owner_agent_id TEXT NOT NULL, - assigned_agent_id TEXT NOT NULL, - subtasks TEXT, - metadata TEXT, - source_memory_id TEXT, - worker_id TEXT, - created_by TEXT NOT NULL, - approved_at TEXT, - approved_by TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - completed_at TEXT - )", - ) - .execute(&pool) - .await - .expect("failed to create tasks table"); + crate::tasks::store::create_task_schema(&pool).await; let task_store = TaskStore::new(pool.clone()); let registry = crate::agent::process_control::ProcessControlRegistry::new(); @@ -5618,31 +5821,7 @@ mod tests { .await .expect("failed to create sqlite memory pool"); - sqlx::query( - "CREATE TABLE tasks ( - id TEXT PRIMARY KEY, - task_number INTEGER NOT NULL UNIQUE, - title TEXT NOT NULL, - description TEXT, - status TEXT NOT NULL DEFAULT 'backlog', - priority TEXT NOT NULL DEFAULT 'medium', - owner_agent_id TEXT NOT NULL, - assigned_agent_id TEXT NOT NULL, - subtasks TEXT, - metadata TEXT, - source_memory_id TEXT, - worker_id TEXT, - created_by TEXT NOT NULL, - approved_at TEXT, - approved_by TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - completed_at TEXT - )", - ) - .execute(&pool) - .await - .expect("failed to create tasks table"); + crate::tasks::store::create_task_schema(&pool).await; let task_store = TaskStore::new(pool.clone()); let registry = crate::agent::process_control::ProcessControlRegistry::new(); diff --git a/src/api/server.rs b/src/api/server.rs index a60cb7ec5..1630ac639 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -142,6 +142,8 @@ pub fn api_router() -> OpenApiRouter> { .routes(routes!(tasks::approve_task)) .routes(routes!(tasks::execute_task)) .routes(routes!(tasks::assign_task)) + .routes(routes!(tasks::list_task_runs)) + .routes(routes!(tasks::retry_task)) // Wiki routes .routes(routes!(wiki::list_pages, wiki::create_page)) .routes(routes!(wiki::search_pages)) diff --git a/src/api/tasks.rs b/src/api/tasks.rs index 98d6eadcb..e25dd77ca 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -52,6 +52,15 @@ pub(super) struct CreateTaskRequest { source_memory_id: Option, #[serde(default)] created_by: Option, + /// Project this task acts on. + #[serde(default)] + project_id: Option, + /// Repo within the project. A project holds many repos. + #[serde(default)] + repo_id: Option, + /// Worktree to execute in. + #[serde(default)] + worktree_id: Option, } #[derive(Deserialize, utoipa::ToSchema)] @@ -76,6 +85,15 @@ pub(super) struct UpdateTaskRequest { worker_id: Option, #[serde(default)] approved_by: Option, + #[serde(default)] + project_id: Option, + #[serde(default)] + repo_id: Option, + #[serde(default)] + worktree_id: Option, + /// Unbind the task from its project/repo/worktree entirely. + #[serde(default)] + clear_binding: bool, } #[derive(Deserialize, utoipa::ToSchema)] @@ -105,6 +123,11 @@ pub(super) struct TaskActionResponse { message: String, } +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct TaskRunsResponse { + runs: Vec, +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -284,6 +307,11 @@ pub(super) async fn create_task( metadata: request.metadata.unwrap_or_else(|| serde_json::json!({})), source_memory_id: request.source_memory_id, created_by: request.created_by.unwrap_or_else(|| "human".to_string()), + binding: crate::tasks::TaskProjectBinding { + project_id: request.project_id, + repo_id: request.repo_id, + worktree_id: request.worktree_id, + }, }) .await .map_err(|error| { @@ -322,6 +350,21 @@ pub(super) async fn update_task( let status = parse_status(request.status.as_deref())?; let priority = parse_priority(request.priority.as_deref())?; + // Only send a binding when at least one field was supplied; otherwise the + // existing columns are left alone. + let binding = if request.project_id.is_some() + || request.repo_id.is_some() + || request.worktree_id.is_some() + { + Some(crate::tasks::TaskProjectBinding { + project_id: request.project_id, + repo_id: request.repo_id, + worktree_id: request.worktree_id, + }) + } else { + None + }; + let task = store .update( number, @@ -334,9 +377,11 @@ pub(super) async fn update_task( subtasks: request.subtasks, metadata: request.metadata, worker_id: request.worker_id, - clear_worker_id: false, approved_by: request.approved_by, complete_subtask: request.complete_subtask, + binding, + clear_binding: request.clear_binding, + ..Default::default() }, ) .await @@ -456,6 +501,81 @@ pub(super) async fn approve_task( Ok(Json(TaskResponse { task })) } +/// `GET /tasks/{number}/runs` — the per-attempt execution log for a task. +#[utoipa::path( + get, + path = "/tasks/{number}/runs", + params( + ("number" = i64, Path, description = "Task number"), + ), + responses( + (status = 200, body = TaskRunsResponse), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn list_task_runs( + State(state): State>, + Path(number): Path, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + let runs = store.list_runs(number).await.map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to list task runs"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(TaskRunsResponse { runs })) +} + +/// `POST /tasks/{number}/retry` — clear the failure budget and requeue. +/// +/// A human looked at the task, so the budget starts over rather than +/// immediately re-parking it on the next failure. +#[utoipa::path( + post, + path = "/tasks/{number}/retry", + params( + ("number" = i64, Path, description = "Task number"), + ), + responses( + (status = 200, body = TaskResponse), + (status = 404, description = "Task not found"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn retry_task( + State(state): State>, + Path(number): Path, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + store.clear_failures(number).await.map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to clear task failure budget"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let task = store + .update( + number, + crate::tasks::UpdateTaskInput { + status: Some(crate::tasks::TaskStatus::Ready), + clear_worker_id: true, + ..Default::default() + }, + ) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to requeue task"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + emit_task_event(&state, &task, "updated"); + Ok(Json(TaskResponse { task })) +} + /// `POST /tasks/{number}/execute` — move a task to ready for execution. /// Tasks already in `ready` or `in_progress` are returned as-is. #[utoipa::path( diff --git a/src/tasks.rs b/src/tasks.rs index 006668ea7..ce7be9289 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -4,6 +4,7 @@ pub mod migration; pub mod store; pub use store::{ - CreateTaskInput, Task, TaskListFilter, TaskPriority, TaskStatus, TaskStore, TaskSubtask, - TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, + CreateTaskInput, DEFAULT_FAILURE_LIMIT, FailureDisposition, Task, TaskListFilter, TaskPriority, + TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, TaskStore, TaskSubtask, + TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, can_transition, legal_transitions, }; diff --git a/src/tasks/store.rs b/src/tasks/store.rs index 6378da948..8aa37af0b 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -20,15 +20,20 @@ pub enum TaskStatus { Backlog, Ready, InProgress, + /// Parked and not eligible for pickup. Today this is only reached by + /// exhausting the failure budget; `block_kind` in a later change will + /// distinguish dependency waits from human gates. + Blocked, Done, } impl TaskStatus { - pub const ALL: [TaskStatus; 5] = [ + pub const ALL: [TaskStatus; 6] = [ TaskStatus::PendingApproval, TaskStatus::Backlog, TaskStatus::Ready, TaskStatus::InProgress, + TaskStatus::Blocked, TaskStatus::Done, ]; @@ -38,6 +43,7 @@ impl TaskStatus { TaskStatus::Backlog => "backlog", TaskStatus::Ready => "ready", TaskStatus::InProgress => "in_progress", + TaskStatus::Blocked => "blocked", TaskStatus::Done => "done", } } @@ -48,10 +54,16 @@ impl TaskStatus { "backlog" => Some(TaskStatus::Backlog), "ready" => Some(TaskStatus::Ready), "in_progress" => Some(TaskStatus::InProgress), + "blocked" => Some(TaskStatus::Blocked), "done" => Some(TaskStatus::Done), _ => None, } } + + /// Whether a task in this status is eligible for the pickup loop. + pub fn is_terminal(self) -> bool { + matches!(self, TaskStatus::Done) + } } impl std::fmt::Display for TaskStatus { @@ -129,6 +141,113 @@ pub struct Task { pub created_at: String, pub updated_at: String, pub completed_at: Option, + /// Failures since the last success. Reset to 0 on completion and on an + /// operator-initiated retry. + pub consecutive_failures: i64, + /// Per-task override of [`DEFAULT_FAILURE_LIMIT`]. + pub max_retries: Option, + /// Text of the most recent failure, kept on the task so the board can + /// show why it is parked without joining `task_runs`. + pub last_error: Option, + /// Project this task acts on, if any. + pub project_id: Option, + /// Specific repo within the project. A project holds many repos, so this is + /// what makes a task about `api-gateway` distinguishable from one about + /// `web` in the same project. + pub repo_id: Option, + /// Worktree to execute in. When set, the worker's working directory is + /// resolved from it rather than from the repo or project root. + pub worktree_id: Option, +} + +/// The codebase a task acts on. Every field is optional and independently +/// meaningful: a project alone scopes the task, a repo narrows it, a worktree +/// pins the exact checkout. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TaskProjectBinding { + pub project_id: Option, + pub repo_id: Option, + pub worktree_id: Option, +} + +impl TaskProjectBinding { + pub fn is_empty(&self) -> bool { + self.project_id.is_none() && self.repo_id.is_none() && self.worktree_id.is_none() + } +} + +/// How many consecutive failures a task may accumulate before it is parked in +/// [`TaskStatus::Blocked`] instead of being requeued. +pub const DEFAULT_FAILURE_LIMIT: i64 = 2; + +/// Outcome of a single task execution attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TaskRunOutcome { + Completed, + Failed, + Timeout, + Cancelled, + Blocked, + /// Provider rate limit. Deliberately does **not** count against the + /// failure budget — a quota outage is not the task's fault. + RateLimited, +} + +impl TaskRunOutcome { + pub fn as_str(self) -> &'static str { + match self { + TaskRunOutcome::Completed => "completed", + TaskRunOutcome::Failed => "failed", + TaskRunOutcome::Timeout => "timeout", + TaskRunOutcome::Cancelled => "cancelled", + TaskRunOutcome::Blocked => "blocked", + TaskRunOutcome::RateLimited => "rate_limited", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "completed" => Some(TaskRunOutcome::Completed), + "failed" => Some(TaskRunOutcome::Failed), + "timeout" => Some(TaskRunOutcome::Timeout), + "cancelled" => Some(TaskRunOutcome::Cancelled), + "blocked" => Some(TaskRunOutcome::Blocked), + "rate_limited" => Some(TaskRunOutcome::RateLimited), + _ => None, + } + } + + /// Whether this outcome should increment `consecutive_failures`. + /// + /// `RateLimited` is excluded on purpose: a long provider quota outage must + /// not trip the circuit breaker on otherwise-healthy tasks. + pub fn counts_as_failure(self) -> bool { + matches!( + self, + TaskRunOutcome::Failed | TaskRunOutcome::Timeout | TaskRunOutcome::Blocked + ) + } +} + +impl std::fmt::Display for TaskRunOutcome { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// A single execution attempt against a task. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct TaskRun { + pub id: String, + pub task_number: i64, + pub attempt: i64, + pub worker_id: Option, + pub outcome: Option, + pub summary: Option, + pub error: Option, + pub started_at: String, + pub ended_at: Option, } #[derive(Debug, Clone)] @@ -143,6 +262,29 @@ pub struct CreateTaskInput { pub metadata: Value, pub source_memory_id: Option, pub created_by: String, + /// Codebase this task acts on. Empty for tasks that aren't about code. + pub binding: TaskProjectBinding, +} + +/// Defaults exist so callers can use `..Default::default()` and stay source +/// compatible as fields are added. Every field that must be set for the task to +/// make sense (agents, title) defaults to empty and is expected to be provided. +impl Default for CreateTaskInput { + fn default() -> Self { + Self { + owner_agent_id: String::new(), + assigned_agent_id: String::new(), + title: String::new(), + description: None, + status: TaskStatus::Backlog, + priority: TaskPriority::Medium, + subtasks: Vec::new(), + metadata: Value::Object(serde_json::Map::new()), + source_memory_id: None, + created_by: String::new(), + binding: TaskProjectBinding::default(), + } + } } #[derive(Debug, Clone, Default)] @@ -159,6 +301,11 @@ pub struct UpdateTaskInput { pub complete_subtask: Option, /// Reassign the task to a different agent. pub assigned_agent_id: Option, + /// Rebind the task to a different codebase. `None` leaves each field as-is; + /// use `clear_binding` to unset. + pub binding: Option, + /// Clear all three binding columns. + pub clear_binding: bool, } #[derive(Debug, Clone)] @@ -236,9 +383,10 @@ impl TaskStore { INSERT INTO tasks ( id, task_number, title, description, status, priority, owner_agent_id, assigned_agent_id, - subtasks, metadata, source_memory_id, created_by + subtasks, metadata, source_memory_id, created_by, + project_id, repo_id, worktree_id ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&task_id) @@ -253,6 +401,9 @@ impl TaskStore { .bind(&metadata_json) .bind(&input.source_memory_id) .bind(&input.created_by) + .bind(&input.binding.project_id) + .bind(&input.binding.repo_id) + .bind(&input.binding.worktree_id) .execute(&mut *tx) .await; @@ -540,6 +691,17 @@ impl TaskStore { query.push_str("worker_id = ?, "); } + // Binding: clear wins over set, and an absent binding leaves the + // existing columns untouched rather than nulling them. + let next_binding = if input.clear_binding { + Some(TaskProjectBinding::default()) + } else { + input.binding.clone() + }; + if next_binding.is_some() { + query.push_str("project_id = ?, repo_id = ?, worktree_id = ?, "); + } + query.push_str( "approved_by = COALESCE(?, approved_by), \ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')", @@ -571,6 +733,13 @@ impl TaskStore { sql = sql.bind(next_worker_id); } + if let Some(binding) = &next_binding { + sql = sql + .bind(binding.project_id.clone()) + .bind(binding.repo_id.clone()) + .bind(binding.worktree_id.clone()); + } + sql.bind(input.approved_by) .bind(task_number) .execute(&mut **tx) @@ -652,13 +821,222 @@ impl TaskStore { row.map(task_from_row).transpose() } + + // -- Attempt log -------------------------------------------------------- + + /// Open a new attempt row for a task. The attempt number is one past the + /// highest existing attempt, allocated inside the transaction so two + /// concurrent starts cannot collide on the unique index. + pub async fn start_run(&self, task_number: i64, worker_id: Option<&str>) -> Result { + let mut tx = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .context("failed to open task run transaction")?; + + let next_attempt: i64 = sqlx::query_scalar( + "SELECT COALESCE(MAX(attempt), 0) + 1 FROM task_runs WHERE task_number = ?", + ) + .bind(task_number) + .fetch_one(&mut *tx) + .await + .context("failed to allocate next task run attempt")?; + + let run_id = uuid::Uuid::new_v4().to_string(); + + sqlx::query( + "INSERT INTO task_runs (id, task_number, attempt, worker_id) VALUES (?, ?, ?, ?)", + ) + .bind(&run_id) + .bind(task_number) + .bind(next_attempt) + .bind(worker_id) + .execute(&mut *tx) + .await + .context("failed to insert task run")?; + + let row = sqlx::query(&format!("{RUN_SELECT_COLUMNS} FROM task_runs WHERE id = ?")) + .bind(&run_id) + .fetch_one(&mut *tx) + .await + .context("failed to read back inserted task run")?; + + tx.commit() + .await + .context("failed to commit task run transaction")?; + + task_run_from_row(row) + } + + /// Close an attempt row with its outcome. Idempotent — closing an already + /// closed run overwrites the outcome rather than erroring, so a duplicate + /// completion path can't fail the caller. + pub async fn finish_run( + &self, + run_id: &str, + outcome: TaskRunOutcome, + summary: Option<&str>, + error: Option<&str>, + ) -> Result<()> { + sqlx::query( + "UPDATE task_runs SET outcome = ?, summary = ?, error = ?, \ + ended_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(outcome.as_str()) + .bind(summary) + .bind(error) + .bind(run_id) + .execute(&self.pool) + .await + .context("failed to finish task run")?; + + Ok(()) + } + + /// Attach a worker to an already-open run row. Used when the run is opened + /// before the worker id is known. + pub async fn set_run_worker(&self, run_id: &str, worker_id: &str) -> Result<()> { + sqlx::query("UPDATE task_runs SET worker_id = ? WHERE id = ?") + .bind(worker_id) + .bind(run_id) + .execute(&self.pool) + .await + .context("failed to set task run worker")?; + Ok(()) + } + + /// All attempts for a task, oldest first. + pub async fn list_runs(&self, task_number: i64) -> Result> { + let rows = sqlx::query(&format!( + "{RUN_SELECT_COLUMNS} FROM task_runs WHERE task_number = ? ORDER BY attempt ASC" + )) + .bind(task_number) + .fetch_all(&self.pool) + .await + .context("failed to list task runs")?; + + rows.into_iter().map(task_run_from_row).collect() + } + + // -- Failure budget ----------------------------------------------------- + + /// Record a failed attempt and decide whether the task may be retried. + /// + /// Runs as one transaction so the increment and the status change cannot + /// interleave with a concurrent claim. + pub async fn record_failure( + &self, + task_number: i64, + outcome: TaskRunOutcome, + error: &str, + ) -> Result { + if !outcome.counts_as_failure() { + return Ok(FailureDisposition::NotCounted); + } + + let mut tx = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .context("failed to open failure budget transaction")?; + + let row = sqlx::query( + "SELECT consecutive_failures, max_retries FROM tasks WHERE task_number = ?", + ) + .bind(task_number) + .fetch_optional(&mut *tx) + .await + .context("failed to read task failure budget")?; + + let Some(row) = row else { + tx.commit() + .await + .context("failed to commit empty failure budget transaction")?; + return Ok(FailureDisposition::TaskMissing); + }; + + let previous: i64 = row.try_get("consecutive_failures").unwrap_or(0); + let limit: i64 = row + .try_get::, _>("max_retries") + .ok() + .flatten() + .unwrap_or(DEFAULT_FAILURE_LIMIT); + let failures = previous + 1; + let exhausted = failures >= limit; + + let next_status = if exhausted { + TaskStatus::Blocked + } else { + TaskStatus::Ready + }; + + sqlx::query( + "UPDATE tasks SET consecutive_failures = ?, last_error = ?, status = ?, \ + worker_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ + WHERE task_number = ?", + ) + .bind(failures) + .bind(error) + .bind(next_status.as_str()) + .bind(task_number) + .execute(&mut *tx) + .await + .context("failed to persist task failure budget")?; + + tx.commit() + .await + .context("failed to commit failure budget transaction")?; + + Ok(if exhausted { + FailureDisposition::Parked { failures, limit } + } else { + FailureDisposition::Requeued { failures, limit } + }) + } + + /// Reset the failure budget. Called on successful completion, and on an + /// operator-initiated retry — a human looked at it, so the budget starts + /// over rather than immediately re-parking the task. + pub async fn clear_failures(&self, task_number: i64) -> Result<()> { + sqlx::query( + "UPDATE tasks SET consecutive_failures = 0, last_error = NULL \ + WHERE task_number = ?", + ) + .bind(task_number) + .execute(&self.pool) + .await + .context("failed to clear task failure budget")?; + + Ok(()) + } +} + +/// What [`TaskStore::record_failure`] decided to do with a failed attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureDisposition { + /// Budget remains — task returned to `ready` for another attempt. + Requeued { failures: i64, limit: i64 }, + /// Budget exhausted — task parked in `blocked` for a human. + Parked { failures: i64, limit: i64 }, + /// Outcome does not count against the budget (rate limits). + NotCounted, + /// The task row disappeared between execution and bookkeeping. + TaskMissing, } /// Column list used by all SELECT queries. Kept in sync with `task_from_row`. const SELECT_COLUMNS: &str = "SELECT id, task_number, title, description, status, priority, \ owner_agent_id, assigned_agent_id, subtasks, metadata, source_memory_id, worker_id, \ - created_by, approved_at, approved_by, created_at, updated_at, completed_at"; + created_by, approved_at, approved_by, created_at, updated_at, completed_at, \ + consecutive_failures, max_retries, last_error, project_id, repo_id, worktree_id"; + +const RUN_SELECT_COLUMNS: &str = "SELECT id, task_number, attempt, worker_id, outcome, \ + summary, error, started_at, ended_at"; +/// The single source of truth for legal status transitions. +/// +/// Both the HTTP API and the dashboard's drag-and-drop consume this, so the +/// board can never render a move the API rejects. pub fn can_transition(current: TaskStatus, next: TaskStatus) -> bool { if current == next { return true; @@ -674,11 +1052,29 @@ pub fn can_transition(current: TaskStatus, next: TaskStatus) -> bool { | (TaskStatus::Ready, TaskStatus::InProgress) | (TaskStatus::InProgress, TaskStatus::Done) | (TaskStatus::InProgress, TaskStatus::Ready) + | (TaskStatus::InProgress, TaskStatus::Blocked) | (TaskStatus::Backlog, TaskStatus::Ready) | (TaskStatus::Done, TaskStatus::Ready) + // Unblocking is always operator- or sweep-initiated. + | (TaskStatus::Blocked, TaskStatus::Ready) + | (TaskStatus::Blocked, TaskStatus::Done) ) } +/// Every legal `(from, to)` pair, for export to the dashboard so the UI and the +/// API agree on what a drag is allowed to do. +pub fn legal_transitions() -> Vec<(TaskStatus, TaskStatus)> { + let mut pairs = Vec::new(); + for from in TaskStatus::ALL { + for to in TaskStatus::ALL { + if from != to && can_transition(from, to) { + pairs.push((from, to)); + } + } + } + pairs +} + fn merge_json_object(current: Value, patch: Option) -> Value { let Some(patch) = patch else { return current; @@ -762,11 +1158,7 @@ fn task_from_row(row: sqlx::sqlite::SqliteRow) -> Result { subtasks: parse_subtasks(&subtasks_value), metadata: parse_metadata(&metadata_value), source_memory_id: row.try_get("source_memory_id").ok(), - worker_id: row - .try_get::, _>("worker_id") - .ok() - .flatten() - .and_then(|value| if value.is_empty() { None } else { Some(value) }), + worker_id: read_optional_id(&row, "worker_id"), created_by: row .try_get("created_by") .context("failed to read task created_by")?, @@ -775,6 +1167,48 @@ fn task_from_row(row: sqlx::sqlite::SqliteRow) -> Result { created_at, updated_at, completed_at: read_optional_timestamp(&row, "completed_at"), + consecutive_failures: row.try_get("consecutive_failures").unwrap_or(0), + max_retries: row.try_get("max_retries").ok().flatten(), + last_error: row + .try_get::, _>("last_error") + .ok() + .flatten() + .filter(|value| !value.is_empty()), + project_id: read_optional_id(&row, "project_id"), + repo_id: read_optional_id(&row, "repo_id"), + worktree_id: read_optional_id(&row, "worktree_id"), + }) +} + +/// Read a nullable TEXT id, treating the empty string as absent. +fn read_optional_id(row: &sqlx::sqlite::SqliteRow, column: &str) -> Option { + row.try_get::, _>(column) + .ok() + .flatten() + .filter(|value| !value.is_empty()) +} + +fn task_run_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + let outcome = row + .try_get::, _>("outcome") + .ok() + .flatten() + .and_then(|value| TaskRunOutcome::parse(&value)); + + Ok(TaskRun { + id: row.try_get("id").context("failed to read task run id")?, + task_number: row + .try_get("task_number") + .context("failed to read task run task_number")?, + attempt: row + .try_get("attempt") + .context("failed to read task run attempt")?, + worker_id: row.try_get::, _>("worker_id").ok().flatten(), + outcome, + summary: row.try_get::, _>("summary").ok().flatten(), + error: row.try_get::, _>("error").ok().flatten(), + started_at: read_timestamp(&row, "started_at")?, + ended_at: read_optional_timestamp(&row, "ended_at"), }) } @@ -803,14 +1237,15 @@ fn read_optional_timestamp(row: &sqlx::sqlite::SqliteRow, column: &str) -> Optio .map(|v| v.and_utc().to_rfc3339()) } +/// Create the task tables in a test pool. +/// +/// This is the single definition of the test schema — `cortex.rs` and any other +/// module that needs a bare pool with task tables calls this rather than +/// hand-rolling its own `CREATE TABLE`. Keep it in sync with +/// `migrations/global/`; when a migration adds a column, add it here too and +/// every test site picks it up. #[cfg(test)] -pub(crate) async fn setup_test_store() -> TaskStore { - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .expect("in-memory sqlite should connect"); - +pub(crate) async fn create_task_schema(pool: &SqlitePool) { sqlx::query( r#" CREATE TABLE tasks ( @@ -831,23 +1266,60 @@ pub(crate) async fn setup_test_store() -> TaskStore { approved_by TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - completed_at TEXT + completed_at TEXT, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER, + last_error TEXT, + project_id TEXT, + repo_id TEXT, + worktree_id TEXT ) "#, ) - .execute(&pool) + .execute(pool) .await .expect("tasks schema should be created"); + sqlx::query( + r#" + CREATE TABLE task_runs ( + id TEXT PRIMARY KEY NOT NULL, + task_number INTEGER NOT NULL, + attempt INTEGER NOT NULL, + worker_id TEXT, + outcome TEXT, + summary TEXT, + error TEXT, + started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + ended_at TEXT, + UNIQUE (task_number, attempt) + ) + "#, + ) + .execute(pool) + .await + .expect("task_runs schema should be created"); + sqlx::query( "CREATE TABLE task_number_seq ( id INTEGER PRIMARY KEY CHECK (id = 1), next_number INTEGER NOT NULL DEFAULT 1 )", ) - .execute(&pool) + .execute(pool) .await .expect("task_number_seq should be created"); +} + +#[cfg(test)] +pub(crate) async fn setup_test_store() -> TaskStore { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite should connect"); + + create_task_schema(&pool).await; sqlx::query("INSERT INTO task_number_seq (id, next_number) VALUES (1, 1)") .execute(&pool) @@ -870,14 +1342,349 @@ mod tests { owner_agent_id: "agent-test".to_string(), assigned_agent_id: "agent-test".to_string(), title: title.to_string(), - description: None, status, - priority: TaskPriority::Medium, - subtasks: Vec::new(), - metadata: serde_json::json!({}), - source_memory_id: None, created_by: "branch".to_string(), + ..Default::default() + } + } + + #[tokio::test] + async fn binding_round_trips_through_create_and_read() { + let store = setup_store().await; + let created = store + .create(CreateTaskInput { + binding: TaskProjectBinding { + project_id: Some("proj-platform".into()), + repo_id: Some("repo-api".into()), + worktree_id: Some("wt-feature".into()), + }, + ..self_assigned_input("bound task", TaskStatus::Backlog) + }) + .await + .expect("should create"); + + assert_eq!(created.project_id.as_deref(), Some("proj-platform")); + assert_eq!(created.repo_id.as_deref(), Some("repo-api")); + assert_eq!(created.worktree_id.as_deref(), Some("wt-feature")); + + let fetched = store + .get_by_number(created.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(fetched.repo_id.as_deref(), Some("repo-api")); + } + + #[tokio::test] + async fn unbound_tasks_have_no_binding() { + let store = setup_store().await; + let created = store + .create(self_assigned_input("plain task", TaskStatus::Backlog)) + .await + .expect("should create"); + + assert!(created.project_id.is_none()); + assert!(created.repo_id.is_none()); + assert!(created.worktree_id.is_none()); + } + + #[tokio::test] + async fn two_tasks_in_one_project_can_target_different_repos() { + // The multi-repo case the board previously could not express at all: + // one project, two repos, one task each. + let store = setup_store().await; + + let api_task = store + .create(CreateTaskInput { + binding: TaskProjectBinding { + project_id: Some("proj-platform".into()), + repo_id: Some("repo-api".into()), + worktree_id: None, + }, + ..self_assigned_input("change the contract", TaskStatus::Backlog) + }) + .await + .expect("should create"); + + let web_task = store + .create(CreateTaskInput { + binding: TaskProjectBinding { + project_id: Some("proj-platform".into()), + repo_id: Some("repo-web".into()), + worktree_id: None, + }, + ..self_assigned_input("regenerate clients", TaskStatus::Backlog) + }) + .await + .expect("should create"); + + assert_eq!(api_task.project_id, web_task.project_id); + assert_ne!( + api_task.repo_id, web_task.repo_id, + "two tasks in the same project must be able to name different repos" + ); + } + + #[tokio::test] + async fn update_rebinds_and_clears() { + let store = setup_store().await; + let created = store + .create(CreateTaskInput { + binding: TaskProjectBinding { + project_id: Some("proj-a".into()), + repo_id: Some("repo-1".into()), + worktree_id: None, + }, + ..self_assigned_input("movable", TaskStatus::Backlog) + }) + .await + .expect("should create"); + + // Rebind to a different repo. + let rebound = store + .update( + created.task_number, + UpdateTaskInput { + binding: Some(TaskProjectBinding { + project_id: Some("proj-a".into()), + repo_id: Some("repo-2".into()), + worktree_id: None, + }), + ..Default::default() + }, + ) + .await + .expect("update") + .expect("exists"); + assert_eq!(rebound.repo_id.as_deref(), Some("repo-2")); + + // An update that says nothing about the binding must leave it alone. + let untouched = store + .update( + created.task_number, + UpdateTaskInput { + title: Some("renamed".into()), + ..Default::default() + }, + ) + .await + .expect("update") + .expect("exists"); + assert_eq!( + untouched.repo_id.as_deref(), + Some("repo-2"), + "an unrelated update must not silently unbind the task" + ); + + // Explicit clear. + let cleared = store + .update( + created.task_number, + UpdateTaskInput { + clear_binding: true, + ..Default::default() + }, + ) + .await + .expect("update") + .expect("exists"); + assert!(cleared.project_id.is_none()); + assert!(cleared.repo_id.is_none()); + } + + #[tokio::test] + async fn runs_are_numbered_sequentially_per_task() { + let store = setup_store().await; + let task = store + .create(self_assigned_input("multi attempt", TaskStatus::Ready)) + .await + .expect("should create"); + + let first = store + .start_run(task.task_number, Some("worker-1")) + .await + .expect("first run"); + let second = store + .start_run(task.task_number, Some("worker-2")) + .await + .expect("second run"); + + assert_eq!(first.attempt, 1); + assert_eq!(second.attempt, 2); + assert!(first.outcome.is_none(), "a fresh run has no outcome yet"); + + store + .finish_run(&first.id, TaskRunOutcome::Failed, None, Some("boom")) + .await + .expect("finish first"); + + let runs = store.list_runs(task.task_number).await.expect("list runs"); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].outcome, Some(TaskRunOutcome::Failed)); + assert_eq!(runs[0].error.as_deref(), Some("boom")); + assert!(runs[0].ended_at.is_some()); + assert!(runs[1].ended_at.is_none(), "open run stays open"); + } + + #[tokio::test] + async fn failure_budget_requeues_then_parks() { + let store = setup_store().await; + let task = store + .create(self_assigned_input("flaky", TaskStatus::InProgress)) + .await + .expect("should create"); + + // First failure: budget remains, back to ready. + let first = store + .record_failure(task.task_number, TaskRunOutcome::Failed, "attempt 1 failed") + .await + .expect("record first failure"); + assert_eq!( + first, + FailureDisposition::Requeued { + failures: 1, + limit: DEFAULT_FAILURE_LIMIT + } + ); + let after_first = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after_first.status, TaskStatus::Ready); + assert_eq!(after_first.consecutive_failures, 1); + assert_eq!(after_first.last_error.as_deref(), Some("attempt 1 failed")); + + // Second failure hits the limit: parked, not requeued. + let second = store + .record_failure(task.task_number, TaskRunOutcome::Failed, "attempt 2 failed") + .await + .expect("record second failure"); + assert_eq!( + second, + FailureDisposition::Parked { + failures: 2, + limit: DEFAULT_FAILURE_LIMIT + } + ); + let after_second = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!( + after_second.status, + TaskStatus::Blocked, + "an exhausted budget must park the task instead of hot-looping" + ); + assert_eq!(after_second.consecutive_failures, 2); + } + + #[tokio::test] + async fn rate_limits_do_not_spend_the_failure_budget() { + let store = setup_store().await; + let task = store + .create(self_assigned_input("quota", TaskStatus::InProgress)) + .await + .expect("should create"); + + for _ in 0..5 { + let disposition = store + .record_failure(task.task_number, TaskRunOutcome::RateLimited, "429") + .await + .expect("record rate limit"); + assert_eq!(disposition, FailureDisposition::NotCounted); + } + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!( + after.consecutive_failures, 0, + "a provider quota outage must not trip the circuit breaker" + ); + } + + #[tokio::test] + async fn clear_failures_resets_the_budget() { + let store = setup_store().await; + let task = store + .create(self_assigned_input("retryable", TaskStatus::InProgress)) + .await + .expect("should create"); + + store + .record_failure(task.task_number, TaskRunOutcome::Failed, "nope") + .await + .expect("record failure"); + store + .clear_failures(task.task_number) + .await + .expect("clear failures"); + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.consecutive_failures, 0); + assert!(after.last_error.is_none()); + } + + #[tokio::test] + async fn max_retries_overrides_the_default_limit() { + let store = setup_store().await; + let task = store + .create(self_assigned_input("one shot", TaskStatus::InProgress)) + .await + .expect("should create"); + + sqlx::query("UPDATE tasks SET max_retries = 1 WHERE task_number = ?") + .bind(task.task_number) + .execute(store.pool()) + .await + .expect("set max_retries"); + + let disposition = store + .record_failure(task.task_number, TaskRunOutcome::Failed, "failed once") + .await + .expect("record failure"); + assert_eq!( + disposition, + FailureDisposition::Parked { + failures: 1, + limit: 1 + }, + "a max_retries of 1 must park on the first failure" + ); + } + + #[tokio::test] + async fn blocked_tasks_are_not_claimable() { + let store = setup_store().await; + let task = store + .create(self_assigned_input("parked", TaskStatus::InProgress)) + .await + .expect("should create"); + + // Burn the budget so the task lands in Blocked. + for _ in 0..DEFAULT_FAILURE_LIMIT { + store + .record_failure(task.task_number, TaskRunOutcome::Failed, "dead end") + .await + .expect("record failure"); } + + let claimed = store + .claim_next_ready("agent-test") + .await + .expect("claim should succeed"); + assert!( + claimed.is_none(), + "the pickup loop must not re-claim a task parked by the failure budget" + ); } #[tokio::test] @@ -1186,6 +1993,7 @@ mod tests { metadata: serde_json::json!({}), source_memory_id: None, created_by: "branch".to_string(), + ..Default::default() }) .await .expect("should create"); diff --git a/src/tools/send_agent_message.rs b/src/tools/send_agent_message.rs index c450d9336..6a443908c 100644 --- a/src/tools/send_agent_message.rs +++ b/src/tools/send_agent_message.rs @@ -247,6 +247,7 @@ impl Tool for SendAgentMessageTool { metadata, source_memory_id: None, created_by: format!("agent:{}", sending_agent_id), + ..Default::default() }) .await .map_err(|error| { diff --git a/src/tools/spawn_worker.rs b/src/tools/spawn_worker.rs index d54299867..f07e40c6f 100644 --- a/src/tools/spawn_worker.rs +++ b/src/tools/spawn_worker.rs @@ -78,6 +78,10 @@ pub struct SpawnWorkerArgs { /// to the project root. #[serde(default)] pub project_id: Option, + /// Repo ID within the project. A project can contain several repos, so set + /// this to point the worker at one of them rather than the project root. + #[serde(default)] + pub repo_id: Option, /// Worktree ID within the project. If set, the worker's directory is /// automatically set to the worktree path. #[serde(default)] @@ -170,6 +174,13 @@ impl Tool for SpawnWorkerTool { "description": "Project ID to associate this worker with. When set, the worker gets project context. If directory is not specified, defaults to the project root." }), ); + obj.insert( + "repo_id".to_string(), + serde_json::json!({ + "type": "string", + "description": "Repo ID within the project. A project can contain several repos; set this to point the worker at one of them instead of the project root." + }), + ); obj.insert( "worktree_id".to_string(), serde_json::json!({ @@ -234,6 +245,7 @@ impl Tool for SpawnWorkerTool { &self.state.deps, args.directory.as_deref(), args.project_id.as_deref(), + args.repo_id.as_deref(), args.worktree_id.as_deref(), ) .await; @@ -583,13 +595,19 @@ impl Tool for DetachedSpawnWorkerTool { /// Resolve a working directory from project/worktree IDs. /// -/// Priority: explicit `directory` > `worktree_id` > `project_id` root. -/// Returns the explicit directory if set, otherwise looks up worktree or -/// project root from the store. -async fn resolve_directory_from_project( +/// Priority: explicit `directory` > `worktree_id` > `repo_id` > `project_id` root. +/// +/// The `repo_id` step is what makes multi-repo projects work: a project holds +/// many repos, so a task about `api-gateway` must land in that repo's directory +/// rather than at the shared project root. +/// +/// Both `project_repos.path` and `project_worktrees.path` are stored relative to +/// the project root (see `projects/git.rs::DiscoveredRepo::relative_path`). +pub(crate) async fn resolve_directory_from_project( deps: &crate::AgentDeps, directory: Option<&str>, project_id: Option<&str>, + repo_id: Option<&str>, worktree_id: Option<&str>, ) -> Option { // Explicit directory takes precedence. @@ -621,6 +639,27 @@ async fn resolve_directory_from_project( } } + // Repo resolution: a project can hold many repos, so a repo-scoped task + // resolves to that repo's directory, not the shared project root. + if let Some(repo_id) = repo_id + && let Ok(Some(repo)) = store.get_repo(repo_id).await + { + if let Some(pid) = project_id + && pid != repo.project_id + { + tracing::warn!( + repo_id, + provided_project_id = pid, + actual_project_id = %repo.project_id, + "project_id/repo_id mismatch — using repo's project" + ); + } + if let Ok(Some(project)) = store.get_project(&repo.project_id).await { + let abs_path = std::path::Path::new(&project.root_path).join(&repo.path); + return Some(abs_path.to_string_lossy().to_string()); + } + } + // Project root resolution. if let Some(project_id) = project_id && let Ok(Some(project)) = store.get_project(project_id).await diff --git a/src/tools/task_create.rs b/src/tools/task_create.rs index 3880b49de..ddedfdd45 100644 --- a/src/tools/task_create.rs +++ b/src/tools/task_create.rs @@ -66,6 +66,16 @@ pub struct TaskCreateArgs { pub subtasks: Vec, #[serde(default)] pub metadata: Option, + /// Project this task acts on. Scopes the task to a codebase. + #[serde(default)] + pub project_id: Option, + /// Repo within the project. A project can hold several repos — set this so + /// the task is about one of them specifically. + #[serde(default)] + pub repo_id: Option, + /// Worktree to execute in. + #[serde(default)] + pub worktree_id: Option, } fn default_priority() -> String { @@ -109,6 +119,18 @@ impl Tool for TaskCreateTool { "metadata": { "type": "object", "description": "Optional metadata object" + }, + "project_id": { + "type": "string", + "description": "Project this task acts on. Scopes the task to a codebase." + }, + "repo_id": { + "type": "string", + "description": "Repo within the project. A project can hold several repos — set this when the task is about one of them specifically." + }, + "worktree_id": { + "type": "string", + "description": "Worktree to execute in." } }, "required": ["title"] @@ -143,6 +165,11 @@ impl Tool for TaskCreateTool { metadata: args.metadata.unwrap_or_else(|| serde_json::json!({})), source_memory_id: None, created_by: self.created_by.clone(), + binding: crate::tasks::TaskProjectBinding { + project_id: args.project_id, + repo_id: args.repo_id, + worktree_id: args.worktree_id, + }, }) .await .map_err(|error| TaskCreateError(format!("{error}")))?; @@ -257,6 +284,9 @@ mod tests { priority: "medium".to_string(), subtasks: Vec::new(), metadata: None, + project_id: None, + repo_id: None, + worktree_id: None, }) .await .expect("task create should succeed"); diff --git a/src/tools/task_update.rs b/src/tools/task_update.rs index f76daa4da..0308ddeba 100644 --- a/src/tools/task_update.rs +++ b/src/tools/task_update.rs @@ -330,6 +330,7 @@ mod tests { metadata: serde_json::json!({}), source_memory_id: None, created_by: "branch".to_string(), + ..Default::default() }) .await .expect("task should be created"); @@ -380,6 +381,7 @@ mod tests { metadata: serde_json::json!({}), source_memory_id: None, created_by: "branch".to_string(), + ..Default::default() }) .await .expect("task should be created"); @@ -428,6 +430,7 @@ mod tests { metadata: serde_json::json!({}), source_memory_id: None, created_by: "branch".to_string(), + ..Default::default() }) .await .expect("assigned task should be created"); @@ -443,6 +446,7 @@ mod tests { metadata: serde_json::json!({}), source_memory_id: None, created_by: "branch".to_string(), + ..Default::default() }) .await .expect("other task should be created"); From af937a8a70729887b27f063cbb3025152f2729b6 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:47:45 +0000 Subject: [PATCH 04/69] fix(interface): regenerate stale OpenAPI schema and drop the contradicting augmentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `interface/src/api/schema.d.ts` was stale on main. `ToolResultStatus` and `live_output` exist in committed Rust (src/api/state.rs, src/conversation/worker_transcript.rs, from 204ffc4 "interactive shell streaming") but had zero occurrences in the generated schema — that feature landed without running `just typegen`, so `just check-typegen` fails on main today. ToolCall.tsx had carried a hand-written local augmentation as a stopgap: type ExtendedTranscriptStep = SchemaTranscriptStep & { live_output?: string; status?: ToolResultStatus; }; Once the schema catches up, that augmentation *contradicts* the generated type — the schema says `string | null | undefined`, the augmentation says `string | undefined` — and the intersection makes assignment fail in AgentWorkers.tsx and ChannelDetail.tsx. Removed the augmentation, took ToolResultStatus from the schema, and normalized `live_output ?? undefined` at the pairing boundary so downstream consumers still only see `string | undefined`. `tsc --noEmit` now passes across the whole interface. Also regenerates the schema for the task changes in the previous commit (TaskRun, TaskRunOutcome, the blocked status, the binding columns, and the two new task endpoints). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/schema.d.ts | 259 +++++++++++++++++++++++++- interface/src/components/ToolCall.tsx | 23 ++- 2 files changed, 268 insertions(+), 14 deletions(-) diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 01ec025cd..30f1ddfe3 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -685,7 +685,7 @@ export interface paths { }; /** * Serve a saved attachment file. - * @description Streams the file from disk with the correct Content-Type. + * @description Reads the file from disk with the correct Content-Type. * Use `?download=true` to force a download prompt. * Use `?thumbnail=true` to request a thumbnail (currently serves full file). */ @@ -736,6 +736,28 @@ export interface paths { patch?: never; trace?: never; }; + "/agents/{agent_id}/wake": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Manually wake a (typically dormant) agent. + * @description Fires the same wake path that `send_agent_message`, cron, and other + * trigger sources use. Useful for debugging dormant deployments and + * recovering an agent stuck on a missed trigger. + */ + post: operations["wake_agent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/bindings": { parameters: { query?: never; @@ -2201,6 +2223,44 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * `POST /tasks/{number}/retry` — clear the failure budget and requeue. + * @description A human looked at the task, so the budget starts over rather than + * immediately re-parking it on the next failure. + */ + post: operations["retry_task"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{number}/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** `GET /tasks/{number}/runs` — the per-attempt execution log for a task. */ + get: operations["list_task_runs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tools": { parameters: { query?: never; @@ -2992,9 +3052,15 @@ export interface components { /** @description Agent that owns (created) this task. */ owner_agent_id: string; priority?: string | null; + /** @description Project this task acts on. */ + project_id?: string | null; + /** @description Repo within the project. A project holds many repos. */ + repo_id?: string | null; source_memory_id?: string | null; subtasks?: components["schemas"]["TaskSubtask"][]; title: string; + /** @description Worktree to execute in. */ + worktree_id?: string | null; }; CreateWorktreeRequest: { branch: string; @@ -3160,6 +3226,11 @@ export interface components { /** Format: date-time */ created_at: string; name: string; + /** + * @description Scope this secret belongs to. Older exports without a `scope` field + * import as `InstanceShared` so existing backups continue to load. + */ + scope?: components["schemas"]["SecretScope"]; /** Format: date-time */ updated_at: string; value: string; @@ -3923,12 +3994,36 @@ export interface components { /** Format: date-time */ created_at: string; name: string; + /** + * @description Visibility scope. `InstanceShared` is the default for system / + * admin-managed secrets; `Agent` rows are per-agent tool credentials. + */ + scope: components["schemas"]["SecretScope"]; /** Format: date-time */ updated_at: string; }; SecretListResponse: { secrets: components["schemas"]["SecretListItem"][]; }; + /** + * @description Secret scope determines visibility across agents on a shared instance. + * + * Orthogonal to `SecretCategory` — `System` secrets are always + * `InstanceShared` (singleton consumers like `LlmManager` / + * `MessagingManager`); `Tool` secrets default to `Agent(...)` for + * agentic-backend deployments where each tenant's worker subprocess must + * not see another tenant's credentials, but can also be `InstanceShared` + * when a single-tenant deployment legitimately wants every agent to share + * the same `Tool` secret (e.g. one repo-wide `GH_TOKEN`). + */ + SecretScope: { + /** @enum {string} */ + kind: "instance_shared"; + } | { + agent_id: string; + /** @enum {string} */ + kind: "agent"; + }; SetChannelArchiveRequest: { agent_id: string; archived: boolean; @@ -3981,13 +4076,37 @@ export interface components { approved_by?: string | null; assigned_agent_id: string; completed_at?: string | null; + /** + * Format: int64 + * @description Failures since the last success. Reset to 0 on completion and on an + * operator-initiated retry. + */ + consecutive_failures: number; created_at: string; created_by: string; description?: string | null; id: string; + /** + * @description Text of the most recent failure, kept on the task so the board can + * show why it is parked without joining `task_runs`. + */ + last_error?: string | null; + /** + * Format: int64 + * @description Per-task override of [`DEFAULT_FAILURE_LIMIT`]. + */ + max_retries?: number | null; metadata: unknown; owner_agent_id: string; priority: components["schemas"]["TaskPriority"]; + /** @description Project this task acts on, if any. */ + project_id?: string | null; + /** + * @description Specific repo within the project. A project holds many repos, so this is + * what makes a task about `api-gateway` distinguishable from one about + * `web` in the same project. + */ + repo_id?: string | null; source_memory_id?: string | null; status: components["schemas"]["TaskStatus"]; subtasks: components["schemas"]["TaskSubtask"][]; @@ -3996,6 +4115,11 @@ export interface components { title: string; updated_at: string; worker_id?: string | null; + /** + * @description Worktree to execute in. When set, the worker's working directory is + * resolved from it rather than from the repo or project root. + */ + worktree_id?: string | null; }; TaskActionResponse: { message: string; @@ -4009,8 +4133,30 @@ export interface components { TaskResponse: { task: components["schemas"]["Task"]; }; + /** @description A single execution attempt against a task. */ + TaskRun: { + /** Format: int64 */ + attempt: number; + ended_at?: string | null; + error?: string | null; + id: string; + outcome?: null | components["schemas"]["TaskRunOutcome"]; + started_at: string; + summary?: string | null; + /** Format: int64 */ + task_number: number; + worker_id?: string | null; + }; + /** + * @description Outcome of a single task execution attempt. + * @enum {string} + */ + TaskRunOutcome: "completed" | "failed" | "timeout" | "cancelled" | "blocked" | "rate_limited"; + TaskRunsResponse: { + runs: components["schemas"]["TaskRun"][]; + }; /** @enum {string} */ - TaskStatus: "pending_approval" | "backlog" | "ready" | "in_progress" | "done"; + TaskStatus: "pending_approval" | "backlog" | "ready" | "in_progress" | "blocked" | "done"; TaskSubtask: { completed: boolean; title: string; @@ -4079,6 +4225,8 @@ export interface components { /** Format: int64 */ reasoning: number; }; + /** @enum {string} */ + ToolResultStatus: "pending" | "final" | "waiting_for_input"; ToolsResponse: { binaries: components["schemas"]["BinaryEntry"][]; tools_bin: string; @@ -4133,7 +4281,10 @@ export interface components { type: "system_text"; } | { call_id: string; + /** @description Accumulated streaming output for live display. Cleared when tool completes. */ + live_output?: string | null; name: string; + status?: components["schemas"]["ToolResultStatus"]; text: string; /** @enum {string} */ type: "tool_result"; @@ -4254,14 +4405,19 @@ export interface components { UpdateTaskRequest: { approved_by?: string | null; assigned_agent_id?: string | null; + /** @description Unbind the task from its project/repo/worktree entirely. */ + clear_binding?: boolean; complete_subtask?: number | null; description?: string | null; metadata?: unknown; priority?: string | null; + project_id?: string | null; + repo_id?: string | null; status?: string | null; subtasks?: components["schemas"]["TaskSubtask"][] | null; title?: string | null; worker_id?: string | null; + worktree_id?: string | null; }; UploadSkillResponse: { installed: string[]; @@ -4340,6 +4496,11 @@ export interface components { /** Format: int64 */ request_count: number; }; + WakeAgentResponse: { + agent_id: string; + fired: boolean; + message: string; + }; WarmupSection: { eager_embedding_load: boolean; enabled: boolean; @@ -6483,6 +6644,35 @@ export interface operations { }; }; }; + wake_agent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Agent ID */ + agent_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WakeAgentResponse"]; + }; + }; + /** @description Wake manager not running */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_bindings: { parameters: { query?: { @@ -9924,6 +10114,71 @@ export interface operations { }; }; }; + retry_task: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + list_task_runs: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskRunsResponse"]; + }; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_tools: { parameters: { query?: never; diff --git a/interface/src/components/ToolCall.tsx b/interface/src/components/ToolCall.tsx index f2ee1658c..1a8bf438c 100644 --- a/interface/src/components/ToolCall.tsx +++ b/interface/src/components/ToolCall.tsx @@ -2,17 +2,15 @@ import {useState} from "react"; import {cx} from "class-variance-authority"; import type {OpenCodePart} from "@/api/client"; import type { TranscriptStep as SchemaTranscriptStep } from "@/api/types"; +import type { components } from "@/api/schema"; -// Extended TranscriptStep with live_output for streaming shell output -type ToolResultStatus = "pending" | "final" | "waiting_for_input"; +type ToolResultStatus = components["schemas"]["ToolResultStatus"]; -type ExtendedTranscriptStep = SchemaTranscriptStep & { - live_output?: string; - status?: ToolResultStatus; -}; - -// Use the extended type for pairing -type TranscriptStep = ExtendedTranscriptStep; +// `live_output` and `status` used to be declared here as a local augmentation +// because the generated schema predated the streaming-shell API. The schema now +// carries both, so the augmentation is gone — keeping it would contradict the +// generated types (`string | null` vs `string | undefined`) and break assignment. +type TranscriptStep = SchemaTranscriptStep; // --------------------------------------------------------------------------- // Types @@ -61,15 +59,16 @@ export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] { {name: string; text: string; status: ToolResultStatus; liveOutput?: string} >(); - // First pass: index all tool_result steps by call_id + // First pass: index all tool_result steps by call_id. + // `live_output` arrives as `string | null` from the API; normalize the null + // away here so downstream consumers only deal with `string | undefined`. for (const step of steps) { if (step.type === "tool_result") { - const liveOutput = step.live_output; resultsById.set(step.call_id, { name: step.name, text: step.text, status: step.status ?? "final", - liveOutput, + liveOutput: step.live_output ?? undefined, }); } } From 268146195123e79f5d6251640e5540eef1ff129f Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:48:01 +0000 Subject: [PATCH 05/69] feat(interface): surface blocked tasks, attempt history, and repo bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TaskList` from @spacedrive/ai groups by a hardcoded TASK_STATUS_ORDER of five statuses and silently drops anything outside it: for (const status of groups) map.set(status, []); for (const task of tasks) { const bucket = map.get(task.status); if (bucket) bucket.push(task); // unknown status => gone } So adding TaskStatus::Blocked on the backend would have made blocked tasks vanish from the board entirely. Blocked tasks are split out and rendered by a local BlockedTasksSection in both GlobalTasks and AgentTasks — which is arguably where they belong anyway: blocked means stuck and needing a human, a different queue from backlog's waiting its turn. New components: - BlockedTasksSection — red left-edge marker, failure count, the block reason muted beneath a dominant title (the reason is why a human is here, but it must not outshout which task it is), and an always-visible Retry. Retry is deliberately not hover-gated: on a queue that exists for human attention, the primary action cannot be a hover secret. - TaskRunHistory — per-attempt timeline, split into a fetching wrapper and a pure view so it renders without a backend. Rate-limited attempts read as neutral warning rather than failure, matching the fact that they do not spend the failure budget. - RepoChip — most-specific-wins (worktree > repo > project), mirroring resolve_directory_from_project's priority. Renders nothing when unbound. - RepoFilter — filter the board to one repo. The point of multi-repo work is asking "what's outstanding in api-gateway?" without reading a board that mixes four services together. - useBindingNames — resolves binding ids to names once for the whole board rather than per-card; a 200-task board would otherwise fan out into hundreds of requests. Known limit: per-row repo chips inside the main board are not possible because TaskList renders its own rows and is opaque. Chips appear on blocked cards and in the detail panel; the board-wide affordance is the filter. Group-by-repo and a project Tasks tab need a spaceui change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/client.ts | 58 ++++++- .../components/tasks/BlockedTasksSection.tsx | 148 +++++++++++++++++ interface/src/components/tasks/RepoChip.tsx | 78 +++++++++ interface/src/components/tasks/RepoFilter.tsx | 81 ++++++++++ .../src/components/tasks/TaskRunHistory.tsx | 152 ++++++++++++++++++ interface/src/hooks/useBindingNames.ts | 60 +++++++ interface/src/routes/AgentTasks.tsx | 46 +++++- interface/src/routes/GlobalTasks.tsx | 115 ++++++++++++- 8 files changed, 734 insertions(+), 4 deletions(-) create mode 100644 interface/src/components/tasks/BlockedTasksSection.tsx create mode 100644 interface/src/components/tasks/RepoChip.tsx create mode 100644 interface/src/components/tasks/RepoFilter.tsx create mode 100644 interface/src/components/tasks/TaskRunHistory.tsx create mode 100644 interface/src/hooks/useBindingNames.ts diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 51f4038f8..e31b0ebc3 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -988,9 +988,41 @@ export interface UploadSkillResponse { // -- Task Types -- -export type TaskStatus = "pending_approval" | "backlog" | "ready" | "in_progress" | "done"; +export type TaskStatus = + | "pending_approval" + | "backlog" + | "ready" + | "in_progress" + | "blocked" + | "done"; export type TaskPriority = "critical" | "high" | "medium" | "low"; +export type TaskRunOutcome = + | "completed" + | "failed" + | "timeout" + | "cancelled" + | "blocked" + | "rate_limited"; + +/// A single execution attempt against a task. +export interface TaskRun { + id: string; + task_number: number; + attempt: number; + worker_id?: string; + /** Null while the attempt is still running. */ + outcome?: TaskRunOutcome; + summary?: string; + error?: string; + started_at: string; + ended_at?: string; +} + +export interface TaskRunsResponse { + runs: TaskRun[]; +} + export interface TaskSubtask { title: string; completed: boolean; @@ -1015,6 +1047,18 @@ export interface TaskItem { created_at: string; updated_at: string; completed_at?: string; + /** Failures since the last success. Reset on completion and on manual retry. */ + consecutive_failures: number; + /** Per-task override of the instance default failure limit. */ + max_retries?: number; + /** Most recent failure text, shown on the card when the task is parked. */ + last_error?: string; + /** Project this task acts on. */ + project_id?: string | null; + /** Repo within the project. A project can hold several repos. */ + repo_id?: string | null; + /** Worktree to execute in. */ + worktree_id?: string | null; } export interface TaskListResponse { @@ -2334,6 +2378,18 @@ export const api = { if (!response.ok) throw new Error(`API error: ${response.status}`); return response.json() as Promise; }, + /** Per-attempt execution log for a task, oldest first. */ + listTaskRuns: (taskNumber: number) => + fetchJson(`/tasks/${taskNumber}/runs`), + /** Clear the failure budget and requeue. Used by the manual retry action. */ + retryTask: async (taskNumber: number): Promise => { + const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/retry`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return response.json() as Promise; + }, // Secrets API secretsStatus: () => fetchJson("/secrets/status"), diff --git a/interface/src/components/tasks/BlockedTasksSection.tsx b/interface/src/components/tasks/BlockedTasksSection.tsx new file mode 100644 index 000000000..3c505471a --- /dev/null +++ b/interface/src/components/tasks/BlockedTasksSection.tsx @@ -0,0 +1,148 @@ +import { Badge, Button } from "@spacedrive/primitives"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBan, faChevronDown, faRotateRight } from "@fortawesome/free-solid-svg-icons"; +import type { TaskItem } from "@/api/client"; +import { RepoChip, type BindingNames } from "./RepoChip"; + +/** + * Blocked tasks, rendered locally rather than through `TaskList`. + * + * `@spacedrive/ai` hardcodes `TASK_STATUS_ORDER` to five statuses and drops any + * task whose status isn't in that list: + * + * for (const status of groups) map.set(status, []); + * for (const task of tasks) { + * const bucket = map.get(task.status); + * if (bucket) bucket.push(task); // unknown status => silently dropped + * } + * + * So a `blocked` task handed to `TaskList` disappears from the board entirely. + * Until the design system learns the status, blocked tasks get their own + * section — which is arguably where they belong anyway: blocked means *stuck + * and needing a human*, which is a different queue from backlog's *waiting its + * turn*. + */ +export interface BlockedTasksSectionProps { + tasks: TaskItem[]; + collapsed?: boolean; + onToggle?: () => void; + onRetry?: (task: TaskItem) => void; + onTaskClick?: (task: TaskItem) => void; + activeTaskId?: string | null; + retryingTaskNumber?: number | null; + resolveAgentName?: (agentId: string) => string; + bindingNames?: BindingNames; +} + +export function BlockedTasksSection({ + tasks, + collapsed = false, + onToggle, + onRetry, + onTaskClick, + activeTaskId, + retryingTaskNumber, + resolveAgentName, + bindingNames, +}: BlockedTasksSectionProps) { + if (tasks.length === 0) return null; + + return ( +
+ + + {!collapsed && ( +
+ {tasks.map((task) => { + const isRetrying = retryingTaskNumber === task.task_number; + return ( +
+ + + {/* Always visible: on a queue that exists for human attention, + the primary action must not be hidden behind hover. */} + {onRetry && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/interface/src/components/tasks/RepoChip.tsx b/interface/src/components/tasks/RepoChip.tsx new file mode 100644 index 000000000..69520dd38 --- /dev/null +++ b/interface/src/components/tasks/RepoChip.tsx @@ -0,0 +1,78 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCodeBranch, faFolderTree } from "@fortawesome/free-solid-svg-icons"; +import type { TaskItem } from "@/api/client"; + +/** + * Lookup tables for turning a task's binding ids into names. + * + * The board loads projects/repos/worktrees once and passes maps down, rather + * than each chip fetching its own — a board of 200 tasks would otherwise fan + * out into hundreds of requests. + */ +export interface BindingNames { + projects: Map; + repos: Map; + worktrees: Map; +} + +export const EMPTY_BINDING_NAMES: BindingNames = { + projects: new Map(), + repos: new Map(), + worktrees: new Map(), +}; + +export interface RepoChipProps { + task: Pick; + names?: BindingNames; + className?: string; +} + +/** + * Which codebase a task acts on, at a glance. + * + * Shows the most specific binding available: worktree beats repo beats project, + * because that mirrors how the working directory is actually resolved. Renders + * nothing for unbound tasks. + */ +export function RepoChip({ task, names, className }: RepoChipProps) { + const lookup = names ?? EMPTY_BINDING_NAMES; + + // Most specific wins, matching resolve_directory_from_project's priority. + if (task.worktree_id) { + const label = lookup.worktrees.get(task.worktree_id) ?? task.worktree_id.slice(0, 8); + return ( + + ); + } + if (task.repo_id) { + const label = lookup.repos.get(task.repo_id) ?? task.repo_id.slice(0, 8); + return ; + } + if (task.project_id) { + const label = lookup.projects.get(task.project_id) ?? task.project_id.slice(0, 8); + return ; + } + return null; +} + +function Chip({ + icon, + label, + title, + className, +}: { + icon: typeof faCodeBranch; + label: string; + title: string; + className?: string; +}) { + return ( + + + {label} + + ); +} diff --git a/interface/src/components/tasks/RepoFilter.tsx b/interface/src/components/tasks/RepoFilter.tsx new file mode 100644 index 000000000..0c306c3d6 --- /dev/null +++ b/interface/src/components/tasks/RepoFilter.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { + Popover, + SelectPill, + OptionList, + OptionListItem, +} from "@spacedrive/primitives"; +import type { BindingNames } from "./RepoChip"; + +/** + * Filter the board down to one repo. + * + * The point of multi-repo work is being able to ask "what's outstanding in + * `api-gateway`?" without reading a board that mixes four services together. + */ +export const ALL_REPOS = "all"; + +export interface RepoFilterProps { + names: BindingNames; + /** Currently selected repo id, or `ALL_REPOS`. */ + value: string; + onChange: (repoId: string) => void; + /** Repo ids that actually appear on the board, so empty repos aren't listed. */ + presentRepoIds: Set; +} + +export function RepoFilter({ + names, + value, + onChange, + presentRepoIds, +}: RepoFilterProps) { + const [open, setOpen] = useState(false); + + const options = [...presentRepoIds] + .map((id) => ({ id, label: names.repos.get(id) ?? id.slice(0, 8) })) + .sort((a, b) => a.label.localeCompare(b.label)); + + // Nothing on the board is repo-bound — the filter would be noise. + if (options.length === 0) return null; + + const selectedLabel = + value === ALL_REPOS + ? "All repos" + : (names.repos.get(value) ?? value.slice(0, 8)); + + return ( + + + {selectedLabel} + + + + { + onChange(ALL_REPOS); + setOpen(false); + }} + > + All repos + + {options.map((option) => ( + { + onChange(option.id); + setOpen(false); + }} + > + {option.label} + + ))} + + + + ); +} diff --git a/interface/src/components/tasks/TaskRunHistory.tsx b/interface/src/components/tasks/TaskRunHistory.tsx new file mode 100644 index 000000000..531f070cd --- /dev/null +++ b/interface/src/components/tasks/TaskRunHistory.tsx @@ -0,0 +1,152 @@ +import { useQuery } from "@tanstack/react-query"; +import { Badge } from "@spacedrive/primitives"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faBan, + faCheck, + faClock, + faSpinner, + faTriangleExclamation, + faXmark, +} from "@fortawesome/free-solid-svg-icons"; +import { api, type TaskRun, type TaskRunOutcome } from "@/api/client"; + +/** Visual treatment per attempt outcome. */ +const OUTCOME_STYLE: Record< + TaskRunOutcome, + { icon: typeof faCheck; className: string; label: string } +> = { + completed: { icon: faCheck, className: "text-status-success", label: "Completed" }, + failed: { icon: faXmark, className: "text-status-error", label: "Failed" }, + timeout: { icon: faClock, className: "text-status-error", label: "Timed out" }, + cancelled: { icon: faBan, className: "text-ink-faint", label: "Cancelled" }, + blocked: { icon: faTriangleExclamation, className: "text-status-error", label: "Blocked" }, + // Rate limits are recorded but deliberately don't spend the failure budget, + // so they read as neutral rather than as a failure. + rate_limited: { icon: faClock, className: "text-status-warning", label: "Rate limited" }, +}; + +/** Badge treatment per outcome. A still-running attempt has no outcome yet. */ +function badgeVariantFor( + outcome?: TaskRunOutcome, +): "secondary" | "success" | "error" | "warning" { + if (!outcome) return "secondary"; + if (outcome === "completed") return "success"; + // Rate limits are not the task's fault and don't spend the failure budget. + if (outcome === "rate_limited") return "warning"; + if (outcome === "cancelled") return "secondary"; + return "error"; +} + +function formatDuration(startedAt: string, endedAt?: string): string | null { + if (!endedAt) return null; + const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime(); + if (!Number.isFinite(ms) || ms < 0) return null; + if (ms < 1000) return `${ms}ms`; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`; +} + +export interface TaskRunHistoryProps { + taskNumber: number; + /** Called when an attempt's worker is clicked, to open its transcript. */ + onWorkerClick?: (workerId: string) => void; +} + +/** + * The per-attempt execution log for a task. + * + * Each row is one entry in `task_runs`. A task retried after a crash or timeout + * has several; the currently-running attempt has no outcome and no end time. + */ +export function TaskRunHistory({ taskNumber, onWorkerClick }: TaskRunHistoryProps) { + const { data, isLoading, error } = useQuery({ + queryKey: ["task-runs", taskNumber], + queryFn: () => api.listTaskRuns(taskNumber), + refetchInterval: 10_000, + }); + + if (isLoading) { + return

Loading attempts…

; + } + if (error) { + return

Failed to load attempts

; + } + + return ; +} + +export interface TaskRunHistoryViewProps { + runs: TaskRun[]; + onWorkerClick?: (workerId: string) => void; +} + +/** Presentational half — takes runs directly so it can render without a backend. */ +export function TaskRunHistoryView({ runs, onWorkerClick }: TaskRunHistoryViewProps) { + if (runs.length === 0) { + return

No attempts recorded yet

; + } + + return ( +
+ {runs.map((run: TaskRun) => { + const style = run.outcome ? OUTCOME_STYLE[run.outcome] : null; + const duration = formatDuration(run.started_at, run.ended_at); + const running = !run.outcome; + + return ( +
+ + +
+
+ + Attempt {run.attempt} + + + {running ? "Running" : (style?.label ?? run.outcome)} + + {duration && {duration}} + {run.worker_id && ( + + )} +
+ + {run.summary && ( +

+ {run.summary} +

+ )} + {run.error && ( +

+ {run.error} +

+ )} +
+
+ ); + })} +
+ ); +} diff --git a/interface/src/hooks/useBindingNames.ts b/interface/src/hooks/useBindingNames.ts new file mode 100644 index 000000000..e565201af --- /dev/null +++ b/interface/src/hooks/useBindingNames.ts @@ -0,0 +1,60 @@ +import { useQueries, useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { api } from "@/api/client"; +import type { BindingNames } from "@/components/tasks/RepoChip"; + +/** + * Resolve project / repo / worktree ids to display names for the task board. + * + * Loaded once for the whole board and passed down, rather than each card + * fetching its own — a 200-task board would otherwise fan out into hundreds of + * requests. Repos and worktrees only exist on the per-project detail endpoint, + * so this issues one query per project. + */ +export function useBindingNames(): { names: BindingNames; isLoading: boolean } { + const { data: projectList, isLoading: projectsLoading } = useQuery({ + queryKey: ["projects", "all"], + queryFn: () => api.listProjects(), + staleTime: 60_000, + }); + + const projects = useMemo(() => projectList?.projects ?? [], [projectList]); + + const detailQueries = useQueries({ + queries: projects.map((project) => ({ + queryKey: ["project", project.id], + queryFn: () => api.getProject(project.id), + staleTime: 60_000, + })), + }); + + const names = useMemo(() => { + const projectMap = new Map(); + const repoMap = new Map(); + const worktreeMap = new Map(); + + for (const project of projects) { + projectMap.set(project.id, project.name || project.id); + } + for (const query of detailQueries) { + const detail = query.data; + if (!detail) continue; + for (const repo of detail.repos ?? []) { + repoMap.set(repo.id, repo.name || repo.path); + } + for (const worktree of detail.worktrees ?? []) { + // Worktrees are branch-scoped, so the branch is the useful label. + worktreeMap.set(worktree.id, worktree.branch || worktree.name); + } + } + + return { projects: projectMap, repos: repoMap, worktrees: worktreeMap }; + // detailQueries is a new array each render; depend on the resolved data. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projects, detailQueries.map((q) => q.dataUpdatedAt).join(",")]); + + return { + names, + isLoading: projectsLoading || detailQueries.some((q) => q.isLoading), + }; +} diff --git a/interface/src/routes/AgentTasks.tsx b/interface/src/routes/AgentTasks.tsx index be4e0456c..1f6c51d1c 100644 --- a/interface/src/routes/AgentTasks.tsx +++ b/interface/src/routes/AgentTasks.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from "react"; +import {useCallback, useEffect, useMemo, useRef, useState} from "react"; import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"; import { api, @@ -20,6 +20,8 @@ import { GithubMetadataBadges, getGithubReferences, } from "@/components/TaskUtils"; +import {BlockedTasksSection} from "@/components/tasks/BlockedTasksSection"; +import {TaskRunHistory} from "@/components/tasks/TaskRunHistory"; const TASK_LIMIT = 200; @@ -46,10 +48,22 @@ export function AgentTasks({agentId}: {agentId: string}) { const tasks = (data?.tasks ?? []) as unknown as Task[]; + // `blocked` is not in @spacedrive/ai's TaskStatus union, so those tasks are + // split out and rendered by BlockedTasksSection instead. + const blockedTasks = useMemo( + () => (data?.tasks ?? []).filter((t) => t.status === "blocked"), + [data], + ); + const boardTasks = useMemo( + () => tasks.filter((t) => (t as unknown as TaskItem).status !== "blocked"), + [tasks], + ); + const [activeTaskId, setActiveTaskId] = useState(null); const [collapsedGroups, setCollapsedGroups] = useState>( () => new Set(), ); + const [blockedCollapsed, setBlockedCollapsed] = useState(false); const [createOpen, setCreateOpen] = useState(false); const activeTask = tasks.find((t) => t.id === activeTaskId); @@ -97,6 +111,11 @@ export function AgentTasks({agentId}: {agentId: string}) { }, }); + const retryMutation = useMutation({ + mutationFn: (taskNumber: number) => api.retryTask(taskNumber), + onSuccess: () => void invalidate(), + }); + const handleStatusChange = useCallback( (task: Task, status: UiTaskStatus) => { const t = task as unknown as TaskItem; @@ -199,8 +218,23 @@ export function AgentTasks({agentId}: {agentId: string}) { ) : (
+ {/* TaskList drops any status outside TASK_STATUS_ORDER, + so blocked tasks are rendered separately. */} + setBlockedCollapsed((v) => !v)} + onRetry={(task) => retryMutation.mutate(task.task_number)} + retryingTaskNumber={ + retryMutation.isPending + ? (retryMutation.variables ?? null) + : null + } + onTaskClick={(task) => setActiveTaskId(task.id)} + activeTaskId={activeTaskId} + /> +
+

+ Attempts +

+ +
)} diff --git a/interface/src/routes/GlobalTasks.tsx b/interface/src/routes/GlobalTasks.tsx index ca28925fb..e0f13f6a3 100644 --- a/interface/src/routes/GlobalTasks.tsx +++ b/interface/src/routes/GlobalTasks.tsx @@ -26,6 +26,11 @@ import { GithubMetadataBadges, getGithubReferences, } from "@/components/TaskUtils"; +import {BlockedTasksSection} from "@/components/tasks/BlockedTasksSection"; +import {TaskRunHistory} from "@/components/tasks/TaskRunHistory"; +import {RepoChip} from "@/components/tasks/RepoChip"; +import {ALL_REPOS, RepoFilter} from "@/components/tasks/RepoFilter"; +import {useBindingNames} from "@/hooks/useBindingNames"; const TASK_LIMIT = 200; @@ -122,10 +127,45 @@ export function GlobalTasks() { const tasks = (data?.tasks ?? []) as unknown as Task[]; + const {names: bindingNames} = useBindingNames(); + const [repoFilter, setRepoFilter] = useState(ALL_REPOS); + + const rawTasks = (data?.tasks ?? []) as TaskItem[]; + + // Repo ids present on the board, so the filter only lists repos with work. + const presentRepoIds = useMemo(() => { + const ids = new Set(); + for (const task of rawTasks) { + if (task.repo_id) ids.add(task.repo_id); + } + return ids; + }, [rawTasks]); + + const matchesRepo = useCallback( + (task: TaskItem) => repoFilter === ALL_REPOS || task.repo_id === repoFilter, + [repoFilter], + ); + + // `blocked` is not in @spacedrive/ai's TaskStatus union, so those tasks are + // split out and rendered by BlockedTasksSection instead. + const blockedTasks = useMemo( + () => rawTasks.filter((t) => t.status === "blocked" && matchesRepo(t)), + [rawTasks, matchesRepo], + ); + const boardTasks = useMemo( + () => + tasks.filter((t) => { + const item = t as unknown as TaskItem; + return item.status !== "blocked" && matchesRepo(item); + }), + [tasks, matchesRepo], + ); + const [activeTaskId, setActiveTaskId] = useState(null); const [collapsedGroups, setCollapsedGroups] = useState>( () => new Set(), ); + const [blockedCollapsed, setBlockedCollapsed] = useState(false); const [createOpen, setCreateOpen] = useState(false); const activeTask = tasks.find((t) => t.id === activeTaskId); @@ -173,6 +213,11 @@ export function GlobalTasks() { }, }); + const retryMutation = useMutation({ + mutationFn: (taskNumber: number) => api.retryTask(taskNumber), + onSuccess: () => void invalidate(), + }); + const handleStatusChange = useCallback( (task: Task, status: UiTaskStatus) => { const t = task as unknown as TaskItem; @@ -237,6 +282,12 @@ export function GlobalTasks() { {tasks.length} task{tasks.length !== 1 ? "s" : ""} + {agents.length > 1 && ( ) : (
+ {/* Blocked tasks render separately: TaskList groups by + TASK_STATUS_ORDER and silently drops any status it + doesn't know, so a blocked task handed to it would + vanish from the board entirely. */} + setBlockedCollapsed((v) => !v)} + onRetry={(task) => retryMutation.mutate(task.task_number)} + retryingTaskNumber={ + retryMutation.isPending + ? (retryMutation.variables ?? null) + : null + } + onTaskClick={(task) => setActiveTaskId(task.id)} + activeTaskId={activeTaskId} + resolveAgentName={resolveAgentName} + bindingNames={bindingNames} + /> + +
+

+ Attempts +

+ +
)} ); } +/** Which codebase the selected task acts on. Hidden for unbound tasks. */ +function BindingSection({ + task, + names, +}: { + task: TaskItem; + names: ReturnType["names"]; +}) { + if (!task.project_id && !task.repo_id && !task.worktree_id) return null; + + const project = task.project_id + ? (names.projects.get(task.project_id) ?? task.project_id) + : null; + + return ( +
+

+ Codebase +

+
+ + {/* The chip shows the most specific binding; name the project too + when it isn't already what's displayed. */} + {project && (task.repo_id || task.worktree_id) && ( + in {project} + )} +
+
+ ); +} + function GithubSection({metadata}: {metadata: Record}) { const refs = getGithubReferences(metadata); if (refs.length === 0) return null; From 44078245fe2185e5aab1f2f8a6e48b7c3307a65d Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:48:12 +0000 Subject: [PATCH 06/69] chore(interface): add a dev-only UI lab for task components Renders the task components against fixtures at /__uilab with no backend running, so they can be inspected and iterated on in a browser. Gated by `import.meta.env.DEV` in the route list, so it is tree-shaken out of production builds and not linked from navigation. This is what surfaced the TaskList status-dropping bug and two design errors that were invisible in source: a hover-gated Retry button, and error text that overpowered the task title. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/router.tsx | 12 ++ interface/src/routes/UiLab.tsx | 212 +++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 interface/src/routes/UiLab.tsx diff --git a/interface/src/router.tsx b/interface/src/router.tsx index 2d2422e6c..ecb0c521f 100644 --- a/interface/src/router.tsx +++ b/interface/src/router.tsx @@ -23,6 +23,7 @@ import {AgentWorkers} from "@/routes/AgentWorkers"; import {AgentProjects} from "@/routes/AgentProjects"; import {AgentTasks} from "@/routes/AgentTasks"; import {GlobalTasks} from "@/routes/GlobalTasks"; +import {UiLab} from "@/routes/UiLab"; import {Wiki} from "@/routes/Wiki"; import {AgentChat} from "@/routes/AgentChat"; import {Settings} from "@/routes/Settings"; @@ -117,6 +118,16 @@ const tasksRoute = createRoute({ }, }); +// Development-only visual harness for task components. Tree-shaken out of +// production builds via the import.meta.env.DEV guard on the route list below. +const uiLabRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/__uilab", + component: function UiLabPage() { + return ; + }, +}); + const wikiRoute = createRoute({ getParentRoute: () => rootRoute, path: "/wiki", @@ -278,6 +289,7 @@ const routeTree = rootRoute.addChildren([ agentCronRoute, agentConfigRoute, channelRoute, + ...(import.meta.env.DEV ? [uiLabRoute] : []), ]); export const router = createRouter({ diff --git a/interface/src/routes/UiLab.tsx b/interface/src/routes/UiLab.tsx new file mode 100644 index 000000000..8cee3beac --- /dev/null +++ b/interface/src/routes/UiLab.tsx @@ -0,0 +1,212 @@ +/** + * Development-only visual harness. + * + * Renders task components against fixtures so they can be inspected without a + * running backend. Not linked from navigation; reachable at /__uilab. + */ +import { useState } from "react"; +import type { TaskItem, TaskRun } from "@/api/client"; +import { BlockedTasksSection } from "@/components/tasks/BlockedTasksSection"; +import { TaskRunHistoryView } from "@/components/tasks/TaskRunHistory"; +import { RepoChip, type BindingNames } from "@/components/tasks/RepoChip"; +import { ALL_REPOS, RepoFilter } from "@/components/tasks/RepoFilter"; + +const BINDING_NAMES: BindingNames = { + projects: new Map([["proj-platform", "platform"]]), + repos: new Map([ + ["repo-api", "api-gateway"], + ["repo-web", "web"], + ["repo-auth", "auth-service"], + ]), + worktrees: new Map([["wt-feature", "feat/contract-v2"]]), +}; + +const AGENTS: Record = { + "agent-platform": "Platform Agent", + "agent-web": "Web Agent", +}; + +function fixtureTask(overrides: Partial): TaskItem { + return { + id: crypto.randomUUID(), + task_number: 1, + title: "Untitled", + status: "blocked", + priority: "medium", + owner_agent_id: "agent-platform", + assigned_agent_id: "agent-platform", + subtasks: [], + metadata: {}, + created_by: "cortex", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + consecutive_failures: 0, + ...overrides, + }; +} + +const BLOCKED: TaskItem[] = [ + fixtureTask({ + task_number: 142, + title: "Regenerate API clients after contract change", + assigned_agent_id: "agent-web", + consecutive_failures: 2, + max_retries: 2, + project_id: "proj-platform", + repo_id: "repo-web", + last_error: + "worker exceeded 1800s wall-clock timeout after 10 segments. Last tool call: shell(`bun run codegen`) — no output for 22m.", + }), + fixtureTask({ + task_number: 138, + title: "Rotate the staging database credentials", + consecutive_failures: 2, + project_id: "proj-platform", + repo_id: "repo-auth", + last_error: "capability: no secret named STAGING_DB_URL is available to this agent", + }), + fixtureTask({ + task_number: 96, + title: "Backfill wiki pages for the ingestion subsystem", + priority: "low", + consecutive_failures: 3, + max_retries: 3, + last_error: + "context overflow after 2 compaction attempts: system prompt alone exceeds the context window", + }), +]; + +const RUNS: TaskRun[] = [ + { + id: "r1", + task_number: 142, + attempt: 1, + worker_id: "9f3a2b1c-4d5e-6f70-8901-234567890abc", + outcome: "failed", + error: "connection refused talking to the codegen sidecar on 127.0.0.1:4010", + started_at: "2026-08-02T09:14:02Z", + ended_at: "2026-08-02T09:16:41Z", + }, + { + id: "r2", + task_number: 142, + attempt: 2, + worker_id: "1a2b3c4d-5e6f-7081-9234-567890abcdef", + outcome: "rate_limited", + error: "429 Too Many Requests — provider quota exhausted, retrying without spending budget", + started_at: "2026-08-02T09:20:00Z", + ended_at: "2026-08-02T09:20:12Z", + }, + { + id: "r3", + task_number: 142, + attempt: 3, + worker_id: "abcdef01-2345-6789-abcd-ef0123456789", + outcome: "timeout", + error: "worker exceeded 1800s wall-clock timeout after 10 segments", + started_at: "2026-08-02T09:25:00Z", + ended_at: "2026-08-02T09:55:00Z", + }, + { + id: "r4", + task_number: 142, + attempt: 4, + worker_id: "55555555-6666-7777-8888-999999999999", + outcome: "completed", + summary: + "Regenerated 4 client packages, ran the contract test suite (18 passed), and opened PR #331 against web.", + started_at: "2026-08-02T10:02:00Z", + ended_at: "2026-08-02T10:09:30Z", + }, + { + id: "r5", + task_number: 142, + attempt: 5, + worker_id: "77777777-8888-9999-aaaa-bbbbbbbbbbbb", + started_at: "2026-08-02T10:30:00Z", + }, +]; + +export function UiLab() { + const [collapsed, setCollapsed] = useState(false); + const [retrying, setRetrying] = useState(null); + const [repo, setRepo] = useState(ALL_REPOS); + + return ( +
+

UI Lab

+

+ Development harness — task components rendered against fixtures. +

+ +
+

+ BlockedTasksSection +

+
+ setCollapsed((v) => !v)} + onRetry={(task) => { + setRetrying(task.task_number); + setTimeout(() => setRetrying(null), 1500); + }} + retryingTaskNumber={retrying} + resolveAgentName={(id) => AGENTS[id] ?? id} + bindingNames={BINDING_NAMES} + /> +
+
+ +
+

+ RepoChip / RepoFilter +

+
+ + selected: {repo} +
+ + + + + (unbound renders nothing →) + + +
+
+
+ +
+

+ TaskRunHistory +

+
+ +
+
+
+ ); +} From 406c39aa7ffee63d2e803a162faa66556fc7c2e2 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:56:10 +0000 Subject: [PATCH 07/69] fix(tasks): stop a partial binding PATCH from unbinding its siblings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from reviewing the foundation commits. 1. `PATCH /tasks/{n}` naming a single binding column nulled the other two. The handler built a full `TaskProjectBinding` whenever *any* of project/repo/worktree was present, filling the absent fields with `None`, and the store then wrote all three columns unconditionally. So `{"repo_id": "..."}` silently unbound the project and the worktree. `UpdateTaskInput` now carries a `TaskBindingPatch` whose fields are `Option>`: absent leaves the column alone, `Some(None)` clears it, `Some(Some(id))` sets it. The store emits one SQL fragment per named column instead of a fixed triple. The existing test never caught this because it only ever passed complete bindings. 2. `record_failure` wrote status with no guard on the current status. A worker runs for minutes; if a human completed or reassigned the task in that window, the late failure dragged it back to `ready`. It now requires `in_progress` and reports `NoLongerRunning` otherwise, leaving whatever the human decided in place. `BEGIN IMMEDIATE` already holds the write lock, so the read and the update cannot interleave. The budget tests were passing only because they called `record_failure` twice in a row against a task the first call had already moved to `ready`. They now re-claim between attempts, which is what actually happens. 3. `legal_transitions()` claimed in its doc comment that "both the HTTP API and the dashboard's drag-and-drop consume this" and had zero callers. Removed rather than left as a promise; the transition surface belongs with the blocked column that needs it. `is_terminal()`'s doc comment described the opposite of what the function returns — corrected, and it is the gate predicate the dependency sweep will use. 4. `max_retries` is a failure limit, not a retry count: at 1 it allows one attempt and zero retries. Documented on the field rather than renamed, since the column ships in an applied migration. Also drops `idx_task_runs_task`, which duplicated the UNIQUE index over the same two columns in the same order. Done in a new migration because migration files are immutable once committed (AGENTS.md). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/schema.d.ts | 4 + ...02000003_drop_redundant_task_run_index.sql | 12 + src/agent/cortex.rs | 11 + src/api/tasks.rs | 20 +- src/tasks.rs | 7 +- src/tasks/store.rs | 260 +++++++++++++++--- 6 files changed, 254 insertions(+), 60 deletions(-) create mode 100644 migrations/global/20260802000003_drop_redundant_task_run_index.sql diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 30f1ddfe3..cbd0e5364 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -4094,6 +4094,10 @@ export interface components { /** * Format: int64 * @description Per-task override of [`DEFAULT_FAILURE_LIMIT`]. + * + * Despite the name (inherited from the column), this is a *failure* limit, + * not a retry count: the task is parked once `consecutive_failures` + * reaches it, so `max_retries = 1` allows one attempt and zero retries. */ max_retries?: number | null; metadata: unknown; diff --git a/migrations/global/20260802000003_drop_redundant_task_run_index.sql b/migrations/global/20260802000003_drop_redundant_task_run_index.sql new file mode 100644 index 000000000..1ef63626c --- /dev/null +++ b/migrations/global/20260802000003_drop_redundant_task_run_index.sql @@ -0,0 +1,12 @@ +-- Drop a redundant index created by 20260802000001_task_runs.sql. +-- +-- That migration created both `idx_task_runs_task` and the UNIQUE +-- `idx_task_runs_task_attempt` over exactly the same columns, in the same +-- order. The unique index already serves every lookup the plain one did, so +-- the plain one was pure write amplification. +-- +-- Fixed here rather than by editing the original: migration files are +-- immutable once committed (AGENTS.md), because rewriting one changes its +-- checksum and blocks startup for anyone who already applied it. + +DROP INDEX IF EXISTS idx_task_runs_task; diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index ae1df3276..ee79ea21a 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -3849,6 +3849,17 @@ async fn handle_detached_completion( "task attempt hit a provider rate limit — requeueing without counting a failure" ); } + Ok(crate::tasks::FailureDisposition::NoLongerRunning { status }) => { + // Somebody moved the task while the worker was in flight. + // Their decision wins — do not drag it back to `ready`. + tracing::info!( + task_number = task.task_number, + status = status.map(|s| s.as_str()).unwrap_or("unknown"), + "task left in_progress while its worker ran — leaving the new status alone" + ); + run_logger.log_worker_completed(worker_id, result_text, false); + return; + } Ok(crate::tasks::FailureDisposition::TaskMissing) => { tracing::warn!( task_number = task.task_number, diff --git a/src/api/tasks.rs b/src/api/tasks.rs index e25dd77ca..077ecfd45 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -350,19 +350,13 @@ pub(super) async fn update_task( let status = parse_status(request.status.as_deref())?; let priority = parse_priority(request.priority.as_deref())?; - // Only send a binding when at least one field was supplied; otherwise the - // existing columns are left alone. - let binding = if request.project_id.is_some() - || request.repo_id.is_some() - || request.worktree_id.is_some() - { - Some(crate::tasks::TaskProjectBinding { - project_id: request.project_id, - repo_id: request.repo_id, - worktree_id: request.worktree_id, - }) - } else { - None + // Each binding column is patched independently: naming only `repo_id` must + // rebind the repo and leave the project and worktree exactly as they were. + // Use `clear_binding` to unbind entirely. + let binding = crate::tasks::TaskBindingPatch { + project_id: request.project_id.map(Some), + repo_id: request.repo_id.map(Some), + worktree_id: request.worktree_id.map(Some), }; let task = store diff --git a/src/tasks.rs b/src/tasks.rs index ce7be9289..3af939379 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -4,7 +4,8 @@ pub mod migration; pub mod store; pub use store::{ - CreateTaskInput, DEFAULT_FAILURE_LIMIT, FailureDisposition, Task, TaskListFilter, TaskPriority, - TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, TaskStore, TaskSubtask, - TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, can_transition, legal_transitions, + CreateTaskInput, DEFAULT_FAILURE_LIMIT, FailureDisposition, Task, TaskBindingPatch, + TaskListFilter, TaskPriority, TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, + TaskStore, TaskSubtask, TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, + can_transition, }; diff --git a/src/tasks/store.rs b/src/tasks/store.rs index 8aa37af0b..3745f27c2 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -60,7 +60,9 @@ impl TaskStatus { } } - /// Whether a task in this status is eligible for the pickup loop. + /// Whether this status is an end state — nothing further will move the + /// task on its own. Used as the gate predicate for dependency edges: a + /// child is eligible only once every parent is terminal. pub fn is_terminal(self) -> bool { matches!(self, TaskStatus::Done) } @@ -145,6 +147,10 @@ pub struct Task { /// operator-initiated retry. pub consecutive_failures: i64, /// Per-task override of [`DEFAULT_FAILURE_LIMIT`]. + /// + /// Despite the name (inherited from the column), this is a *failure* limit, + /// not a retry count: the task is parked once `consecutive_failures` + /// reaches it, so `max_retries = 1` allows one attempt and zero retries. pub max_retries: Option, /// Text of the most recent failure, kept on the task so the board can /// show why it is parked without joining `task_runs`. @@ -176,6 +182,48 @@ impl TaskProjectBinding { } } +/// A partial update to a task's binding. +/// +/// Each field is independently three-valued, which [`TaskProjectBinding`] is +/// not: `None` leaves the column alone, `Some(None)` clears it, `Some(Some(id))` +/// sets it. Using the flat binding here would make "set the repo" indistinguishable +/// from "set the repo and unbind the project", which is exactly the bug this type +/// exists to prevent — a `PATCH` naming one field must not null its siblings. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TaskBindingPatch { + pub project_id: Option>, + pub repo_id: Option>, + pub worktree_id: Option>, +} + +impl TaskBindingPatch { + /// A patch that clears all three columns. + pub fn clear_all() -> Self { + Self { + project_id: Some(None), + repo_id: Some(None), + worktree_id: Some(None), + } + } + + /// Whether this patch touches any column at all. + pub fn is_noop(&self) -> bool { + self.project_id.is_none() && self.repo_id.is_none() && self.worktree_id.is_none() + } +} + +impl From for TaskBindingPatch { + /// Sets every field, including the absent ones. Use this only when the + /// caller genuinely supplied a complete binding. + fn from(binding: TaskProjectBinding) -> Self { + Self { + project_id: Some(binding.project_id), + repo_id: Some(binding.repo_id), + worktree_id: Some(binding.worktree_id), + } + } +} + /// How many consecutive failures a task may accumulate before it is parked in /// [`TaskStatus::Blocked`] instead of being requeued. pub const DEFAULT_FAILURE_LIMIT: i64 = 2; @@ -301,10 +349,10 @@ pub struct UpdateTaskInput { pub complete_subtask: Option, /// Reassign the task to a different agent. pub assigned_agent_id: Option, - /// Rebind the task to a different codebase. `None` leaves each field as-is; - /// use `clear_binding` to unset. - pub binding: Option, - /// Clear all three binding columns. + /// Rebind the task to a different codebase, one column at a time. An + /// untouched field is left as-is rather than nulled. + pub binding: TaskBindingPatch, + /// Clear all three binding columns, overriding `binding`. pub clear_binding: bool, } @@ -691,15 +739,22 @@ impl TaskStore { query.push_str("worker_id = ?, "); } - // Binding: clear wins over set, and an absent binding leaves the - // existing columns untouched rather than nulling them. - let next_binding = if input.clear_binding { - Some(TaskProjectBinding::default()) + // Binding: clear wins over set, and each column is emitted only when + // the patch actually names it. Emitting all three unconditionally is + // what made a single-field PATCH silently unbind its siblings. + let binding_patch = if input.clear_binding { + TaskBindingPatch::clear_all() } else { input.binding.clone() }; - if next_binding.is_some() { - query.push_str("project_id = ?, repo_id = ?, worktree_id = ?, "); + if binding_patch.project_id.is_some() { + query.push_str("project_id = ?, "); + } + if binding_patch.repo_id.is_some() { + query.push_str("repo_id = ?, "); + } + if binding_patch.worktree_id.is_some() { + query.push_str("worktree_id = ?, "); } query.push_str( @@ -733,11 +788,15 @@ impl TaskStore { sql = sql.bind(next_worker_id); } - if let Some(binding) = &next_binding { - sql = sql - .bind(binding.project_id.clone()) - .bind(binding.repo_id.clone()) - .bind(binding.worktree_id.clone()); + // Bind order must match the fragment order pushed above. + if let Some(project_id) = &binding_patch.project_id { + sql = sql.bind(project_id.clone()); + } + if let Some(repo_id) = &binding_patch.repo_id { + sql = sql.bind(repo_id.clone()); + } + if let Some(worktree_id) = &binding_patch.worktree_id { + sql = sql.bind(worktree_id.clone()); } sql.bind(input.approved_by) @@ -941,7 +1000,7 @@ impl TaskStore { .context("failed to open failure budget transaction")?; let row = sqlx::query( - "SELECT consecutive_failures, max_retries FROM tasks WHERE task_number = ?", + "SELECT status, consecutive_failures, max_retries FROM tasks WHERE task_number = ?", ) .bind(task_number) .fetch_optional(&mut *tx) @@ -955,6 +1014,23 @@ impl TaskStore { return Ok(FailureDisposition::TaskMissing); }; + // A worker runs for minutes. If a human completed, cancelled, or + // reassigned the task in that window, the attempt's failure must not + // drag it back to `ready`. `BEGIN IMMEDIATE` holds the write lock, so + // this read and the update below cannot interleave with another writer. + let current_status = row + .try_get::("status") + .ok() + .and_then(|value| TaskStatus::parse(&value)); + if current_status != Some(TaskStatus::InProgress) { + tx.commit() + .await + .context("failed to commit no-op failure budget transaction")?; + return Ok(FailureDisposition::NoLongerRunning { + status: current_status, + }); + } + let previous: i64 = row.try_get("consecutive_failures").unwrap_or(0); let limit: i64 = row .try_get::, _>("max_retries") @@ -973,7 +1049,7 @@ impl TaskStore { sqlx::query( "UPDATE tasks SET consecutive_failures = ?, last_error = ?, status = ?, \ worker_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ - WHERE task_number = ?", + WHERE task_number = ? AND status = 'in_progress'", ) .bind(failures) .bind(error) @@ -1020,6 +1096,9 @@ pub enum FailureDisposition { Parked { failures: i64, limit: i64 }, /// Outcome does not count against the budget (rate limits). NotCounted, + /// The task moved out of `in_progress` while the worker was in flight — a + /// human completed, reassigned, or requeued it. Left untouched. + NoLongerRunning { status: Option }, /// The task row disappeared between execution and bookkeeping. TaskMissing, } @@ -1033,10 +1112,8 @@ const SELECT_COLUMNS: &str = "SELECT id, task_number, title, description, status const RUN_SELECT_COLUMNS: &str = "SELECT id, task_number, attempt, worker_id, outcome, \ summary, error, started_at, ended_at"; -/// The single source of truth for legal status transitions. -/// -/// Both the HTTP API and the dashboard's drag-and-drop consume this, so the -/// board can never render a move the API rejects. +/// The single source of truth for legal status transitions, enforced by the +/// store on every update. pub fn can_transition(current: TaskStatus, next: TaskStatus) -> bool { if current == next { return true; @@ -1061,20 +1138,6 @@ pub fn can_transition(current: TaskStatus, next: TaskStatus) -> bool { ) } -/// Every legal `(from, to)` pair, for export to the dashboard so the UI and the -/// API agree on what a drag is allowed to do. -pub fn legal_transitions() -> Vec<(TaskStatus, TaskStatus)> { - let mut pairs = Vec::new(); - for from in TaskStatus::ALL { - for to in TaskStatus::ALL { - if from != to && can_transition(from, to) { - pairs.push((from, to)); - } - } - } - pairs -} - fn merge_json_object(current: Value, patch: Option) -> Value { let Some(patch) = patch else { return current; @@ -1440,16 +1503,56 @@ mod tests { .await .expect("should create"); - // Rebind to a different repo. + // Rebind only the repo. The project must survive untouched — naming one + // column in a patch must never null its siblings. + let rebound = store + .update( + created.task_number, + UpdateTaskInput { + binding: TaskBindingPatch { + repo_id: Some(Some("repo-2".into())), + ..Default::default() + }, + ..Default::default() + }, + ) + .await + .expect("update") + .expect("exists"); + assert_eq!(rebound.repo_id.as_deref(), Some("repo-2")); + assert_eq!( + rebound.project_id.as_deref(), + Some("proj-a"), + "patching the repo must not unbind the project" + ); + + // A single column can also be cleared on its own. + let repo_cleared = store + .update( + created.task_number, + UpdateTaskInput { + binding: TaskBindingPatch { + repo_id: Some(None), + ..Default::default() + }, + ..Default::default() + }, + ) + .await + .expect("update") + .expect("exists"); + assert!(repo_cleared.repo_id.is_none()); + assert_eq!(repo_cleared.project_id.as_deref(), Some("proj-a")); + + // Put the repo back for the remaining assertions. let rebound = store .update( created.task_number, UpdateTaskInput { - binding: Some(TaskProjectBinding { - project_id: Some("proj-a".into()), - repo_id: Some("repo-2".into()), - worktree_id: None, - }), + binding: TaskBindingPatch { + repo_id: Some(Some("repo-2".into())), + ..Default::default() + }, ..Default::default() }, ) @@ -1555,6 +1658,20 @@ mod tests { assert_eq!(after_first.consecutive_failures, 1); assert_eq!(after_first.last_error.as_deref(), Some("attempt 1 failed")); + // The requeued task gets picked up again before it can fail again — + // `record_failure` only acts on a task that is actually running. + store + .update( + task.task_number, + UpdateTaskInput { + status: Some(TaskStatus::InProgress), + ..Default::default() + }, + ) + .await + .expect("re-claim") + .expect("exists"); + // Second failure hits the limit: parked, not requeued. let second = store .record_failure(task.task_number, TaskRunOutcome::Failed, "attempt 2 failed") @@ -1580,6 +1697,53 @@ mod tests { assert_eq!(after_second.consecutive_failures, 2); } + #[tokio::test] + async fn failure_does_not_override_a_status_changed_mid_run() { + let store = setup_store().await; + let task = store + .create(self_assigned_input("long runner", TaskStatus::InProgress)) + .await + .expect("should create"); + + // A human marks it done while the worker is still in flight. + store + .update( + task.task_number, + UpdateTaskInput { + status: Some(TaskStatus::Done), + ..Default::default() + }, + ) + .await + .expect("update") + .expect("exists"); + + // The worker then fails. The human's decision must win. + let disposition = store + .record_failure(task.task_number, TaskRunOutcome::Failed, "too late") + .await + .expect("record failure"); + assert_eq!( + disposition, + FailureDisposition::NoLongerRunning { + status: Some(TaskStatus::Done) + } + ); + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!( + after.status, + TaskStatus::Done, + "a late failure must not resurrect a task somebody already closed" + ); + assert_eq!(after.consecutive_failures, 0); + assert!(after.last_error.is_none()); + } + #[tokio::test] async fn rate_limits_do_not_spend_the_failure_budget() { let store = setup_store().await; @@ -1669,8 +1833,16 @@ mod tests { .await .expect("should create"); - // Burn the budget so the task lands in Blocked. - for _ in 0..DEFAULT_FAILURE_LIMIT { + // Burn the budget so the task lands in Blocked, going through a real + // claim each round — `record_failure` only acts on a running task. + for round in 0..DEFAULT_FAILURE_LIMIT { + if round > 0 { + store + .claim_next_ready("agent-test") + .await + .expect("claim should succeed") + .expect("a requeued task must be claimable again"); + } store .record_failure(task.task_number, TaskRunOutcome::Failed, "dead end") .await From 39904a216e5afff34863bbd658e84d43a327dd48 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:59:32 +0000 Subject: [PATCH 08/69] test(db): pin foreign key enforcement on the instance database I claimed in review that `ON DELETE SET NULL` on the task binding columns never fires because nothing in the repo issues `PRAGMA foreign_keys` and SQLite defaults it off. That was wrong: sqlx sets `foreign_keys = ON` on every connection it opens (sqlx-sqlite `options/mod.rs`, default pragma map), so the constraints have been live all along. Rather than leave that resting on a driver default nobody can see from this codebase, this pins it. Against a real migrated instance database the test asserts the pragma reads on, that a task cannot be bound to a project that does not exist, and that deleting a project unbinds the task instead of cascading the delete away. A driver swap or an options change now fails here instead of silently leaving dangling project ids on tasks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- src/db.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/db.rs b/src/db.rs index 522ae023e..e001a18e7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -118,3 +118,74 @@ pub async fn connect_instance_db(data_dir: &Path) -> Result { Ok(pool) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Foreign keys must actually be enforced. + /// + /// Nothing in this repo issues `PRAGMA foreign_keys`, which reads like the + /// constraints are decorative — SQLite itself defaults the pragma off. They + /// are not: sqlx sets `foreign_keys = ON` on every connection it opens + /// (sqlx-sqlite `options/mod.rs`, default pragma map). This test pins that + /// behaviour so an options change or a driver swap fails here rather than + /// silently leaving dangling `project_id`s on tasks. + #[tokio::test] + async fn instance_db_enforces_task_project_foreign_keys() { + let dir = tempfile::tempdir().expect("temp dir"); + let pool = connect_instance_db(dir.path()) + .await + .expect("instance db should connect and migrate"); + + let enforced: i64 = sqlx::query_scalar("PRAGMA foreign_keys") + .fetch_one(&pool) + .await + .expect("read foreign_keys pragma"); + assert_eq!(enforced, 1, "foreign key enforcement must be on"); + + sqlx::query( + "INSERT INTO projects (id, name, root_path) VALUES ('p1', 'platform', '/tmp/p1')", + ) + .execute(&pool) + .await + .expect("insert project"); + + sqlx::query( + "INSERT INTO tasks (id, task_number, title, owner_agent_id, assigned_agent_id, \ + created_by, project_id) VALUES ('t1', 1, 'bound', 'a', 'a', 'test', 'p1')", + ) + .execute(&pool) + .await + .expect("insert bound task"); + + // A binding to a project that does not exist must be rejected outright. + let dangling = sqlx::query( + "INSERT INTO tasks (id, task_number, title, owner_agent_id, assigned_agent_id, \ + created_by, project_id) VALUES ('t2', 2, 'dangling', 'a', 'a', 'test', 'nope')", + ) + .execute(&pool) + .await; + assert!( + dangling.is_err(), + "a task must not be bindable to a project that does not exist" + ); + + // Deleting the project unbinds the task rather than destroying it. + sqlx::query("DELETE FROM projects WHERE id = 'p1'") + .execute(&pool) + .await + .expect("delete project"); + + let (survived, project_id): (i64, Option) = + sqlx::query_as("SELECT task_number, project_id FROM tasks WHERE id = 't1'") + .fetch_one(&pool) + .await + .expect("task should survive its project"); + assert_eq!(survived, 1); + assert!( + project_id.is_none(), + "ON DELETE SET NULL must unbind the task, not cascade the delete" + ); + } +} From 983350c6f791ec114a1ba95a786b2e9ea4641497 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:16:23 +0000 Subject: [PATCH 09/69] fix(worker): actually run bound tasks in their bound directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task pickup appended "Working directory: X" to the prompt and left it at that. `Worker` had no cwd, so the shell and file tools stayed rooted at the agent workspace: a task bound to `api-gateway` produced a worker that started in the workspace and was merely asked to `cd`. A model that ignored the sentence wrote to the wrong tree, and nothing stopped it. `spawn_worker` was worse. It resolved `directory`/`project_id`/`repo_id`/ `worktree_id` into a path and then used it only in the OpenCode branch — for builtin workers the resolved directory was computed and dropped, so the documented `directory` argument silently did nothing. `Worker::with_working_dir` now roots a worker's tools at a given directory, and both paths use it. The check in `resolve_worker_working_dir` is the load-bearing part. The shell tool validates an explicit `working_dir` argument against the sandbox allowlist but treats its own root as trusted and skips the check there — so accepting an arbitrary root would convert every caller into a sandbox bypass. A root is therefore accepted only if it canonicalises, is a directory, and is already inside the workspace or an allowed project path. With the sandbox disabled there is no boundary to bypass and no allowlist to consult, so anything readable is accepted; inventing a restriction there would break unsandboxed deployments. Rejection is not silently downgraded. A task whose binding resolves outside the allowlist is a misconfiguration, and running it in the workspace instead would do the work in the wrong place — so pickup records a failure with the reason, and the existing budget parks it for a human. The prompt line stays, but now it describes what the tools are already doing rather than making a request. Split the guard into a free function so the security property is tested without standing up an agent: outside-the-allowlist, inside-a-registered- project, under-the-workspace, sandbox-disabled, missing, and not-a-directory. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- src/agent/channel_dispatch.rs | 24 ++++- src/agent/cortex.rs | 88 +++++++++++---- src/agent/worker.rs | 194 +++++++++++++++++++++++++++++++++- src/tasks/store.rs | 11 ++ src/tools/spawn_worker.rs | 1 + 5 files changed, 293 insertions(+), 25 deletions(-) diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index 83295ca76..208a61952 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -600,14 +600,22 @@ pub async fn spawn_worker_from_state( interactive: bool, suggested_skills: &[&str], worker_context: &WorkerContextMode, + working_dir: Option, ) -> std::result::Result { check_worker_limit(state).await?; let task = task.into(); reserve_task_if_unique(state, &task).await?; ensure_dispatch_readiness(state, "worker"); - let result = - spawn_worker_inner(state, &task, interactive, suggested_skills, worker_context).await; + let result = spawn_worker_inner( + state, + &task, + interactive, + suggested_skills, + worker_context, + working_dir, + ) + .await; // Release the reservation regardless of success or failure. // On success the task is now in the status block; on failure it needs cleanup. @@ -624,6 +632,7 @@ async fn spawn_worker_inner( interactive: bool, suggested_skills: &[&str], worker_context: &WorkerContextMode, + working_dir: Option, ) -> std::result::Result { let rc = &state.deps.runtime_config; let prompt_engine = rc.prompts.load(); @@ -793,6 +802,17 @@ async fn spawn_worker_inner( worker }; + // Root the worker in the caller's requested directory. Before this the + // resolved directory was computed and then dropped for builtin workers, so + // `spawn_worker(directory: ...)` only ever took effect for OpenCode — the + // argument silently did nothing everywhere else. + let worker = match working_dir { + Some(dir) => worker + .with_working_dir(&dir) + .map_err(|error| AgentError::Other(anyhow::anyhow!("{error}")))?, + None => worker, + }; + let worker_id = worker.id; let worker_span = tracing::info_span!( diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index ee79ea21a..a06a5efb1 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -4160,28 +4160,36 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho } } - // A task bound to a project/repo/worktree names its working directory - // explicitly. Without this the worker only sees the global project listing - // and has to guess which repo the task is about — the whole point of the - // binding is that it doesn't have to. - if let Some(directory) = crate::tools::spawn_worker::resolve_directory_from_project( - deps, - None, - task.project_id.as_deref(), - task.repo_id.as_deref(), - task.worktree_id.as_deref(), - ) - .await - { - task_prompt.push_str("\n\nWorking directory: "); - task_prompt.push_str(&directory); - task_prompt.push_str("\nThis task is scoped to that directory. Work there unless the task explicitly says otherwise."); + // A task bound to a project/repo/worktree runs *in* that directory — the + // worker's shell and file tools are rooted there, not merely told about it. + // + // Deliberately no sandbox mutation here. A task can only bind to a + // registered project (the FK is enforced), whose root is already in the + // allowlist via `refresh_project_paths`, and repo/worktree paths live under + // that root. Widening the sandbox as a side effect of task pickup would be + // a quiet privilege escalation, so `with_working_dir` rejects anything the + // allowlist doesn't already cover. + let bound_directory = if task.binding().is_empty() { + None + } else { + crate::tools::spawn_worker::resolve_directory_from_project( + deps, + None, + task.project_id.as_deref(), + task.repo_id.as_deref(), + task.worktree_id.as_deref(), + ) + .await + }; - // Deliberately no sandbox mutation here. A task can only bind to a - // registered project (enforced by the FK), whose root is already in the - // allowlist via `refresh_project_paths`, and repo/worktree paths live - // under that root. Widening the sandbox as a side effect of task pickup - // would be a quiet privilege escalation. + if let Some(directory) = &bound_directory { + task_prompt.push_str("\n\nWorking directory: "); + task_prompt.push_str(directory); + task_prompt.push_str( + "\nYour shell and file tools already run here — relative paths resolve \ + against this directory. This task is scoped to it; work here unless the \ + task explicitly says otherwise.", + ); } let screenshot_dir = deps @@ -4222,6 +4230,44 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho // workers don't support mid-flight context injection. drop(inject_tx); + // Root the worker in its bound directory. A rejection here means the + // binding points somewhere the sandbox does not allow, which is a + // misconfiguration — running the task in the workspace instead would do the + // work in the wrong tree, so park it for a human rather than guess. + let worker = match &bound_directory { + Some(directory) => match worker.with_working_dir(std::path::Path::new(directory)) { + Ok(worker) => worker, + Err(error) => { + let message = format!( + "task #{} is bound to a directory the worker may not use: {error}", + task.task_number + ); + tracing::error!(task_number = task.task_number, %error, "refusing to run task outside its binding"); + logger.log( + "task_pickup_binding_rejected", + &message, + Some(serde_json::json!({ + "task_number": task.task_number, + "directory": directory, + })), + ); + if let Err(error) = deps + .task_store + .record_failure( + task.task_number, + crate::tasks::TaskRunOutcome::Failed, + &message, + ) + .await + { + tracing::warn!(%error, task_number = task.task_number, "failed to record binding rejection"); + } + return Ok(()); + } + }, + None => worker, + }; + let worker_id = worker.id; let (detached_worker_lifecycle, mut detached_cancel_rx) = register_detached_worker_for_pickup( &deps.process_control_registry, diff --git a/src/agent/worker.rs b/src/agent/worker.rs index 3f0a80eaf..6a942ca4d 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -12,7 +12,7 @@ use rig::agent::AgentBuilder; use rig::completion::CompletionModel; use std::collections::HashMap; use std::fmt::Write as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tokio::sync::{mpsc, watch}; use uuid::Uuid; @@ -223,6 +223,51 @@ pub enum WorkerState { } /// A worker process that executes tasks independently. +/// Why a requested worker working directory was refused. +#[derive(Debug, thiserror::Error)] +pub enum WorkingDirError { + #[error("working directory {} could not be resolved: {source}", .path.display())] + Unreadable { + path: PathBuf, + source: std::io::Error, + }, + #[error("working directory {} is not a directory", .path.display())] + NotADirectory { path: PathBuf }, + #[error( + "working directory {} is outside the workspace and every allowed project path", + .path.display() + )] + NotAllowed { path: PathBuf }, +} + +/// Canonicalise `dir` and confirm the sandbox permits it as a worker root. +/// +/// Split out from [`Worker::with_working_dir`] so the security property is +/// testable without standing up an entire agent. +pub fn resolve_worker_working_dir( + sandbox: &crate::sandbox::Sandbox, + dir: &Path, +) -> std::result::Result { + let canonical = dir + .canonicalize() + .map_err(|source| WorkingDirError::Unreadable { + path: dir.to_path_buf(), + source, + })?; + + if !canonical.is_dir() { + return Err(WorkingDirError::NotADirectory { path: canonical }); + } + + // When the sandbox is off there is no allowlist to consult and no boundary + // to bypass, so any readable directory is fair game. + if sandbox.mode_enabled() && !sandbox.is_path_allowed(&canonical) { + return Err(WorkingDirError::NotAllowed { path: canonical }); + } + + Ok(canonical) +} + pub struct Worker { pub id: WorkerId, pub channel_id: Option, @@ -259,6 +304,16 @@ pub struct Worker { pub wiki_write: bool, /// Model override from conversation settings (per-process or blanket). pub model_override: Option, + /// Root directory this worker's shell and file tools operate in. + /// + /// `None` means the agent workspace. A task bound to a repo sets this to + /// that repo's checkout so relative paths and bare shell commands land + /// there — telling the model to `cd` in the prompt is a suggestion, and a + /// worker that ignores it would otherwise write to the wrong tree. + /// + /// Set only through [`Worker::with_working_dir`], which refuses any path + /// the sandbox does not already allow. + pub working_dir: Option, /// Wall-clock budget for the entire `run()` invocation. Distinct from /// the supervisor's `CortexConfig.worker_timeout_secs` (which is an /// idle-kill bound measured from `last_activity_at`). Resolution chain @@ -336,6 +391,7 @@ impl Worker { worker_memory_mode, wiki_write, model_override, + working_dir: None, worker_wall_clock_timeout_secs, segments_run: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), blocked_signal: new_block_signal(), @@ -381,6 +437,20 @@ impl Worker { ) } + /// Point this worker's shell and file tools at `dir` instead of the + /// agent workspace. + /// + /// Returns an error unless `dir` is a real directory the sandbox already + /// permits. That check is the whole point: the tools treat their root as + /// trusted and skip the allowlist for paths under it, so accepting an + /// arbitrary root here would turn any caller into a sandbox bypass. The + /// caller decides what to do with a rejection — silently falling back to + /// the workspace would run the work in the wrong tree. + pub fn with_working_dir(mut self, dir: &Path) -> std::result::Result { + self.working_dir = Some(resolve_worker_working_dir(&self.deps.sandbox, dir)?); + Ok(self) + } + /// Create a new interactive worker. /// /// Returns `(worker, input_tx, inject_tx)`. The `input_tx` drives the @@ -573,7 +643,9 @@ impl Worker { self.browser_config.clone(), self.screenshot_dir.clone(), self.brave_search_key.clone(), - self.deps.runtime_config.workspace_dir.clone(), + self.working_dir + .clone() + .unwrap_or_else(|| self.deps.runtime_config.workspace_dir.clone()), self.deps.sandbox.clone(), mcp_tools, self.deps.runtime_config.clone(), @@ -1532,3 +1604,121 @@ fn build_worker_recap(messages: &[rig::message::Message]) -> String { recap } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sandbox::{Sandbox, SandboxConfig, SandboxMode}; + use arc_swap::ArcSwap; + use std::sync::Arc; + + async fn sandbox_with( + workspace: &Path, + mode: SandboxMode, + project_paths: Vec, + ) -> Sandbox { + let config = SandboxConfig { + mode, + project_paths, + ..Default::default() + }; + Sandbox::new( + Arc::new(ArcSwap::from_pointee(config)), + workspace.to_path_buf(), + workspace, + workspace.join("data"), + Arc::from("agent-test"), + ) + .await + } + + /// The tools treat their root as trusted — the default `working_dir` path + /// in the shell tool never consults the allowlist. So a root outside the + /// allowlist is a sandbox bypass, and must be refused here. + #[tokio::test] + async fn working_dir_outside_the_allowlist_is_refused() { + let workspace = tempfile::tempdir().expect("workspace"); + let elsewhere = tempfile::tempdir().expect("elsewhere"); + let sandbox = sandbox_with(workspace.path(), SandboxMode::Enabled, Vec::new()).await; + + let error = resolve_worker_working_dir(&sandbox, elsewhere.path()) + .expect_err("a directory outside the workspace must be refused"); + assert!( + matches!(error, WorkingDirError::NotAllowed { .. }), + "expected NotAllowed, got {error:?}" + ); + } + + #[tokio::test] + async fn working_dir_inside_a_registered_project_is_allowed() { + let workspace = tempfile::tempdir().expect("workspace"); + let project = tempfile::tempdir().expect("project"); + let repo = project.path().join("services/api-gateway"); + std::fs::create_dir_all(&repo).expect("create repo dir"); + + // A registered project root puts every repo beneath it in the allowlist, + // which is exactly how a task binding reaches its checkout. + let sandbox = sandbox_with( + workspace.path(), + SandboxMode::Enabled, + vec![project.path().to_path_buf()], + ) + .await; + + let resolved = resolve_worker_working_dir(&sandbox, &repo) + .expect("a repo under a registered project must be allowed"); + assert_eq!(resolved, repo.canonicalize().expect("canonicalize")); + } + + #[tokio::test] + async fn working_dir_under_the_workspace_is_allowed() { + let workspace = tempfile::tempdir().expect("workspace"); + let nested = workspace.path().join("checkout"); + std::fs::create_dir_all(&nested).expect("create nested dir"); + let sandbox = sandbox_with(workspace.path(), SandboxMode::Enabled, Vec::new()).await; + + let resolved = + resolve_worker_working_dir(&sandbox, &nested).expect("workspace children are allowed"); + assert_eq!(resolved, nested.canonicalize().expect("canonicalize")); + } + + /// With the sandbox off there is no boundary to bypass, so the check must + /// not invent one and break unsandboxed deployments. + #[tokio::test] + async fn working_dir_is_unrestricted_when_the_sandbox_is_disabled() { + let workspace = tempfile::tempdir().expect("workspace"); + let elsewhere = tempfile::tempdir().expect("elsewhere"); + let sandbox = sandbox_with(workspace.path(), SandboxMode::Disabled, Vec::new()).await; + + resolve_worker_working_dir(&sandbox, elsewhere.path()) + .expect("an unsandboxed agent may root a worker anywhere readable"); + } + + #[tokio::test] + async fn working_dir_must_exist() { + let workspace = tempfile::tempdir().expect("workspace"); + let sandbox = sandbox_with(workspace.path(), SandboxMode::Enabled, Vec::new()).await; + + let error = resolve_worker_working_dir(&sandbox, &workspace.path().join("nope")) + .expect_err("a missing directory must be refused"); + assert!( + matches!(error, WorkingDirError::Unreadable { .. }), + "expected Unreadable, got {error:?}" + ); + } + + #[tokio::test] + async fn working_dir_must_be_a_directory() { + let workspace = tempfile::tempdir().expect("workspace"); + let file = workspace.path().join("README.md"); + std::fs::write(&file, "not a directory").expect("write file"); + let sandbox = sandbox_with(workspace.path(), SandboxMode::Enabled, Vec::new()).await; + + let error = resolve_worker_working_dir(&sandbox, &file) + .expect_err("a file must be refused as a worker root"); + assert!( + matches!(error, WorkingDirError::NotADirectory { .. }), + "expected NotADirectory, got {error:?}" + ); + } +} diff --git a/src/tasks/store.rs b/src/tasks/store.rs index 3745f27c2..1456174c7 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -182,6 +182,17 @@ impl TaskProjectBinding { } } +impl Task { + /// The codebase this task is bound to, as a single value. + pub fn binding(&self) -> TaskProjectBinding { + TaskProjectBinding { + project_id: self.project_id.clone(), + repo_id: self.repo_id.clone(), + worktree_id: self.worktree_id.clone(), + } + } +} + /// A partial update to a task's binding. /// /// Each field is independently three-valued, which [`TaskProjectBinding`] is diff --git a/src/tools/spawn_worker.rs b/src/tools/spawn_worker.rs index f07e40c6f..5d11ed40d 100644 --- a/src/tools/spawn_worker.rs +++ b/src/tools/spawn_worker.rs @@ -278,6 +278,7 @@ impl Tool for SpawnWorkerTool { .map(String::as_str) .collect::>(), &worker_context, + resolved_directory.as_deref().map(std::path::PathBuf::from), ) .await .map_err(|e| SpawnWorkerError(format!("{e}")))? From 186fedddb4241cb84ff6dcd6e87297a8f7041ed7 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:38:08 +0000 Subject: [PATCH 10/69] feat(cortex): reap task pickups whose worker died MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task moves to `in_progress` at claim time and back out when its worker reports an outcome. If the process died in between — host restart, OOM, `kill -9` — nothing reported, so the task sat in `in_progress` forever with an open `task_runs` row. `claim_next_ready` only looks at `ready`, so it was never retried: the work silently stopped existing, and the attempt log went on claiming it was still running. The reaper runs at the top of each ready-task tick, before claiming, since an abandoned task is invisible to the claim query until something returns it to `ready`. Liveness comes from the detached-worker registry rather than a heartbeat column. The registry is in-memory, so after a restart it is empty and every task that was running is correctly seen as orphaned — the first tick after a restart recovers them through exactly the same code as the steady state, with no separate recovery path to drift out of sync. Two things keep it from eating live work. A five-minute age floor covers the gap between claiming a task and registering its worker, which are separate writes. And scoping by `assigned_agent_id` keeps one agent out of another's running tasks, since the task table is instance-wide while each agent's registry is local. Reaped tasks go through `record_failure`, so the existing budget applies: a task that keeps getting abandoned is requeued, then parked in `blocked` for a human instead of being traded between the reaper and the pickup loop forever. Adds `TaskRunOutcome::Abandoned` rather than reusing `failed`, because nothing observed this run — it did not fail, it stopped. It counts against the budget and renders in the attempt history with its own treatment. Decision logic is split from logging and event emission so it can be tested against a bare store and registry: vanished worker requeued and its run row closed, live worker untouched, fresh claim protected by the grace period, other agents' tasks ignored, and repeated abandonment parking the task. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/client.ts | 3 +- interface/src/api/schema.d.ts | 2 +- .../src/components/tasks/TaskRunHistory.tsx | 8 + interface/src/routes/UiLab.tsx | 15 +- src/agent/cortex.rs | 433 +++++++++++++++++- src/tasks/store.rs | 57 ++- 6 files changed, 512 insertions(+), 6 deletions(-) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index e31b0ebc3..c5b541c47 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1003,7 +1003,8 @@ export type TaskRunOutcome = | "timeout" | "cancelled" | "blocked" - | "rate_limited"; + | "rate_limited" + | "abandoned"; /// A single execution attempt against a task. export interface TaskRun { diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index cbd0e5364..60bc9086c 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -4155,7 +4155,7 @@ export interface components { * @description Outcome of a single task execution attempt. * @enum {string} */ - TaskRunOutcome: "completed" | "failed" | "timeout" | "cancelled" | "blocked" | "rate_limited"; + TaskRunOutcome: "completed" | "failed" | "timeout" | "cancelled" | "blocked" | "rate_limited" | "abandoned"; TaskRunsResponse: { runs: components["schemas"]["TaskRun"][]; }; diff --git a/interface/src/components/tasks/TaskRunHistory.tsx b/interface/src/components/tasks/TaskRunHistory.tsx index 531f070cd..55ea3ebe7 100644 --- a/interface/src/components/tasks/TaskRunHistory.tsx +++ b/interface/src/components/tasks/TaskRunHistory.tsx @@ -5,6 +5,7 @@ import { faBan, faCheck, faClock, + faPlugCircleXmark, faSpinner, faTriangleExclamation, faXmark, @@ -24,6 +25,13 @@ const OUTCOME_STYLE: Record< // Rate limits are recorded but deliberately don't spend the failure budget, // so they read as neutral rather than as a failure. rate_limited: { icon: faClock, className: "text-status-warning", label: "Rate limited" }, + // The worker never reported back — the reaper wrote this row, not the run. + // Distinct from "failed" because nothing observed the work; it just stopped. + abandoned: { + icon: faPlugCircleXmark, + className: "text-status-error", + label: "Abandoned", + }, }; /** Badge treatment per outcome. A still-running attempt has no outcome yet. */ diff --git a/interface/src/routes/UiLab.tsx b/interface/src/routes/UiLab.tsx index 8cee3beac..c98dc55cf 100644 --- a/interface/src/routes/UiLab.tsx +++ b/interface/src/routes/UiLab.tsx @@ -108,9 +108,20 @@ const RUNS: TaskRun[] = [ ended_at: "2026-08-02T09:55:00Z", }, { - id: "r4", + id: "r3b", task_number: 142, attempt: 4, + worker_id: "deadbeef-0000-1111-2222-333344445555", + outcome: "abandoned", + error: + "worker deadbeef-0000-1111-2222-333344445555 is gone without reporting an outcome — the process most likely died or the agent restarted mid-run", + started_at: "2026-08-02T09:58:00Z", + ended_at: "2026-08-02T10:01:00Z", + }, + { + id: "r4", + task_number: 142, + attempt: 5, worker_id: "55555555-6666-7777-8888-999999999999", outcome: "completed", summary: @@ -121,7 +132,7 @@ const RUNS: TaskRun[] = [ { id: "r5", task_number: 142, - attempt: 5, + attempt: 6, worker_id: "77777777-8888-9999-aaaa-bbbbbbbbbbbb", started_at: "2026-08-02T10:30:00Z", }, diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index a06a5efb1..6c8e6937c 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -4026,6 +4026,212 @@ async fn handle_detached_completion( }); } +/// How long a task must sit untouched in `in_progress` before the reaper will +/// consider it abandoned. +/// +/// A claim and its worker registration are separate writes, so a task picked up +/// microseconds ago legitimately has no live worker yet. This floor is what +/// stops the reaper from killing healthy work it merely caught mid-handshake. +const ORPHANED_TASK_GRACE_SECS: i64 = 300; + +/// Return tasks whose worker died to the failure budget. +/// +/// A task moves to `in_progress` at claim time and back out when its worker +/// reports. If the process dies in between — host restart, OOM, `kill -9` — +/// nothing reports, so the task stays `in_progress` forever with an open +/// `task_runs` row, and `claim_next_ready` only looks at `ready`, so it is +/// never retried. The work silently stops existing. +/// +/// Liveness is decided by the detached-worker registry rather than a heartbeat +/// column: the registry is in-memory, so after a restart it is empty and every +/// previously-running task is correctly seen as orphaned. That makes the +/// restart path the same code as the steady-state path, with no separate +/// recovery routine to drift out of sync. +async fn reap_orphaned_task_pickups( + deps: &AgentDeps, + logger: &CortexLogger, +) -> anyhow::Result { + let reaped = reap_orphaned_task_pickups_in( + deps.task_store.as_ref(), + &deps.process_control_registry, + &deps.agent_id, + ORPHANED_TASK_GRACE_SECS, + ) + .await?; + + for entry in &reaped { + let (event, message) = if entry.new_status == TaskStatus::Blocked { + ( + "task_pickup_reaped_budget_exhausted", + format!( + "Task #{} was abandoned once too often ({}/{}) and was blocked: {}", + entry.task_number, entry.failures, entry.limit, entry.reason + ), + ) + } else { + ( + "task_pickup_reaped", + format!( + "Task #{} was abandoned (attempt {}/{}) and requeued: {}", + entry.task_number, entry.failures, entry.limit, entry.reason + ), + ) + }; + + logger.log( + event, + &message, + Some(serde_json::json!({ + "task_number": entry.task_number, + "worker_id": entry.worker_id, + "failures": entry.failures, + "limit": entry.limit, + })), + ); + + let _ = deps.event_tx.send(ProcessEvent::TaskUpdated { + agent_id: deps.agent_id.clone(), + task_number: entry.task_number, + status: entry.new_status.as_str().to_string(), + action: "updated".to_string(), + }); + } + + Ok(reaped.len()) +} + +/// A task the reaper returned to the scheduler. +#[derive(Debug, Clone)] +struct ReapedTask { + task_number: i64, + worker_id: Option, + new_status: TaskStatus, + failures: i64, + limit: i64, + reason: String, +} + +/// The reaper's decision logic, separated from logging and event emission so +/// it can be exercised against a bare task store and registry. +async fn reap_orphaned_task_pickups_in( + task_store: &TaskStore, + registry: &ProcessControlRegistry, + agent_id: &str, + grace_secs: i64, +) -> anyhow::Result> { + let stale = task_store + .list_stale_in_progress(agent_id, grace_secs) + .await?; + + if stale.is_empty() { + return Ok(Vec::new()); + } + + let live: std::collections::HashSet = registry + .detached_worker_snapshots() + .await + .into_iter() + .map(|snapshot| snapshot.worker_id) + .collect(); + + let mut reaped = Vec::new(); + + for task in stale { + // A task whose worker is still registered is simply slow, not dead. + let worker_alive = task + .worker_id + .as_deref() + .and_then(|id| id.parse::().ok()) + .is_some_and(|id| live.contains(&id)); + if worker_alive { + continue; + } + + let reason = match &task.worker_id { + Some(worker_id) => format!( + "worker {worker_id} is gone without reporting an outcome — \ + the process most likely died or the agent restarted mid-run" + ), + None => "task was claimed but no worker was ever registered for it".to_string(), + }; + + // Close the attempt row first so the log stops claiming this run is + // still in flight, even if the status write below fails. + match task_store.open_run(task.task_number).await { + Ok(Some(run)) => { + if let Err(error) = task_store + .finish_run( + &run.id, + crate::tasks::TaskRunOutcome::Abandoned, + None, + Some(&reason), + ) + .await + { + tracing::warn!(%error, task_number = task.task_number, "failed to close abandoned run row"); + } + } + Ok(None) => {} + Err(error) => { + tracing::warn!(%error, task_number = task.task_number, "failed to look up open run for abandoned task"); + } + } + + let outcome = task_store + .record_failure( + task.task_number, + crate::tasks::TaskRunOutcome::Abandoned, + &reason, + ) + .await; + + let (new_status, failures, limit) = match outcome { + Ok(crate::tasks::FailureDisposition::Requeued { failures, limit }) => { + (TaskStatus::Ready, failures, limit) + } + Ok(crate::tasks::FailureDisposition::Parked { failures, limit }) => { + (TaskStatus::Blocked, failures, limit) + } + Ok(other) => { + // Somebody moved the task between the listing and now. Their + // decision stands. + tracing::debug!( + task_number = task.task_number, + ?other, + "abandoned task changed underneath the reaper" + ); + continue; + } + Err(error) => { + tracing::warn!(%error, task_number = task.task_number, "failed to reap abandoned task"); + continue; + } + }; + + // Drop any registry entry left behind by a worker that never + // unregistered itself, so a stale row can't shield the next attempt + // from being reaped in turn. + if let Some(worker_id) = task + .worker_id + .as_deref() + .and_then(|id| id.parse::().ok()) + { + registry.unregister_detached_worker(worker_id).await; + } + + reaped.push(ReapedTask { + task_number: task.task_number, + worker_id: task.worker_id.clone(), + new_status, + failures, + limit, + reason, + }); + } + + Ok(reaped) +} + /// One-shot wake for dormant agents. /// /// Triggered by `agent::wake::WakeManager` when an external event delivers @@ -4059,6 +4265,17 @@ async fn run_ready_task_loop(deps: &AgentDeps, logger: &CortexLogger) -> anyhow: let interval = deps.runtime_config.cortex.load().tick_interval_secs; tokio::time::sleep(Duration::from_secs(interval.max(5))).await; + // Reap before claiming. Tasks whose worker died are invisible to + // `claim_next_ready` until they are returned to `ready`, so a pickup + // pass that skipped this would never see them again. On the first tick + // after a restart this is what recovers everything that was running + // when the process went down. + match reap_orphaned_task_pickups(deps, logger).await { + Ok(0) => {} + Ok(count) => tracing::info!(count, "reaped abandoned task pickups"), + Err(error) => tracing::warn!(%error, "task reaper pass failed"), + } + if let Err(error) = pickup_one_ready_task(deps, logger).await { tracing::warn!(%error, "ready-task pickup pass failed"); } @@ -4940,7 +5157,8 @@ mod tests { maintenance_task_timeout, maintenance_timeout_action, mark_knowledge_synthesis_version_complete, maybe_close_bulletin_refresh_circuit, maybe_generate_bulletin_under_lock, maybe_spawn_synthesis_task, - parse_structured_success_flag, push_signal_into_buffer, record_bulletin_refresh_failure, + parse_structured_success_flag, push_signal_into_buffer, reap_orphaned_task_pickups_in, + record_bulletin_refresh_failure, register_detached_worker_for_pickup, should_execute_warmup, should_generate_bulletin_from_bulletin_loop, signal_from_event, summarize_signal_text, take_lagged_control_flag, }; @@ -6170,4 +6388,217 @@ mod tests { MAX_DROPPED_EVENTS_BUDGET ); } + // -- Dead-job reaper ---------------------------------------------------- + + async fn reaper_fixture() -> ( + TaskStore, + crate::agent::process_control::ProcessControlRegistry, + ) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("failed to create sqlite memory pool"); + crate::tasks::store::create_task_schema(&pool).await; + sqlx::query("INSERT INTO task_number_seq (id, next_number) VALUES (1, 1)") + .execute(&pool) + .await + .expect("seed task number sequence"); + + ( + TaskStore::new(pool), + crate::agent::process_control::ProcessControlRegistry::new(), + ) + } + + async fn running_task(store: &TaskStore, agent_id: &str, title: &str) -> crate::tasks::Task { + store + .create(crate::tasks::CreateTaskInput { + owner_agent_id: agent_id.to_string(), + assigned_agent_id: agent_id.to_string(), + title: title.to_string(), + status: TaskStatus::InProgress, + created_by: "test".to_string(), + ..Default::default() + }) + .await + .expect("create running task") + } + + /// The restart case: the registry is empty after a process restart, so + /// everything that was running when the process died is orphaned. + #[tokio::test] + async fn reaper_requeues_a_task_whose_worker_vanished() { + let (store, registry) = reaper_fixture().await; + let task = running_task(&store, "agent-1", "orphan").await; + let worker_id = uuid::Uuid::new_v4(); + store + .update( + task.task_number, + crate::tasks::UpdateTaskInput { + worker_id: Some(worker_id.to_string()), + ..Default::default() + }, + ) + .await + .expect("bind worker") + .expect("task exists"); + let run = store + .start_run(task.task_number, Some(&worker_id.to_string())) + .await + .expect("open attempt row"); + + let reaped = reap_orphaned_task_pickups_in(&store, ®istry, "agent-1", 0) + .await + .expect("reap"); + + assert_eq!(reaped.len(), 1); + assert_eq!(reaped[0].task_number, task.task_number); + assert_eq!(reaped[0].new_status, TaskStatus::Ready); + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!( + after.status, + TaskStatus::Ready, + "an abandoned task must become claimable again" + ); + assert_eq!(after.consecutive_failures, 1); + assert!(after.worker_id.is_none(), "the dead worker must be unbound"); + + let runs = store.list_runs(task.task_number).await.expect("list runs"); + let closed = runs + .iter() + .find(|candidate| candidate.id == run.id) + .expect("the attempt row survives"); + assert_eq!( + closed.outcome, + Some(crate::tasks::TaskRunOutcome::Abandoned) + ); + assert!( + closed.ended_at.is_some(), + "the attempt log must stop claiming the run is still in flight" + ); + } + + /// A registered worker is slow, not dead. Reaping it would kill live work. + #[tokio::test] + async fn reaper_leaves_a_task_whose_worker_is_still_registered() { + let (store, registry) = reaper_fixture().await; + let task = running_task(&store, "agent-1", "healthy").await; + let worker_id = uuid::Uuid::new_v4(); + let agent_id: crate::AgentId = Arc::from("agent-1"); + + register_detached_worker_for_pickup( + ®istry, + &store, + &agent_id, + task.task_number, + worker_id, + ) + .await + .expect("register worker"); + + let reaped = reap_orphaned_task_pickups_in(&store, ®istry, "agent-1", 0) + .await + .expect("reap"); + + assert!(reaped.is_empty(), "a live worker must not be reaped"); + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::InProgress); + assert_eq!(after.consecutive_failures, 0); + } + + /// The grace period covers the window between claiming a task and + /// registering its worker — two separate writes. + #[tokio::test] + async fn reaper_respects_the_grace_period_for_freshly_claimed_tasks() { + let (store, registry) = reaper_fixture().await; + let task = running_task(&store, "agent-1", "just claimed").await; + + let reaped = reap_orphaned_task_pickups_in(&store, ®istry, "agent-1", 300) + .await + .expect("reap"); + + assert!( + reaped.is_empty(), + "a task claimed moments ago must survive the reaper" + ); + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::InProgress); + } + + /// Tasks live in one instance-wide table, so the reaper must not touch + /// work belonging to another agent whose registry it cannot see. + #[tokio::test] + async fn reaper_ignores_other_agents_tasks() { + let (store, registry) = reaper_fixture().await; + let theirs = running_task(&store, "agent-2", "not mine").await; + + let reaped = reap_orphaned_task_pickups_in(&store, ®istry, "agent-1", 0) + .await + .expect("reap"); + + assert!(reaped.is_empty()); + let after = store + .get_by_number(theirs.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::InProgress); + } + + /// A task that keeps getting abandoned must eventually stop being retried + /// — otherwise the reaper and the pickup loop trade it forever. + #[tokio::test] + async fn repeatedly_abandoned_tasks_exhaust_the_budget_and_park() { + let (store, registry) = reaper_fixture().await; + let task = running_task(&store, "agent-1", "cursed").await; + + let first = reap_orphaned_task_pickups_in(&store, ®istry, "agent-1", 0) + .await + .expect("first reap"); + assert_eq!(first[0].new_status, TaskStatus::Ready); + + // Claimed again, dies again. + store + .claim_next_ready("agent-1") + .await + .expect("claim") + .expect("requeued task is claimable"); + + let second = reap_orphaned_task_pickups_in(&store, ®istry, "agent-1", 0) + .await + .expect("second reap"); + assert_eq!(second[0].new_status, TaskStatus::Blocked); + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::Blocked); + assert!(after.last_error.is_some()); + + // And a parked task is not picked up again. + assert!( + store + .claim_next_ready("agent-1") + .await + .expect("claim") + .is_none(), + "a parked task must stay out of the pickup loop" + ); + } } diff --git a/src/tasks/store.rs b/src/tasks/store.rs index 1456174c7..3e163bd68 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -251,6 +251,10 @@ pub enum TaskRunOutcome { /// Provider rate limit. Deliberately does **not** count against the /// failure budget — a quota outage is not the task's fault. RateLimited, + /// The worker vanished without reporting anything — process died, host + /// restarted, cortex was killed mid-run. Recorded by the reaper, never by + /// the worker itself, since by definition nothing was left to report it. + Abandoned, } impl TaskRunOutcome { @@ -262,6 +266,7 @@ impl TaskRunOutcome { TaskRunOutcome::Cancelled => "cancelled", TaskRunOutcome::Blocked => "blocked", TaskRunOutcome::RateLimited => "rate_limited", + TaskRunOutcome::Abandoned => "abandoned", } } @@ -273,6 +278,7 @@ impl TaskRunOutcome { "cancelled" => Some(TaskRunOutcome::Cancelled), "blocked" => Some(TaskRunOutcome::Blocked), "rate_limited" => Some(TaskRunOutcome::RateLimited), + "abandoned" => Some(TaskRunOutcome::Abandoned), _ => None, } } @@ -284,7 +290,10 @@ impl TaskRunOutcome { pub fn counts_as_failure(self) -> bool { matches!( self, - TaskRunOutcome::Failed | TaskRunOutcome::Timeout | TaskRunOutcome::Blocked + TaskRunOutcome::Failed + | TaskRunOutcome::Timeout + | TaskRunOutcome::Blocked + | TaskRunOutcome::Abandoned ) } } @@ -566,6 +575,33 @@ impl TaskStore { .await } + /// Tasks this agent left running that have not been touched for + /// `min_age_secs`. + /// + /// The age floor is what keeps the reaper from eating a task that was + /// claimed moments ago and whose worker has not finished registering yet. + /// Scoped to one agent because the task table is instance-wide: another + /// agent's running task is not this one's to reap. + pub async fn list_stale_in_progress( + &self, + assigned_agent_id: &str, + min_age_secs: i64, + ) -> Result> { + let rows = sqlx::query(&format!( + "{SELECT_COLUMNS} FROM tasks \ + WHERE status = 'in_progress' AND assigned_agent_id = ? \ + AND updated_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?) \ + ORDER BY task_number ASC" + )) + .bind(assigned_agent_id) + .bind(format!("-{} seconds", min_age_secs.max(0))) + .fetch_all(&self.pool) + .await + .context("failed to list stale in-progress tasks")?; + + rows.into_iter().map(task_from_row).collect() + } + /// Fetch a single task by its globally unique number. pub async fn get_by_number(&self, task_number: i64) -> Result> { let row = sqlx::query(&format!( @@ -975,6 +1011,25 @@ impl TaskStore { Ok(()) } + /// The still-open attempt for a task, if one exists. + /// + /// An attempt with no `ended_at` means the process died before it could + /// close the row — the reaper uses this to write a terminal outcome rather + /// than leaving the log claiming the work is still running. + pub async fn open_run(&self, task_number: i64) -> Result> { + let row = sqlx::query(&format!( + "{RUN_SELECT_COLUMNS} FROM task_runs \ + WHERE task_number = ? AND ended_at IS NULL \ + ORDER BY attempt DESC LIMIT 1" + )) + .bind(task_number) + .fetch_optional(&self.pool) + .await + .context("failed to look up open task run")?; + + row.map(task_run_from_row).transpose() + } + /// All attempts for a task, oldest first. pub async fn list_runs(&self, task_number: i64) -> Result> { let rows = sqlx::query(&format!( From eb16a3c88e451e2a6a2a92f0a2ee05c80c3aa934 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:10:46 +0000 Subject: [PATCH 11/69] fix(interface): consume the generated API types instead of hand-written copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `client.ts` declared its own copies of 114 response and request shapes that the OpenAPI schema already defined — 104 under the same name, 10 renamed (`BindingInfo` for `BindingResponse`, `MemoryItem` for `Memory`, and so on). `check-typegen` could not see this. It proves `schema.d.ts` matches the Rust and says nothing about whether the app uses it, so the generated file stayed in sync while the app compiled against something else entirely. There was no type error to raise: two unrelated types that happen to share a name are not a conflict. What the duplicates were hiding, found by deleting them: - The config API never exposed `*_thinking_effort`. The values exist in `RoutingConfig`, are honoured by `thinking_effort_for_model`, and are settable in TOML — but `RoutingSection`/`RoutingUpdate` did not carry them, so the dashboard's Thinking Effort dropdown read nothing and wrote nowhere. The hand-written type declared the fields, so it compiled. - `POST /tasks` hardcoded `pending_approval` and had no `status` field. The dashboard has always sent `backlog`; serde dropped it silently, so every task created from the UI came back awaiting an approval the creator had just granted by clicking "create". `status` is now accepted and still defaults to `pending_approval` for callers that omit it. - `BrowserSection.close_policy` was `String` while `BrowserUpdate` used the `ClosePolicy` enum — the response could not tell a client which values were legal. Now typed on both sides. - 13 response types were missing server fields entirely: `warmup`, `passthrough_env`, `ssh_enabled`, `team_id`, seven cortex maintenance settings, and the task binding columns. The UI could not reach them. - Nullable fields were typed `string | undefined` where the server sends `string | null`, and free-form `serde_json::Value` fields were typed as objects when nothing guarantees they are. Task metadata and cortex event details are `unknown` now, which is what the server actually promises; `lib/json.ts` narrows them at the point of use. `CortexEvent.event_type` stays an open string deliberately — the cortex logs new event names freely and the UI's union is a filter vocabulary, not an exhaustive list. `scripts/check-api-types.sh` fails the build if a type in `client.ts` shares a name with a schema type without aliasing it, and runs in both `gate-pr` and `check-typegen`. Types with no server counterpart — SSE events, view models, component props — are untouched and stay hand-written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/client.ts | 1049 +++-------------- interface/src/api/schema.d.ts | 20 +- interface/src/api/types.ts | 32 + interface/src/components/TaskUtils.tsx | 11 +- .../src/components/tasks/TaskRunHistory.tsx | 11 +- interface/src/lib/format.ts | 7 +- interface/src/lib/json.ts | 25 + interface/src/routes/AgentCortex.tsx | 11 +- interface/src/routes/AgentCron.tsx | 6 +- interface/src/routes/AgentTasks.tsx | 2 +- interface/src/routes/GlobalTasks.tsx | 2 +- justfile | 7 + scripts/check-api-types.sh | 87 ++ scripts/gate-pr.sh | 2 + src/api/config.rs | 40 +- src/api/tasks.rs | 10 +- 16 files changed, 396 insertions(+), 926 deletions(-) create mode 100644 interface/src/lib/json.ts create mode 100755 scripts/check-api-types.sh diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index c5b541c47..9a7c9b523 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -289,24 +289,9 @@ export type ApiEvent = // -- Timeline types (discriminated union parts) -- -export interface AttachmentMeta { - id: string; - filename: string; - saved_filename: string; - mime_type: string; - size_bytes: number; -} +export type AttachmentMeta = Types.SavedAttachmentMeta; -export interface TimelineMessage { - type: "message"; - id: string; - role: "user" | "assistant"; - sender_name: string | null; - sender_id: string | null; - content: string; - created_at: string; - attachments?: AttachmentMeta[]; -} +export type TimelineMessage = Types.TimelineItem; export interface TimelineBranchRun { type: "branch_run"; @@ -445,15 +430,7 @@ export interface PromptCaptureResponse { // --- Memory helper types (extended beyond schema) --- // Extended MemoryType with additional values not yet in schema -export type MemoryType = - | "fact" - | "preference" - | "decision" - | "identity" - | "event" - | "observation" - | "goal" - | "todo"; +export type MemoryType = Types.MemoryType; export const MEMORY_TYPES: MemoryType[] = [ "fact", "preference", "decision", "identity", @@ -463,34 +440,13 @@ export const MEMORY_TYPES: MemoryType[] = [ export type MemorySort = "recent" | "importance" | "most_accessed"; // Extended MemoryItem with forgotten field (not yet in schema) -export interface MemoryItem { - id: string; - content: string; - memory_type: MemoryType; - importance: number; - created_at: string; - updated_at: string; - last_accessed_at: string; - access_count: number; - source: string | null; - channel_id: string | null; - forgotten: boolean; -} +export type MemoryItem = Types.Memory; -export interface MemoriesListResponse { - memories: MemoryItem[]; - total: number; -} +export type MemoriesListResponse = Types.MemoriesListResponse; -export interface MemorySearchResultItem { - memory: MemoryItem; - score: number; - rank: number; -} +export type MemorySearchResultItem = Types.MemorySearchResult; -export interface MemoriesSearchResponse { - results: MemorySearchResultItem[]; -} +export type MemoriesSearchResponse = Types.MemoriesSearchResponse; export interface MemoryGraphParams { limit?: number; @@ -541,18 +497,9 @@ export const CORTEX_EVENT_TYPES: CortexEventType[] = [ "observation_created", "health_check", ]; -export interface CortexEvent { - id: string; - event_type: CortexEventType; - summary: string; - details: Record | null; - created_at: string; -} +export type CortexEvent = Types.CortexEvent; -export interface CortexEventsResponse { - events: CortexEvent[]; - total: number; -} +export type CortexEventsResponse = Types.CortexEventsResponse; export interface CortexEventsParams { limit?: number; @@ -571,19 +518,9 @@ export type CortexChatSSEEvent = // -- Factory Presets -- -export interface PresetDefaults { - max_concurrent_workers: number | null; - max_turns: number | null; -} +export type PresetDefaults = Types.PresetDefaults; -export interface PresetMeta { - id: string; - name: string; - description: string; - icon: string; - tags: string[]; - defaults: PresetDefaults; -} +export type PresetMeta = Types.PresetMeta; export interface PresetsResponse { presets: PresetMeta[]; @@ -591,262 +528,68 @@ export interface PresetsResponse { // -- Config types with frontend-specific extensions -- -export interface RoutingSection { - channel: string; - branch: string; - worker: string; - compactor: string; - cortex: string; - voice: string; - rate_limit_cooldown_secs: number; - channel_thinking_effort: string; - branch_thinking_effort: string; - worker_thinking_effort: string; - compactor_thinking_effort: string; - cortex_thinking_effort: string; -} +export type RoutingSection = Types.RoutingSection; -export interface TuningSection { - max_concurrent_branches: number; - max_concurrent_workers: number; - max_turns: number; - branch_max_turns: number; - context_window: number; - history_backfill_count: number; -} +export type TuningSection = Types.TuningSection; -export interface CompactionSection { - background_threshold: number; - aggressive_threshold: number; - emergency_threshold: number; -} +export type CompactionSection = Types.CompactionSection; -export interface CortexSection { - tick_interval_secs: number; - worker_timeout_secs: number; - branch_timeout_secs: number; - circuit_breaker_threshold: number; - bulletin_interval_secs: number; - bulletin_max_words: number; - bulletin_max_turns: number; -} +export type CortexSection = Types.CortexSection; -export interface CoalesceSection { - enabled: boolean; - debounce_ms: number; - max_wait_ms: number; - min_messages: number; - multi_user_only: boolean; -} +export type CoalesceSection = Types.CoalesceSection; -export interface MemoryPersistenceSection { - enabled: boolean; - message_interval: number; -} +export type MemoryPersistenceSection = Types.MemoryPersistenceSection; -export interface BrowserSection { - enabled: boolean; - headless: boolean; - evaluate_enabled: boolean; - persist_session: boolean; - close_policy: "close_browser" | "close_tabs" | "detach"; -} +export type BrowserSection = Types.BrowserSection; -export interface ChannelSection { - listen_only_mode: boolean; -} +export type ChannelSection = Types.ChannelSection; -export interface SandboxSection { - mode: "enabled" | "disabled"; - writable_paths: string[]; -} +export type SandboxSection = Types.SandboxSection; -export interface ProjectsSection { - use_worktrees: boolean; - worktree_name_template: string; - auto_create_worktrees: boolean; - auto_discover_repos: boolean; - auto_discover_worktrees: boolean; - disk_usage_warning_threshold: number; -} +export type ProjectsSection = Types.ProjectsSection; -export interface DiscordSection { - enabled: boolean; - allow_bot_messages: boolean; -} +export type DiscordSection = Types.DiscordSection; -export interface AgentConfigResponse { - routing: RoutingSection; - tuning: TuningSection; - compaction: CompactionSection; - cortex: CortexSection; - coalesce: CoalesceSection; - memory_persistence: MemoryPersistenceSection; - browser: BrowserSection; - channel: ChannelSection; - discord: DiscordSection; - sandbox: SandboxSection; - projects: ProjectsSection; -} +export type AgentConfigResponse = Types.AgentConfigResponse; // Partial update types - all fields are optional -export interface RoutingUpdate { - channel?: string; - branch?: string; - worker?: string; - compactor?: string; - cortex?: string; - voice?: string; - rate_limit_cooldown_secs?: number; - channel_thinking_effort?: string; - branch_thinking_effort?: string; - worker_thinking_effort?: string; - compactor_thinking_effort?: string; - cortex_thinking_effort?: string; -} +export type RoutingUpdate = Types.RoutingUpdate; -export interface TuningUpdate { - max_concurrent_branches?: number; - max_concurrent_workers?: number; - max_turns?: number; - branch_max_turns?: number; - context_window?: number; - history_backfill_count?: number; -} +export type TuningUpdate = Types.TuningUpdate; -export interface CompactionUpdate { - background_threshold?: number; - aggressive_threshold?: number; - emergency_threshold?: number; -} +export type CompactionUpdate = Types.CompactionUpdate; -export interface CortexUpdate { - tick_interval_secs?: number; - worker_timeout_secs?: number; - branch_timeout_secs?: number; - circuit_breaker_threshold?: number; - bulletin_interval_secs?: number; - bulletin_max_words?: number; - bulletin_max_turns?: number; -} +export type CortexUpdate = Types.CortexUpdate; -export interface CoalesceUpdate { - enabled?: boolean; - debounce_ms?: number; - max_wait_ms?: number; - min_messages?: number; - multi_user_only?: boolean; -} +export type CoalesceUpdate = Types.CoalesceUpdate; -export interface MemoryPersistenceUpdate { - enabled?: boolean; - message_interval?: number; -} +export type MemoryPersistenceUpdate = Types.MemoryPersistenceUpdate; -export interface BrowserUpdate { - enabled?: boolean; - headless?: boolean; - evaluate_enabled?: boolean; - persist_session?: boolean; - close_policy?: "close_browser" | "close_tabs" | "detach"; -} +export type BrowserUpdate = Types.BrowserUpdate; -export interface ChannelUpdate { - listen_only_mode?: boolean; -} +export type ChannelUpdate = Types.ChannelUpdate; -export interface SandboxUpdate { - mode?: "enabled" | "disabled"; - writable_paths?: string[]; -} +export type SandboxUpdate = Types.SandboxUpdate; -export interface ProjectsUpdate { - use_worktrees?: boolean; - worktree_name_template?: string; - auto_create_worktrees?: boolean; - auto_discover_repos?: boolean; - auto_discover_worktrees?: boolean; - disk_usage_warning_threshold?: number; -} +export type ProjectsUpdate = Types.ProjectsUpdate; -export interface DiscordUpdate { - allow_bot_messages?: boolean; -} +export type DiscordUpdate = Types.DiscordUpdate; -export interface AgentConfigUpdateRequest { - agent_id: string; - routing?: RoutingUpdate; - tuning?: TuningUpdate; - compaction?: CompactionUpdate; - cortex?: CortexUpdate; - coalesce?: CoalesceUpdate; - memory_persistence?: MemoryPersistenceUpdate; - browser?: BrowserUpdate; - channel?: ChannelUpdate; - discord?: DiscordUpdate; - sandbox?: SandboxUpdate; - projects?: ProjectsUpdate; -} +export type AgentConfigUpdateRequest = Types.AgentConfigUpdateRequest; // -- Cron Types -- -export interface CronJobWithStats { - id: string; - prompt: string; - cron_expr: string | null; - interval_secs: number; - delivery_target: string; - enabled: boolean; - run_once: boolean; - active_hours: [number, number] | null; - timeout_secs: number | null; - execution_success_count: number; - execution_failure_count: number; - delivery_success_count: number; - delivery_failure_count: number; - delivery_skipped_count: number; - last_executed_at: string | null; -} +export type CronJobWithStats = Types.CronJobWithStats; -export interface CronExecutionEntry { - id: string; - cron_id: string | null; - executed_at: string; - success: boolean; - execution_succeeded: boolean; - delivery_attempted: boolean; - delivery_succeeded: boolean | null; - result_summary: string | null; - execution_error: string | null; - delivery_error: string | null; -} +export type CronExecutionEntry = Types.CronExecutionEntry; -export interface CronListResponse { - jobs: CronJobWithStats[]; - timezone: string; -} +export type CronListResponse = Types.CronListResponse; -export interface CronExecutionsResponse { - executions: CronExecutionEntry[]; -} +export type CronExecutionsResponse = Types.CronExecutionsResponse; -export interface CronActionResponse { - success: boolean; - message: string; -} +export type CronActionResponse = Types.CronActionResponse; -export interface CreateCronRequest { - id: string; - prompt: string; - cron_expr?: string; - interval_secs?: number; - delivery_target: string; - active_start_hour?: number; - active_end_hour?: number; - enabled: boolean; - run_once: boolean; - timeout_secs?: number; -} +export type CreateCronRequest = Types.CreateCronRequest; export interface CronExecutionsParams { cron_id?: string; @@ -855,21 +598,9 @@ export interface CronExecutionsParams { // -- Update Types -- -export type Deployment = "docker" | "hosted" | "native"; - -export interface UpdateStatus { - current_version: string; - latest_version: string | null; - update_available: boolean; - release_url: string | null; - release_notes: string | null; - deployment: Deployment; - can_apply: boolean; - cannot_apply_reason: string | null; - docker_image: string | null; - checked_at: string | null; - error: string | null; -} +export type Deployment = Types.Deployment; + +export type UpdateStatus = Types.UpdateStatus; export interface UpdateApplyResponse { status: "updating" | "error"; @@ -878,257 +609,76 @@ export interface UpdateApplyResponse { // -- Global Settings Types -- -export interface OpenCodePermissions { - edit: string; - bash: string; - webfetch: string; -} +export type OpenCodePermissions = Types.OpenCodePermissionsResponse; -export interface OpenCodeSettings { - enabled: boolean; - path: string; - max_servers: number; - server_startup_timeout_secs: number; - max_restart_retries: number; - permissions: OpenCodePermissions; -} +export type OpenCodeSettings = Types.OpenCodeSettingsResponse; -export interface OpenCodeSettingsUpdate { - enabled?: boolean; - path?: string; - max_servers?: number; - server_startup_timeout_secs?: number; - max_restart_retries?: number; - permissions?: Partial; -} +export type OpenCodeSettingsUpdate = Types.OpenCodeSettingsUpdate; -export interface GlobalSettingsUpdate { - company_name?: string; - brave_search_key?: string | null; - api_enabled?: boolean; - api_port?: number; - api_bind?: string; - worker_log_mode?: string; - opencode?: OpenCodeSettingsUpdate; -} +export type GlobalSettingsUpdate = Types.GlobalSettingsUpdate; // -- Skills Types -- -export interface SkillInfo { - name: string; - description: string; - file_path: string; - base_dir: string; - source: "builtin" | "instance" | "workspace"; - source_repo?: string; -} +export type SkillInfo = Types.SkillInfo; -export interface SkillsListResponse { - skills: SkillInfo[]; -} +export type SkillsListResponse = Types.SkillsListResponse; -export interface InstallSkillRequest { - agent_id: string; - spec: string; - instance?: boolean; -} +export type InstallSkillRequest = Types.InstallSkillRequest; -export interface InstallSkillResponse { - installed: string[]; -} +export type InstallSkillResponse = Types.InstallSkillResponse; -export interface RemoveSkillRequest { - agent_id: string; - name: string; -} +export type RemoveSkillRequest = Types.RemoveSkillRequest; -export interface RemoveSkillResponse { - success: boolean; - path: string | null; -} +export type RemoveSkillResponse = Types.RemoveSkillResponse; // -- Skills Registry Types (skills.sh) -- export type RegistryView = "all-time" | "trending" | "hot"; -export interface RegistrySkill { - source: string; - skillId: string; - name: string; - installs: number; - description?: string; - id?: string; -} +export type RegistrySkill = Types.RegistrySkill; -export interface RegistryBrowseResponse { - skills: RegistrySkill[]; - has_more: boolean; - total?: number; -} +export type RegistryBrowseResponse = Types.RegistryBrowseResponse; -export interface RegistrySearchResponse { - skills: RegistrySkill[]; - query: string; - count: number; -} +export type RegistrySearchResponse = Types.RegistrySearchResponse; -export interface SkillContentResponse { - name: string; - description: string; - content: string; - file_path: string; - base_dir: string; - source: string; - source_repo?: string; -} +export type SkillContentResponse = Types.SkillContentResponse; -export interface UploadSkillResponse { - installed: string[]; -} +export type UploadSkillResponse = Types.UploadSkillResponse; // -- Task Types -- - -export type TaskStatus = - | "pending_approval" - | "backlog" - | "ready" - | "in_progress" - | "blocked" - | "done"; -export type TaskPriority = "critical" | "high" | "medium" | "low"; - -export type TaskRunOutcome = - | "completed" - | "failed" - | "timeout" - | "cancelled" - | "blocked" - | "rate_limited" - | "abandoned"; - -/// A single execution attempt against a task. -export interface TaskRun { - id: string; - task_number: number; - attempt: number; - worker_id?: string; - /** Null while the attempt is still running. */ - outcome?: TaskRunOutcome; - summary?: string; - error?: string; - started_at: string; - ended_at?: string; -} - -export interface TaskRunsResponse { - runs: TaskRun[]; -} - -export interface TaskSubtask { - title: string; - completed: boolean; -} - -export interface TaskItem { - id: string; - task_number: number; - title: string; - description?: string; - status: TaskStatus; - priority: TaskPriority; - owner_agent_id: string; - assigned_agent_id: string; - subtasks: TaskSubtask[]; - metadata: Record; - source_memory_id?: string; - worker_id?: string; - created_by: string; - approved_at?: string; - approved_by?: string; - created_at: string; - updated_at: string; - completed_at?: string; - /** Failures since the last success. Reset on completion and on manual retry. */ - consecutive_failures: number; - /** Per-task override of the instance default failure limit. */ - max_retries?: number; - /** Most recent failure text, shown on the card when the task is parked. */ - last_error?: string; - /** Project this task acts on. */ - project_id?: string | null; - /** Repo within the project. A project can hold several repos. */ - repo_id?: string | null; - /** Worktree to execute in. */ - worktree_id?: string | null; -} - -export interface TaskListResponse { - tasks: TaskItem[]; -} - -export interface TaskResponse { - task: TaskItem; -} - -export interface TaskActionResponse { - success: boolean; - message: string; -} - -export interface CreateTaskRequest { - owner_agent_id: string; - assigned_agent_id?: string; - title: string; - description?: string; - status?: TaskStatus; - priority?: TaskPriority; - subtasks?: TaskSubtask[]; - metadata?: Record; - source_memory_id?: string; - created_by?: string; -} - -export interface UpdateTaskRequest { - title?: string; - description?: string; - status?: TaskStatus; - priority?: TaskPriority; - assigned_agent_id?: string; - subtasks?: TaskSubtask[]; - metadata?: Record; - complete_subtask?: number; - worker_id?: string; - approved_by?: string; -} +// +// Aliased straight from the generated OpenAPI schema rather than hand-written. +// These used to be duplicated by hand here, which `check-typegen` cannot catch: +// it only diffs `schema.d.ts` against the Rust, so a local redeclaration could +// drift from the server indefinitely and the build stayed green. +// +// `TaskItem` is kept as the name most call sites use. +export type Task = Types.Task; +export type TaskStatus = Types.TaskStatus; +export type TaskPriority = Types.TaskPriority; +export type TaskSubtask = Types.TaskSubtask; +export type TaskRun = Types.TaskRun; +export type TaskRunOutcome = Types.TaskRunOutcome; +export type TaskRunsResponse = Types.TaskRunsResponse; +export type TaskListResponse = Types.TaskListResponse; +export type TaskResponse = Types.TaskResponse; +export type TaskActionResponse = Types.TaskActionResponse; +export type TaskItem = Types.Task; + +export type CreateTaskRequest = Types.CreateTaskRequest; + +export type UpdateTaskRequest = Types.UpdateTaskRequest; // -- Notification Types -- export type NotificationKind = "task_approval" | "worker_failed" | "cortex_observation"; export type NotificationSeverity = "info" | "warn" | "error"; -export interface NotificationItem { - id: string; - kind: NotificationKind; - severity: NotificationSeverity; - title: string; - body?: string; - agent_id?: string; - related_entity_type?: string; - related_entity_id?: string; - action_url?: string; - metadata?: string; - created_at: string; - read_at?: string; - dismissed_at?: string; -} +export type NotificationItem = Types.Notification; -export interface NotificationsResponse { - notifications: NotificationItem[]; -} +export type NotificationsResponse = Types.NotificationsResponse; -export interface UnreadCountResponse { - count: number; -} +export type UnreadCountResponse = Types.UnreadCountResponse; export interface NotificationCreatedEvent { type: "notification_created"; @@ -1144,97 +694,21 @@ export interface NotificationUpdatedEvent { // -- Messaging / Bindings Types -- -export interface BindingInfo { - agent_id: string; - channel: string; - adapter: string | null; - guild_id: string | null; - workspace_id: string | null; - chat_id: string | null; - channel_ids: string[]; - require_mention: boolean; - dm_allowed_users: string[]; -} +export type BindingInfo = Types.BindingResponse; -export interface BindingsListResponse { - bindings: BindingInfo[]; -} +export type BindingsListResponse = Types.BindingsListResponse; -export interface CreateBindingRequest { - agent_id: string; - channel: string; - adapter?: string; - guild_id?: string; - workspace_id?: string; - chat_id?: string; - channel_ids?: string[]; - require_mention?: boolean; - dm_allowed_users?: string[]; - platform_credentials?: { - discord_token?: string; - slack_bot_token?: string; - slack_app_token?: string; - telegram_token?: string; - email_imap_host?: string; - email_imap_port?: number; - email_imap_username?: string; - email_imap_password?: string; - email_smtp_host?: string; - email_smtp_port?: number; - email_smtp_username?: string; - email_smtp_password?: string; - email_from_address?: string; - email_from_name?: string; - twitch_username?: string; - twitch_oauth_token?: string; - twitch_client_id?: string; - twitch_client_secret?: string; - twitch_refresh_token?: string; - }; -} +export type CreateBindingRequest = Types.CreateBindingRequest; -export interface CreateBindingResponse { - success: boolean; - restart_required: boolean; - message: string; -} +export type CreateBindingResponse = Types.CreateBindingResponse; -export interface UpdateBindingRequest { - original_agent_id: string; - original_channel: string; - original_adapter?: string; - original_guild_id?: string; - original_workspace_id?: string; - original_chat_id?: string; - agent_id: string; - channel: string; - adapter?: string; - guild_id?: string; - workspace_id?: string; - chat_id?: string; - channel_ids?: string[]; - require_mention?: boolean; - dm_allowed_users?: string[]; -} +export type UpdateBindingRequest = Types.UpdateBindingRequest; -export interface UpdateBindingResponse { - success: boolean; - message: string; -} +export type UpdateBindingResponse = Types.UpdateBindingResponse; -export interface DeleteBindingRequest { - agent_id: string; - channel: string; - adapter?: string; - guild_id?: string; - workspace_id?: string; - chat_id?: string; -} +export type DeleteBindingRequest = Types.DeleteBindingRequest; -export interface DeleteBindingResponse { - success: boolean; - message: string; -} +export type DeleteBindingResponse = Types.DeleteBindingResponse; // -- Links & Topology Types -- @@ -1252,168 +726,56 @@ export interface LinksResponse { links: AgentLinkResponse[]; } -export interface CreateHumanRequest { - id: string; - display_name?: string; - role?: string; - bio?: string; - description?: string; - discord_id?: string; - telegram_id?: string; - slack_id?: string; - email?: string; -} +export type CreateHumanRequest = Types.CreateHumanRequest; -export interface UpdateHumanRequest { - display_name?: string; - role?: string; - bio?: string; - description?: string; - discord_id?: string; - telegram_id?: string; - slack_id?: string; - email?: string; -} +export type UpdateHumanRequest = Types.UpdateHumanRequest; -export interface CreateGroupRequest { - name: string; - agent_ids?: string[]; - color?: string; -} +export type CreateGroupRequest = Types.CreateGroupRequest; -export interface UpdateGroupRequest { - name?: string; - agent_ids?: string[]; - color?: string; -} +export type UpdateGroupRequest = Types.UpdateGroupRequest; -export interface CreateLinkRequest { - from: string; - to: string; - direction?: LinkDirection; - kind?: LinkKind; -} +export type CreateLinkRequest = Types.CreateLinkRequest; -export interface UpdateLinkRequest { - direction?: LinkDirection; - kind?: LinkKind; -} +export type UpdateLinkRequest = Types.UpdateLinkRequest; // -- Projects Types -- -export type ProjectStatus = "active" | "archived"; +export type ProjectStatus = Types.ProjectStatus; -export interface Project { - id: string; - name: string; - description: string; - icon: string; - tags: string[]; - root_path: string; - logo_path: string | null; - settings: Record; - status: ProjectStatus; - sort_order: number; - created_at: string; - updated_at: string; -} +export type Project = Types.Project; -export interface ProjectRepo { - id: string; - project_id: string; - name: string; - path: string; - remote_url: string; - default_branch: string; - current_branch: string | null; - description: string; - disk_usage_bytes: number | null; - created_at: string; - updated_at: string; -} +export type ProjectRepo = Types.ProjectRepo; -export interface ProjectWorktree { - id: string; - project_id: string; - repo_id: string; - name: string; - path: string; - branch: string; - created_by: string; - disk_usage_bytes: number | null; - created_at: string; - updated_at: string; -} +export type ProjectWorktree = Types.ProjectWorktree; -export interface ProjectWorktreeWithRepo extends ProjectWorktree { - repo_name: string; -} +export type ProjectWorktreeWithRepo = Types.ProjectWorktreeWithRepo; /** GET /agents/projects response */ -export interface ProjectListResponse { - projects: Project[]; -} +export type ProjectListResponse = Types.ProjectListResponse; /** GET /agents/projects/:id response — project fields are flattened */ -export interface ProjectWithRelations extends Project { - repos: ProjectRepo[]; - worktrees: ProjectWorktreeWithRepo[]; -} +export type ProjectWithRelations = Types.ProjectWithRelations; export interface ProjectActionResponse { success: boolean; message: string; } -export interface DiskUsageEntry { - name: string; - bytes: number; - is_dir: boolean; -} +export type DiskUsageEntry = Types.DiskUsageEntry; -export interface DiskUsageResponse { - total_bytes: number; - entries: DiskUsageEntry[]; -} +export type DiskUsageResponse = Types.DiskUsageResponse; -export interface CreateProjectRequest { - name: string; - description?: string; - icon?: string; - tags?: string[]; - root_path: string; - settings?: Record; - auto_discover?: boolean; -} +export type CreateProjectRequest = Types.CreateProjectRequest; -export interface UpdateProjectRequest { - name?: string; - description?: string; - icon?: string; - tags?: string[]; - logo_path?: string | null; - settings?: Record; - status?: ProjectStatus; -} +export type UpdateProjectRequest = Types.UpdateProjectRequest; -export interface CreateRepoRequest { - name: string; - path: string; - remote_url?: string; - default_branch?: string; - description?: string; -} +export type CreateRepoRequest = Types.CreateRepoRequest; -export interface CreateWorktreeRequest { - repo_id: string; - branch: string; - worktree_name?: string; - start_point?: string; -} +export type CreateWorktreeRequest = Types.CreateWorktreeRequest; // -- Secrets Types -- -export type SecretCategory = "system" | "tool"; +export type SecretCategory = Types.SecretCategory; export type StoreState = "unencrypted" | "locked" | "unlocked"; export interface SecretStoreStatus { @@ -1425,33 +787,15 @@ export interface SecretStoreStatus { platform_managed: boolean; } -export interface SecretListItem { - name: string; - category: SecretCategory; - created_at: string; - updated_at: string; -} +export type SecretListItem = Types.SecretListItem; -export interface SecretListResponse { - secrets: SecretListItem[]; -} +export type SecretListResponse = Types.SecretListResponse; -export interface PutSecretResponse { - name: string; - category: SecretCategory; - reload_required: boolean; - message: string; -} +export type PutSecretResponse = Types.PutSecretResponse; -export interface DeleteSecretResponse { - deleted: string; - warning?: string; -} +export type DeleteSecretResponse = Types.DeleteSecretResponse; -export interface EncryptResponse { - master_key: string; - message: string; -} +export type EncryptResponse = Types.EncryptResponse; export interface UnlockResponse { state: string; @@ -1459,17 +803,9 @@ export interface UnlockResponse { message: string; } -export interface MigrationItem { - config_key: string; - secret_name: string; - category: SecretCategory; -} +export type MigrationItem = Types.MigrationItem; -export interface MigrateResponse { - migrated: MigrationItem[]; - skipped: string[]; - message: string; -} +export type MigrateResponse = Types.MigrateResponse; export const api = { status: () => fetchJson("/status"), @@ -1686,7 +1022,12 @@ export const api = { return fetchJson(`/agents/cron/executions?${search}`); }, - createCronJob: async (agentId: string, request: CreateCronRequest) => { + // `agent_id` is supplied by the caller's route context and injected here, + // so the request object itself never carries it. + createCronJob: async ( + agentId: string, + request: Omit, + ) => { const response = await fetch(`${getApiBase()}/agents/cron`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -2709,146 +2050,38 @@ export const api = { }, } -export interface UsageTotals { - input_tokens: number; - output_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - reasoning_tokens: number; - request_count: number; - estimated_cost_usd: number | null; - cost_status: string; -} +export type UsageTotals = Types.UsageTotals; -export interface UsageByModel { - model: string; - input_tokens: number; - output_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - reasoning_tokens: number; - request_count: number; - estimated_cost_usd: number | null; -} +export type UsageByModel = Types.UsageByModel; -export interface UsageResponse { - total: UsageTotals; - by_model?: UsageByModel[]; - by_day?: Array<{ date: string } & UsageTotals>; - by_agent?: Array<{ agent_id: string } & UsageTotals>; -}; +export type UsageResponse = Types.UsageResponse;; // Activity types -export interface ProcessTokens { - input: number; - output: number; - cache_read: number; - reasoning: number; - cost_usd: number; -} +export type ProcessTokens = Types.ProcessTokens; -export interface TokenSummary { - input: number; - output: number; - cache_read: number; - reasoning: number; - cost_usd: number; - by_process: Record; -} +export type TokenSummary = Types.TokenSummary; -export interface ActivityDay { - date: string; - messages: number; - branches: number; - workers: number; - cortex: number; - cron: number; - active_channels: number; - tokens: TokenSummary; -} +export type ActivityDay = Types.ActivityDay; -export interface ActivityTotals { - messages: number; - branches: number; - workers: number; - cortex: number; - cron: number; - active_channels: number; - tokens: TokenSummary; -} +export type ActivityTotals = Types.ActivityTotals; -export interface ActivityResponse { - daily: ActivityDay[]; - totals: ActivityTotals; -} +export type ActivityResponse = Types.ActivityResponse; // Wiki types export type WikiPageType = "entity" | "concept" | "decision" | "project" | "reference"; -export interface WikiPageSummary { - id: string; - slug: string; - title: string; - page_type: string; - version: number; - updated_at: string; - updated_by: string; -} +export type WikiPageSummary = Types.WikiPageSummary; -export interface WikiPage { - id: string; - slug: string; - title: string; - page_type: string; - content: string; - related: string[]; - created_by: string; - updated_by: string; - version: number; - archived: boolean; - created_at: string; - updated_at: string; -} +export type WikiPage = Types.WikiPage; -export interface WikiPageVersion { - id: string; - page_id: string; - version: number; - content: string; - edit_summary: string | null; - author_type: string; - author_id: string; - created_at: string; -} +export type WikiPageVersion = Types.WikiPageVersion; -export interface WikiListResponse { - pages: WikiPageSummary[]; - total: number; -} +export type WikiListResponse = Types.WikiListResponse; -export interface WikiPageResponse { - page: WikiPage; -} +export type WikiPageResponse = Types.WikiPageResponse; -export interface WikiHistoryResponse { - versions: WikiPageVersion[]; -} +export type WikiHistoryResponse = Types.WikiHistoryResponse; -export interface CreateWikiPageRequest { - title: string; - page_type: WikiPageType; - content: string; - related?: string[]; - edit_summary?: string; - author_id?: string; - author_type?: string; -} +export type CreateWikiPageRequest = Types.CreatePageRequest; -export interface EditWikiPageRequest { - old_string: string; - new_string: string; - replace_all?: boolean; - edit_summary?: string; - author_id?: string; - author_type?: string; -} +export type EditWikiPageRequest = Types.EditPageRequest; diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 60bc9086c..2230e785b 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -2690,7 +2690,7 @@ export interface components { bindings: components["schemas"]["BindingResponse"][]; }; BrowserSection: { - close_policy: string; + close_policy: components["schemas"]["ClosePolicy"]; enabled: boolean; evaluate_enabled: boolean; headless: boolean; @@ -3057,6 +3057,14 @@ export interface components { /** @description Repo within the project. A project holds many repos. */ repo_id?: string | null; source_memory_id?: string | null; + /** + * @description Status to create the task in. Defaults to `pending_approval`. + * + * The dashboard has always sent `backlog` here; the field simply did not + * exist, so serde dropped it and every task created from the UI came back + * awaiting an approval the creator had just given by clicking "create". + */ + status?: string | null; subtasks?: components["schemas"]["TaskSubtask"][]; title: string; /** @description Worktree to execute in. */ @@ -3935,23 +3943,33 @@ export interface components { }; RoutingSection: { branch: string; + branch_thinking_effort: string; channel: string; + channel_thinking_effort: string; compactor: string; + compactor_thinking_effort: string; cortex: string; + cortex_thinking_effort: string; /** Format: int64 */ rate_limit_cooldown_secs: number; voice: string; worker: string; + worker_thinking_effort: string; }; RoutingUpdate: { branch?: string | null; + branch_thinking_effort?: string | null; channel?: string | null; + channel_thinking_effort?: string | null; compactor?: string | null; + compactor_thinking_effort?: string | null; cortex?: string | null; + cortex_thinking_effort?: string | null; /** Format: int64 */ rate_limit_cooldown_secs?: number | null; voice?: string | null; worker?: string | null; + worker_thinking_effort?: string | null; }; SandboxSection: { mode: string; diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index e41fe27bf..95709dfd3 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -370,6 +370,9 @@ export type TaskSubtask = components["schemas"]["TaskSubtask"]; export type TaskListResponse = components["schemas"]["TaskListResponse"]; export type TaskResponse = components["schemas"]["TaskResponse"]; export type TaskActionResponse = components["schemas"]["TaskActionResponse"]; +export type TaskRun = components["schemas"]["TaskRun"]; +export type TaskRunOutcome = components["schemas"]["TaskRunOutcome"]; +export type TaskRunsResponse = components["schemas"]["TaskRunsResponse"]; // Requests export type CreateTaskRequest = components["schemas"]["CreateTaskRequest"]; @@ -509,3 +512,32 @@ export type StorageStatus = components["schemas"]["StorageStatus"]; // NOTE: Backup operations use raw binary data (zip/octet-stream), not JSON schema types: // - Backup export returns application/zip // - Backup restore accepts application/octet-stream + +// ============================================================================= +// Aliases consumed by client.ts +// ============================================================================= + +export type ActivityDay = components["schemas"]["ActivityDay"]; +export type ActivityResponse = components["schemas"]["ActivityResponse"]; +export type ActivityTotals = components["schemas"]["ActivityTotals"]; +export type Deployment = components["schemas"]["Deployment"]; +export type NotificationsResponse = components["schemas"]["NotificationsResponse"]; +export type PresetDefaults = components["schemas"]["PresetDefaults"]; +export type PresetMeta = components["schemas"]["PresetMeta"]; +export type ProcessTokens = components["schemas"]["ProcessTokens"]; +export type TokenSummary = components["schemas"]["TokenSummary"]; +export type UnreadCountResponse = components["schemas"]["UnreadCountResponse"]; +export type UpdateStatus = components["schemas"]["UpdateStatus"]; +export type UsageByModel = components["schemas"]["UsageByModel"]; +export type UsageResponse = components["schemas"]["UsageResponse"]; +export type UsageTotals = components["schemas"]["UsageTotals"]; +export type WikiHistoryResponse = components["schemas"]["WikiHistoryResponse"]; +export type WikiListResponse = components["schemas"]["WikiListResponse"]; +export type WikiPage = components["schemas"]["WikiPage"]; +export type WikiPageResponse = components["schemas"]["WikiPageResponse"]; +export type WikiPageSummary = components["schemas"]["WikiPageSummary"]; +export type WikiPageVersion = components["schemas"]["WikiPageVersion"]; +export type CreatePageRequest = components["schemas"]["CreatePageRequest"]; +export type EditPageRequest = components["schemas"]["EditPageRequest"]; +export type Notification = components["schemas"]["Notification"]; +export type SavedAttachmentMeta = components["schemas"]["SavedAttachmentMeta"]; diff --git a/interface/src/components/TaskUtils.tsx b/interface/src/components/TaskUtils.tsx index 0bb1487ef..548039d43 100644 --- a/interface/src/components/TaskUtils.tsx +++ b/interface/src/components/TaskUtils.tsx @@ -52,7 +52,16 @@ function readGithubReference( return { kind, label, url }; } -export function getGithubReferences(metadata: Record): GithubReference[] { +/** + * Task metadata is a free-form JSON value on the server, so it arrives typed as + * `unknown` rather than as an object — it is not guaranteed to be one. Narrow + * here instead of asserting at each call site. + */ +export function getGithubReferences(metadata: unknown): GithubReference[] { + if (!isRecord(metadata)) { + return []; + } + return [ readGithubReference(metadata.github_issue, "issue"), readGithubReference(metadata.github_pr, "pr"), diff --git a/interface/src/components/tasks/TaskRunHistory.tsx b/interface/src/components/tasks/TaskRunHistory.tsx index 55ea3ebe7..1691393ff 100644 --- a/interface/src/components/tasks/TaskRunHistory.tsx +++ b/interface/src/components/tasks/TaskRunHistory.tsx @@ -34,9 +34,14 @@ const OUTCOME_STYLE: Record< }, }; -/** Badge treatment per outcome. A still-running attempt has no outcome yet. */ +/** + * Badge treatment per outcome. A still-running attempt has no outcome yet. + * + * Nullable columns come off the wire as `null`, not `undefined`, so every + * optional field here accepts both. + */ function badgeVariantFor( - outcome?: TaskRunOutcome, + outcome?: TaskRunOutcome | null, ): "secondary" | "success" | "error" | "warning" { if (!outcome) return "secondary"; if (outcome === "completed") return "success"; @@ -46,7 +51,7 @@ function badgeVariantFor( return "error"; } -function formatDuration(startedAt: string, endedAt?: string): string | null { +function formatDuration(startedAt: string, endedAt?: string | null): string | null { if (!endedAt) return null; const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime(); if (!Number.isFinite(ms) || ms < 0) return null; diff --git a/interface/src/lib/format.ts b/interface/src/lib/format.ts index 81745fa3b..a14f1b491 100644 --- a/interface/src/lib/format.ts +++ b/interface/src/lib/format.ts @@ -27,7 +27,12 @@ export function formatDuration(seconds: number): string { return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; } -export function formatCronSchedule(cronExpr: string | null, intervalSecs: number): string { +// Nullable server fields arrive as `null`, and an absent optional as +// `undefined`. Accept both rather than making every caller normalise. +export function formatCronSchedule( + cronExpr: string | null | undefined, + intervalSecs: number, +): string { if (cronExpr) return cronExpr; if (intervalSecs % 86400 === 0) return `every ${intervalSecs / 86400}d`; if (intervalSecs % 3600 === 0) return `every ${intervalSecs / 3600}h`; diff --git a/interface/src/lib/json.ts b/interface/src/lib/json.ts new file mode 100644 index 000000000..5fde9c9a2 --- /dev/null +++ b/interface/src/lib/json.ts @@ -0,0 +1,25 @@ +/** + * Narrowing helpers for free-form JSON coming off the API. + * + * Several fields are `serde_json::Value` on the server, which the generated + * schema types as `unknown` — correctly, since nothing guarantees they are + * objects. Consumers narrow here rather than asserting a shape the server + * never promised. + */ + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The value as an object, or an empty one when it is anything else. */ +export function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +/** Whether a free-form JSON value carries anything worth rendering. */ +export function hasContent(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (isRecord(value)) return Object.keys(value).length > 0; + if (Array.isArray(value)) return value.length > 0; + return true; +} diff --git a/interface/src/routes/AgentCortex.tsx b/interface/src/routes/AgentCortex.tsx index 63476e474..f991b49b3 100644 --- a/interface/src/routes/AgentCortex.tsx +++ b/interface/src/routes/AgentCortex.tsx @@ -2,6 +2,7 @@ import {useState} from "react"; import {useQuery} from "@tanstack/react-query"; import {AnimatePresence, motion} from "framer-motion"; import {api, type CortexEvent, type CortexEventType} from "@/api/client"; +import {asRecord, hasContent} from "@/lib/json"; import {formatTimeAgo} from "@/lib/format"; import {FilterButton} from "@spacedrive/primitives"; @@ -67,10 +68,10 @@ function EventTypeBadge({eventType}: {eventType: string}) { ); } -function DetailsPanel({details}: {details: Record}) { +function DetailsPanel({details}: {details: unknown}) { return (
- {Object.entries(details).map(([key, value]) => ( + {Object.entries(asRecord(details)).map(([key, value]) => (
{key} @@ -116,7 +117,7 @@ export function AgentCortex({agentId}: AgentCortexProps) { if (isGroupFiltering) { events = events.filter((event) => - activeGroupTypes.includes(event.event_type), + (activeGroupTypes as string[]).includes(event.event_type), ); total = events.length; events = events.slice(offset, offset + PAGE_SIZE); @@ -230,7 +231,7 @@ export function AgentCortex({agentId}: AgentCortexProps) { {event.summary} - {event.details && ( + {hasContent(event.details) && ( {isExpanded ? "v" : ">"} @@ -238,7 +239,7 @@ export function AgentCortex({agentId}: AgentCortexProps) { - {isExpanded && event.details && ( + {isExpanded && hasContent(event.details) && ( { const active_start = data.active_start_hour ? parseInt(data.active_start_hour, 10) : undefined; @@ -225,7 +227,7 @@ export function AgentCron({agentId}: AgentCronProps) { }); const saveMutation = useMutation({ - mutationFn: (request: CreateCronRequest) => + mutationFn: (request: Omit) => api.createCronJob(agentId, request), onSuccess: () => { queryClient.invalidateQueries({queryKey: ["cron-jobs", agentId]}); diff --git a/interface/src/routes/AgentTasks.tsx b/interface/src/routes/AgentTasks.tsx index 1f6c51d1c..13ef4c1cc 100644 --- a/interface/src/routes/AgentTasks.tsx +++ b/interface/src/routes/AgentTasks.tsx @@ -274,7 +274,7 @@ export function AgentTasks({agentId}: {agentId: string}) { ); } -function GithubSection({metadata}: {metadata: Record}) { +function GithubSection({metadata}: {metadata: unknown}) { const refs = getGithubReferences(metadata); if (refs.length === 0) return null; diff --git a/interface/src/routes/GlobalTasks.tsx b/interface/src/routes/GlobalTasks.tsx index e0f13f6a3..cf9c95f98 100644 --- a/interface/src/routes/GlobalTasks.tsx +++ b/interface/src/routes/GlobalTasks.tsx @@ -433,7 +433,7 @@ function BindingSection({ ); } -function GithubSection({metadata}: {metadata: Record}) { +function GithubSection({metadata}: {metadata: unknown}) { const refs = getGithubReferences(metadata); if (refs.length === 0) return null; diff --git a/justfile b/justfile index 0b4f62c52..a86dcaf71 100644 --- a/justfile +++ b/justfile @@ -58,6 +58,13 @@ check-typegen: cargo run --bin openapi-spec > /tmp/spacebot-openapi-check.json cd interface && bunx openapi-typescript /tmp/spacebot-openapi-check.json -o /tmp/spacebot-schema-check.d.ts diff interface/src/api/schema.d.ts /tmp/spacebot-schema-check.d.ts + ./scripts/check-api-types.sh + +# Fail if the frontend hand-writes a type the schema already defines. Separate +# from check-typegen, which only proves schema.d.ts matches the Rust — not that +# the app actually consumes it. +check-api-types: + ./scripts/check-api-types.sh typegen-package: cargo run --bin openapi-spec > /tmp/spacebot-openapi-package.json diff --git a/scripts/check-api-types.sh b/scripts/check-api-types.sh new file mode 100755 index 000000000..8b6ad8cfe --- /dev/null +++ b/scripts/check-api-types.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Fail if the frontend hand-writes a type the OpenAPI schema already defines. +# +# `check-typegen` only proves `schema.d.ts` matches the Rust. It says nothing +# about whether the app actually *uses* those types. For a long time it did not: +# `client.ts` declared its own copies of 114 response and request shapes, so the +# generated file could be perfectly in sync while the app compiled against +# something else entirely. Nothing caught the difference, because from the type +# checker's point of view there was no difference to catch — just two unrelated +# types that happened to share a name. +# +# What that cost, found when the duplicates were finally removed: +# - the config API never exposed `*_thinking_effort`, so the dashboard's +# Thinking Effort control read and wrote nothing +# - `POST /tasks` ignored the `status` the dashboard sent, so every task +# created from the UI came back awaiting approval +# - 13 response types were missing server fields the UI could not reach +# - nullable fields were typed as `string | undefined` while the server sends +# `string | null` +# +# So: response and request types come from `schema.d.ts` via `types.ts`. Types +# with no server counterpart — SSE events, view models, component props — stay +# hand-written, and this check ignores them because they share no name with a +# schema type. + +set -euo pipefail + +cd "$(dirname "$0")/.." + +SCHEMA="interface/src/api/schema.d.ts" +CLIENT="interface/src/api/client.ts" + +for f in "$SCHEMA" "$CLIENT"; do + if [[ ! -f "$f" ]]; then + echo "check-api-types: missing $f" >&2 + exit 1 + fi +done + +python3 - "$SCHEMA" "$CLIENT" <<'PY' +import re +import sys + +schema_path, client_path = sys.argv[1], sys.argv[2] + +with open(schema_path) as fh: + schema = fh.read() +with open(client_path) as fh: + client = fh.read() + +# Component schemas are emitted at a fixed indent inside `components.schemas`. +schema_names = set(re.findall(r"^ ([A-Za-z_]\w*):", schema, re.M)) + +offenders = [] +for match in re.finditer(r"^export (interface|type) ([A-Za-z_]\w*)\b", client, re.M): + kind, name = match.group(1), match.group(2) + if name not in schema_names: + continue + # An alias pointing at the generated schema is the whole point — allow it. + tail = client[match.start():match.start() + 200] + if re.match(r"^export type \w+ = (Types\.\w+|components\[)", tail): + continue + line = client[:match.start()].count("\n") + 1 + offenders.append((line, kind, name)) + +if offenders: + print( + "check-api-types: these are declared by hand in client.ts but already " + "exist in the generated schema:\n", + file=sys.stderr, + ) + for line, kind, name in offenders: + print(f" client.ts:{line} export {kind} {name}", file=sys.stderr) + print( + "\nReplace each with an alias:\n" + " export type = Types.;\n" + "adding `export type = components[\"schemas\"][\"\"];` to " + "api/types.ts if it is not there yet.\n" + "\nIf the shapes genuinely differ, the server is the source of truth — " + "fix the Rust type, not the TypeScript copy.", + file=sys.stderr, + ) + sys.exit(1) + +print(f"check-api-types: OK ({len(schema_names)} schema types, no hand-written duplicates)") +PY diff --git a/scripts/gate-pr.sh b/scripts/gate-pr.sh index 76a4f4b11..ddd991862 100755 --- a/scripts/gate-pr.sh +++ b/scripts/gate-pr.sh @@ -171,6 +171,8 @@ if $is_ci; then fi check_migration_safety +# Cheap and catches a whole class of frontend/server drift — run it first. +run_step "scripts/check-api-types.sh" ./scripts/check-api-types.sh run_step "cargo fmt --all -- --check" cargo fmt --all -- --check run_step "cargo check --all-targets" cargo check --all-targets diff --git a/src/api/config.rs b/src/api/config.rs index 080d2c119..7c835016d 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -16,6 +16,15 @@ pub(super) struct RoutingSection { cortex: String, voice: String, rate_limit_cooldown_secs: u64, + // Per-process adaptive thinking effort. Honoured by + // `RoutingConfig::thinking_effort_for_model` and settable in the TOML, but + // absent from this response until now — so the dashboard's Thinking Effort + // control had nothing to read and nowhere to write. + channel_thinking_effort: String, + branch_thinking_effort: String, + worker_thinking_effort: String, + compactor_thinking_effort: String, + cortex_thinking_effort: String, } #[derive(Serialize, Debug, utoipa::ToSchema)] @@ -82,7 +91,10 @@ pub(super) struct BrowserSection { headless: bool, evaluate_enabled: bool, persist_session: bool, - close_policy: String, + // Typed rather than stringly, so the response declares the same closed set + // the update accepts. As a bare `String` the dashboard could not tell which + // values were legal from the schema alone. + close_policy: ClosePolicy, } #[derive(Serialize, Debug, utoipa::ToSchema)] @@ -172,6 +184,11 @@ pub(super) struct RoutingUpdate { cortex: Option, voice: Option, rate_limit_cooldown_secs: Option, + channel_thinking_effort: Option, + branch_thinking_effort: Option, + worker_thinking_effort: Option, + compactor_thinking_effort: Option, + cortex_thinking_effort: Option, } #[derive(Deserialize, Debug, utoipa::ToSchema)] @@ -309,6 +326,11 @@ pub(super) async fn get_agent_config( cortex: routing.cortex.clone(), voice: routing.voice.clone(), rate_limit_cooldown_secs: routing.rate_limit_cooldown_secs, + channel_thinking_effort: routing.channel_thinking_effort.clone(), + branch_thinking_effort: routing.branch_thinking_effort.clone(), + worker_thinking_effort: routing.worker_thinking_effort.clone(), + compactor_thinking_effort: routing.compactor_thinking_effort.clone(), + cortex_thinking_effort: routing.cortex_thinking_effort.clone(), }, tuning: TuningSection { max_concurrent_branches: **rc.max_concurrent_branches.load(), @@ -361,7 +383,7 @@ pub(super) async fn get_agent_config( headless: browser.headless, evaluate_enabled: browser.evaluate_enabled, persist_session: browser.persist_session, - close_policy: browser.close_policy.as_str().to_string(), + close_policy: browser.close_policy, }, channel: ChannelSection { listen_only_mode: channel.listen_only_mode, @@ -628,6 +650,20 @@ fn update_routing_table( if let Some(v) = routing.rate_limit_cooldown_secs { table["rate_limit_cooldown_secs"] = toml_edit::value(v as i64); } + for (key, value) in [ + ("channel_thinking_effort", &routing.channel_thinking_effort), + ("branch_thinking_effort", &routing.branch_thinking_effort), + ("worker_thinking_effort", &routing.worker_thinking_effort), + ( + "compactor_thinking_effort", + &routing.compactor_thinking_effort, + ), + ("cortex_thinking_effort", &routing.cortex_thinking_effort), + ] { + if let Some(v) = value { + table[key] = toml_edit::value(v.as_str()); + } + } Ok(()) } diff --git a/src/api/tasks.rs b/src/api/tasks.rs index 077ecfd45..38f0254fb 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -61,6 +61,13 @@ pub(super) struct CreateTaskRequest { /// Worktree to execute in. #[serde(default)] worktree_id: Option, + /// Status to create the task in. Defaults to `pending_approval`. + /// + /// The dashboard has always sent `backlog` here; the field simply did not + /// exist, so serde dropped it and every task created from the UI came back + /// awaiting an approval the creator had just given by clicking "create". + #[serde(default)] + status: Option, } #[derive(Deserialize, utoipa::ToSchema)] @@ -287,7 +294,8 @@ pub(super) async fn create_task( ) -> Result, StatusCode> { let store = get_task_store(&state)?; - let status = crate::tasks::TaskStatus::PendingApproval; + let status = parse_status(request.status.as_deref())? + .unwrap_or(crate::tasks::TaskStatus::PendingApproval); let priority = parse_priority(request.priority.as_deref())?.unwrap_or(crate::tasks::TaskPriority::Medium); From 7b6ede8f9cfa0d7275722e6b78ebcf7f3e625c66 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:52:49 +0000 Subject: [PATCH 12/69] feat(tasks): dependency edges and typed block reasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "B waits for A" has until now existed only in whatever plan an LLM wrote in its own head. `task_dependencies` makes it storage the scheduler enforces. Blocks are typed, because the four reasons a task stops need four different recoveries and a single `blocked` bucket cannot express that — an automatic sweep either resurrects cards a human deliberately parked, or it never resurrects anything. There is no correct single behaviour: dependency waiting upstream swept back automatically transient flaky / outage retried under the F1 budget needs_input wants a decision sticky; only an explicit unblock releases capability missing credential sticky; same A dependency wait rests in `backlog`, not `blocked`. It is ordinary scheduling, not an incident, and mixing the two would stop the board from answering the one question it exists to answer: what needs me? So `blocked` now means "stuck, a human should look", and the sweep never touches it unless the kind says it may. `recompute_ready` runs before each claim — promoting children whose parents all landed, and demoting `ready` tasks that gained an unfinished parent, which repairs drift from a reopened parent or an edge added after promotion. It has to run before the claim, or the graph stalls one tick behind reality forever. `claim_next_ready` re-checks the parent invariant itself rather than trusting the sweep. Hermes does the same and their comment cites an incident RCA; the cost is one NOT EXISTS and the failure it prevents is a task running before its input exists. The existing `rows_affected() == 0` race check is untouched — it was already correct. Cycles are rejected at link time, not discovered at execution time, and the rejection names the path that would have closed so the caller can see which existing edge conflicts. The walk is iterative: the graph is user-built and a deep chain must not blow the stack. `BLOCK_RECURRENCE_LIMIT` breaks the loop where a sweep unblocks a card and a worker immediately re-blocks it — the two trade it forever, burning a worker spawn each round, and nothing in the loop notices. Two repeats of the *same* reason escalates to `pending_approval`, which already notifies. Different reasons in sequence are progress, not a loop, and reset the counter. `CreateTaskInput.depends_on` files a task with its edges in one call. A rejected edge deletes the task rather than leaving it behind half-linked, which would be worse than nothing — the scheduler would run it early. Also gives F1's budget-exhausted tasks a `transient` kind, so the sweep can tell them apart from a dependency wait, and backfills existing blocked rows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- .../20260802000004_task_dependencies.sql | 42 + src/agent/cortex.rs | 36 + src/api/server.rs | 7 + src/api/tasks.rs | 241 ++++ src/tasks.rs | 8 +- src/tasks/store.rs | 1043 ++++++++++++++++- src/tools/task_create.rs | 10 + 7 files changed, 1377 insertions(+), 10 deletions(-) create mode 100644 migrations/global/20260802000004_task_dependencies.sql diff --git a/migrations/global/20260802000004_task_dependencies.sql b/migrations/global/20260802000004_task_dependencies.sql new file mode 100644 index 000000000..a05e14c00 --- /dev/null +++ b/migrations/global/20260802000004_task_dependencies.sql @@ -0,0 +1,42 @@ +-- Task dependency edges plus a typed reason for why a task is parked. +-- +-- Edges key on `task_number`, not `id`, because every other subsystem refers to +-- tasks by number — the tool schemas, the API paths, the prompts a worker sees. +-- `task_number` is UNIQUE but not the primary key, so SQLite will not accept it +-- as a foreign key target; referential integrity is enforced in `link_tasks`, +-- which has to reject self-loops and cycles anyway. + +CREATE TABLE IF NOT EXISTS task_dependencies ( + parent_task_number INTEGER NOT NULL, + child_task_number INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (parent_task_number, child_task_number) +); + +-- The sweep asks "which parents does this child have"; the completion path asks +-- "which children does this parent unblock". Both directions are hot. +CREATE INDEX IF NOT EXISTS idx_task_deps_child ON task_dependencies(child_task_number); +CREATE INDEX IF NOT EXISTS idx_task_deps_parent ON task_dependencies(parent_task_number); + +-- Why a task is parked. dependency | needs_input | capability | transient +-- +-- These are not cosmetic labels: each implies a different recovery. `dependency` +-- and `transient` recover on their own, `needs_input` and `capability` are +-- sticky and only a human releases them. A single undifferentiated "blocked" +-- cannot express that — an auto-recovery sweep would either resurrect cards a +-- human deliberately parked, or never resurrect anything. +ALTER TABLE tasks ADD COLUMN block_kind TEXT; +ALTER TABLE tasks ADD COLUMN block_reason TEXT; + +-- How many times this task has been unblocked and re-blocked for the same +-- reason. A cron and a worker can otherwise bounce a card between blocked and +-- ready forever; past the limit it escalates to a human instead. +ALTER TABLE tasks ADD COLUMN block_recurrences INTEGER NOT NULL DEFAULT 0; + +-- Tasks parked before this migration existed were all budget-exhaustion cases, +-- which is exactly `transient`. Backfilling means the column is never silently +-- NULL for a blocked task, so a reader does not have to guess. +UPDATE tasks SET block_kind = 'transient' WHERE status = 'blocked' AND block_kind IS NULL; + +-- The sweep scans for children whose parents may have finished. +CREATE INDEX IF NOT EXISTS idx_tasks_status_block_kind ON tasks(status, block_kind); diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index 6c8e6937c..4e2a7f5cb 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -4276,6 +4276,42 @@ async fn run_ready_task_loop(deps: &AgentDeps, logger: &CortexLogger) -> anyhow: Err(error) => tracing::warn!(%error, "task reaper pass failed"), } + // Then reconcile the dependency graph. A child whose last parent just + // finished is still sitting in `backlog`; nothing promotes it except + // this sweep, so it has to run before the claim or the graph stalls one + // tick behind reality forever. + match deps.task_store.recompute_ready(&deps.agent_id).await { + Ok(sweep) if sweep.is_empty() => {} + Ok(sweep) => { + logger.log( + "task_ready_sweep", + &format!( + "Dependency sweep promoted {} and demoted {} task(s)", + sweep.promoted.len(), + sweep.demoted.len() + ), + Some(serde_json::json!({ + "promoted": sweep.promoted, + "demoted": sweep.demoted, + })), + ); + let moves = sweep + .promoted + .iter() + .map(|number| (*number, TaskStatus::Ready)) + .chain(sweep.demoted.iter().map(|n| (*n, TaskStatus::Backlog))); + for (task_number, status) in moves { + let _ = deps.event_tx.send(ProcessEvent::TaskUpdated { + agent_id: deps.agent_id.clone(), + task_number, + status: status.as_str().to_string(), + action: "updated".to_string(), + }); + } + } + Err(error) => tracing::warn!(%error, "dependency ready sweep failed"), + } + if let Err(error) = pickup_one_ready_task(deps, logger).await { tracing::warn!(%error, "ready-task pickup pass failed"); } diff --git a/src/api/server.rs b/src/api/server.rs index 1630ac639..debc1e519 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -144,6 +144,13 @@ pub fn api_router() -> OpenApiRouter> { .routes(routes!(tasks::assign_task)) .routes(routes!(tasks::list_task_runs)) .routes(routes!(tasks::retry_task)) + .routes(routes!( + tasks::list_task_dependencies, + tasks::add_task_dependency + )) + .routes(routes!(tasks::remove_task_dependency)) + .routes(routes!(tasks::block_task)) + .routes(routes!(tasks::unblock_task)) // Wiki routes .routes(routes!(wiki::list_pages, wiki::create_page)) .routes(routes!(wiki::search_pages)) diff --git a/src/api/tasks.rs b/src/api/tasks.rs index 38f0254fb..4d4b28d95 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -61,6 +61,9 @@ pub(super) struct CreateTaskRequest { /// Worktree to execute in. #[serde(default)] worktree_id: Option, + /// Task numbers that must finish before this one may run. + #[serde(default)] + depends_on: Vec, /// Status to create the task in. Defaults to `pending_approval`. /// /// The dashboard has always sent `backlog` here; the field simply did not @@ -135,6 +138,29 @@ pub(super) struct TaskRunsResponse { runs: Vec, } +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct TaskDependenciesResponse { + /// Tasks this one waits on. + parents: Vec, + /// Tasks waiting on this one. + children: Vec, + /// The subset of `parents` that has not finished yet — what the board + /// should name when explaining why a task is not moving. + blocked_by: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub(super) struct AddDependencyRequest { + parent_task_number: i64, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub(super) struct BlockTaskRequest { + /// dependency | needs_input | capability | transient + kind: String, + reason: String, +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -320,6 +346,7 @@ pub(super) async fn create_task( repo_id: request.repo_id, worktree_id: request.worktree_id, }, + depends_on: request.depends_on, }) .await .map_err(|error| { @@ -683,3 +710,217 @@ pub(super) async fn assign_task( emit_task_event(&state, &task, "updated"); Ok(Json(TaskResponse { task })) } + +/// `GET /tasks/{number}/dependencies` — the edges around a task. +#[utoipa::path( + get, + path = "/tasks/{number}/dependencies", + params(("number" = i64, Path, description = "Task number")), + responses( + (status = 200, body = TaskDependenciesResponse), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn list_task_dependencies( + State(state): State>, + Path(number): Path, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + let parents = store.list_parents(number).await.map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to list task parents"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let children = store.list_children(number).await.map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to list task children"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let blocked_by = store.unfinished_parents(number).await.map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to list unfinished parents"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(TaskDependenciesResponse { + parents, + children, + blocked_by, + })) +} + +/// `POST /tasks/{number}/dependencies` — make this task wait on another. +/// +/// Rejects self-loops, unknown tasks, and any edge that would close a cycle. +/// The cycle response names the path so the caller can see which existing edge +/// conflicts, rather than being told only that something is wrong. +#[utoipa::path( + post, + path = "/tasks/{number}/dependencies", + params(("number" = i64, Path, description = "Child task number")), + request_body = AddDependencyRequest, + responses( + (status = 200, body = TaskDependenciesResponse), + (status = 404, description = "Task not found"), + (status = 409, description = "Edge would create a cycle"), + (status = 422, description = "A task cannot depend on itself"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn add_task_dependency( + State(state): State>, + Path(number): Path, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let store = get_task_store(&state) + .map_err(|status| (status, "task store not initialized".to_string()))?; + + store + .link_tasks(request.parent_task_number, number) + .await + .map_err(|error| { + let status = match &error { + crate::tasks::DependencyError::SelfLoop { .. } => StatusCode::UNPROCESSABLE_ENTITY, + crate::tasks::DependencyError::UnknownTask { .. } => StatusCode::NOT_FOUND, + crate::tasks::DependencyError::WouldCycle { .. } => StatusCode::CONFLICT, + crate::tasks::DependencyError::Storage(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, error.to_string()) + })?; + + // A task that just gained an unfinished parent must not stay claimable. + if let Ok(unfinished) = store.unfinished_parents(number).await + && !unfinished.is_empty() + && let Err(error) = store + .block_task( + number, + crate::tasks::BlockKind::Dependency, + &format!( + "waiting on {}", + unfinished + .iter() + .map(|n| format!("#{n}")) + .collect::>() + .join(", ") + ), + ) + .await + { + tracing::warn!(%error, task_number = number, "failed to park newly dependent task"); + } + + list_task_dependencies(State(state), Path(number)) + .await + .map_err(|status| (status, "failed to read dependencies".to_string())) +} + +/// `DELETE /tasks/{number}/dependencies/{parent}` — drop an edge. +#[utoipa::path( + delete, + path = "/tasks/{number}/dependencies/{parent}", + params( + ("number" = i64, Path, description = "Child task number"), + ("parent" = i64, Path, description = "Parent task number"), + ), + responses( + (status = 200, body = TaskDependenciesResponse), + (status = 404, description = "Edge not found"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn remove_task_dependency( + State(state): State>, + Path((number, parent)): Path<(i64, i64)>, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + let removed = store.unlink_tasks(parent, number).await.map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to unlink tasks"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + if !removed { + return Err(StatusCode::NOT_FOUND); + } + + list_task_dependencies(State(state), Path(number)).await +} + +/// `POST /tasks/{number}/block` — park a task with a typed reason. +#[utoipa::path( + post, + path = "/tasks/{number}/block", + params(("number" = i64, Path, description = "Task number")), + request_body = BlockTaskRequest, + responses( + (status = 200, body = TaskResponse), + (status = 404, description = "Task not found"), + (status = 422, description = "Unknown block kind"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn block_task( + State(state): State>, + Path(number): Path, + Json(request): Json, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + let kind = + crate::tasks::BlockKind::parse(&request.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?; + + store + .block_task(number, kind, &request.reason) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to block task"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + let task = store + .get_by_number(number) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to read blocked task"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + emit_task_event(&state, &task, "updated"); + Ok(Json(TaskResponse { task })) +} + +/// `POST /tasks/{number}/unblock` — release a parked task. +/// +/// Lands in `ready` when nothing upstream is outstanding, `backlog` otherwise. +#[utoipa::path( + post, + path = "/tasks/{number}/unblock", + params(("number" = i64, Path, description = "Task number")), + responses( + (status = 200, body = TaskResponse), + (status = 404, description = "Task not found or not blocked"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn unblock_task( + State(state): State>, + Path(number): Path, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + let task = store + .unblock_task(number) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to unblock task"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + emit_task_event(&state, &task, "updated"); + Ok(Json(TaskResponse { task })) +} diff --git a/src/tasks.rs b/src/tasks.rs index 3af939379..c7cdae907 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -4,8 +4,8 @@ pub mod migration; pub mod store; pub use store::{ - CreateTaskInput, DEFAULT_FAILURE_LIMIT, FailureDisposition, Task, TaskBindingPatch, - TaskListFilter, TaskPriority, TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, - TaskStore, TaskSubtask, TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, - can_transition, + BLOCK_RECURRENCE_LIMIT, BlockKind, BlockOutcome, CreateTaskInput, DEFAULT_FAILURE_LIMIT, + DependencyError, FailureDisposition, ReadySweep, Task, TaskBindingPatch, TaskListFilter, + TaskPriority, TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, TaskStore, TaskSubtask, + TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, can_transition, }; diff --git a/src/tasks/store.rs b/src/tasks/store.rs index 3e163bd68..d47738714 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -20,13 +20,91 @@ pub enum TaskStatus { Backlog, Ready, InProgress, - /// Parked and not eligible for pickup. Today this is only reached by - /// exhausting the failure budget; `block_kind` in a later change will - /// distinguish dependency waits from human gates. + /// Stuck and waiting on a human. `block_kind` says why. + /// + /// Deliberately *not* where a task waiting on its dependencies lives — that + /// task sits in `Backlog`, which already means "not yet eligible". Putting + /// both in one status would make the board unable to answer the only + /// question it exists to answer: what needs me? Blocked, Done, } +/// Why a task is parked. +/// +/// The kinds differ in how they recover, which is the entire reason the column +/// exists. `dependency` and `transient` clear themselves; `needs_input` and +/// `capability` are sticky and only an explicit unblock releases them. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BlockKind { + /// Waiting on an upstream task. Cleared automatically by the ready sweep. + /// Never a human gate, so a task carrying it stays in `Backlog`. + Dependency, + /// Needs a human decision. + NeedsInput, + /// The agent lacks a tool, credential, or permission it needs. + Capability, + /// Flaky failure or provider outage. Retried under the F1 failure budget. + Transient, +} + +impl BlockKind { + pub fn as_str(self) -> &'static str { + match self { + BlockKind::Dependency => "dependency", + BlockKind::NeedsInput => "needs_input", + BlockKind::Capability => "capability", + BlockKind::Transient => "transient", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "dependency" => Some(BlockKind::Dependency), + "needs_input" => Some(BlockKind::NeedsInput), + "capability" => Some(BlockKind::Capability), + "transient" => Some(BlockKind::Transient), + _ => None, + } + } + + /// Whether only an explicit unblock may release this task. + /// + /// The automatic sweep must skip sticky kinds. A human parked the task + /// knowing it could not proceed; resurrecting it on a timer would throw + /// that decision away and hand the worker the same dead end again. + pub fn is_sticky(self) -> bool { + matches!(self, BlockKind::NeedsInput | BlockKind::Capability) + } + + /// The status a task takes when blocked for this reason. + /// + /// `dependency` is ordinary scheduling, not an incident: the task goes back + /// to `Backlog` and the sweep promotes it when its parents land. Everything + /// else is a stop that wants attention. + pub fn resting_status(self) -> TaskStatus { + match self { + BlockKind::Dependency => TaskStatus::Backlog, + _ => TaskStatus::Blocked, + } + } +} + +impl std::fmt::Display for BlockKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// How many times a task may be unblocked and re-blocked for the *same* reason +/// before it escalates to a human instead of continuing to bounce. +/// +/// Borrowed from Hermes, which learned it by running the system: a cron that +/// unblocks and a worker that re-blocks will trade a card forever, burning a +/// worker spawn each round, and nothing in the loop notices. +pub const BLOCK_RECURRENCE_LIMIT: i64 = 2; + impl TaskStatus { pub const ALL: [TaskStatus; 6] = [ TaskStatus::PendingApproval, @@ -164,6 +242,12 @@ pub struct Task { /// Worktree to execute in. When set, the worker's working directory is /// resolved from it rather than from the repo or project root. pub worktree_id: Option, + /// Why this task is parked, when it is. + pub block_kind: Option, + /// Human-readable explanation shown on the card. + pub block_reason: Option, + /// Consecutive blocks for the same reason. See [`BLOCK_RECURRENCE_LIMIT`]. + pub block_recurrences: i64, } /// The codebase a task acts on. Every field is optional and independently @@ -332,6 +416,11 @@ pub struct CreateTaskInput { pub created_by: String, /// Codebase this task acts on. Empty for tasks that aren't about code. pub binding: TaskProjectBinding, + /// Tasks that must finish before this one may run. + /// + /// Applied after the row exists, so a bad edge fails the create rather than + /// leaving an orphan task with a half-built graph. + pub depends_on: Vec, } /// Defaults exist so callers can use `..Default::default()` and stay source @@ -351,6 +440,7 @@ impl Default for CreateTaskInput { source_memory_id: None, created_by: String::new(), binding: TaskProjectBinding::default(), + depends_on: Vec::new(), } } } @@ -481,6 +571,35 @@ impl TaskStore { .await .context("failed to commit task create transaction")?; + // Edges are applied after the row exists, because + // `link_tasks` validates both endpoints. A rejected edge + // deletes the task rather than leaving it behind with a + // graph the caller did not ask for — a half-linked task is + // worse than none, since the scheduler would run it early. + for parent in &input.depends_on { + if let Err(error) = self.link_tasks(*parent, task_number).await { + let _ = self.delete(task_number).await; + return Err(anyhow::anyhow!( + "failed to link task #{task_number} to parent #{parent}: {error}" + ) + .into()); + } + } + + // Anything waiting on a parent starts in backlog; the ready + // sweep promotes it once every parent lands. + if !input.depends_on.is_empty() && input.status == TaskStatus::Ready { + sqlx::query( + "UPDATE tasks SET status = 'backlog', block_kind = 'dependency', \ + block_reason = 'waiting on an upstream task' \ + WHERE task_number = ? AND status = 'ready'", + ) + .bind(task_number) + .execute(&self.pool) + .await + .context("failed to park newly linked task")?; + } + return self .get_by_number(task_number) .await? @@ -878,6 +997,10 @@ impl TaskStore { pub async fn claim_next_ready(&self, assigned_agent_id: &str) -> Result> { let row = sqlx::query( "SELECT task_number FROM tasks WHERE assigned_agent_id = ? AND status = 'ready' \ + AND NOT EXISTS (\ + SELECT 1 FROM task_dependencies d \ + JOIN tasks p ON p.task_number = d.parent_task_number \ + WHERE d.child_task_number = tasks.task_number AND p.status <> 'done') \ ORDER BY CASE priority \ WHEN 'critical' THEN 0 \ WHEN 'high' THEN 1 \ @@ -902,7 +1025,11 @@ impl TaskStore { let result = sqlx::query( "UPDATE tasks SET status = 'in_progress', \ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ - WHERE task_number = ? AND status = 'ready'", + WHERE task_number = ? AND status = 'ready' \ + AND NOT EXISTS (\ + SELECT 1 FROM task_dependencies d \ + JOIN tasks p ON p.task_number = d.parent_task_number \ + WHERE d.child_task_number = tasks.task_number AND p.status <> 'done')", ) .bind(task_number) .execute(&self.pool) @@ -928,6 +1055,327 @@ impl TaskStore { row.map(task_from_row).transpose() } + // -- Dependency graph --------------------------------------------------- + + /// Add a `parent → child` edge. + /// + /// Rejects at link time rather than at execution time: a cycle discovered + /// while scheduling is a deadlock nobody can see, whereas a cycle rejected + /// here names the exact edge that would have caused it. + pub async fn link_tasks( + &self, + parent: i64, + child: i64, + ) -> std::result::Result<(), DependencyError> { + if parent == child { + return Err(DependencyError::SelfLoop { task_number: child }); + } + + let mut tx = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| DependencyError::Storage(error.to_string()))?; + + for number in [parent, child] { + let exists: Option = + sqlx::query_scalar("SELECT task_number FROM tasks WHERE task_number = ?") + .bind(number) + .fetch_optional(&mut *tx) + .await + .map_err(|error| DependencyError::Storage(error.to_string()))?; + if exists.is_none() { + return Err(DependencyError::UnknownTask { + task_number: number, + }); + } + } + + // Walk down from `child`: if `parent` is reachable, this edge closes a + // loop. Done inside the write transaction so a concurrent link cannot + // slip an edge in between the check and the insert. + if let Some(path) = reachable_path(&mut tx, child, parent).await? { + return Err(DependencyError::WouldCycle { path }); + } + + sqlx::query( + "INSERT OR IGNORE INTO task_dependencies (parent_task_number, child_task_number) \ + VALUES (?, ?)", + ) + .bind(parent) + .bind(child) + .execute(&mut *tx) + .await + .map_err(|error| DependencyError::Storage(error.to_string()))?; + + tx.commit() + .await + .map_err(|error| DependencyError::Storage(error.to_string()))?; + + Ok(()) + } + + /// Remove a `parent → child` edge. Returns whether an edge was removed. + pub async fn unlink_tasks(&self, parent: i64, child: i64) -> Result { + let result = sqlx::query( + "DELETE FROM task_dependencies \ + WHERE parent_task_number = ? AND child_task_number = ?", + ) + .bind(parent) + .bind(child) + .execute(&self.pool) + .await + .context("failed to unlink tasks")?; + + Ok(result.rows_affected() > 0) + } + + /// Task numbers this task waits on. + pub async fn list_parents(&self, child: i64) -> Result> { + sqlx::query_scalar( + "SELECT parent_task_number FROM task_dependencies \ + WHERE child_task_number = ? ORDER BY parent_task_number ASC", + ) + .bind(child) + .fetch_all(&self.pool) + .await + .context("failed to list task parents") + .map_err(Into::into) + } + + /// Task numbers waiting on this task. + pub async fn list_children(&self, parent: i64) -> Result> { + sqlx::query_scalar( + "SELECT child_task_number FROM task_dependencies \ + WHERE parent_task_number = ? ORDER BY child_task_number ASC", + ) + .bind(parent) + .fetch_all(&self.pool) + .await + .context("failed to list task children") + .map_err(Into::into) + } + + /// Parents of `child` that have not reached a terminal status. + pub async fn unfinished_parents(&self, child: i64) -> Result> { + sqlx::query_scalar( + "SELECT d.parent_task_number FROM task_dependencies d \ + JOIN tasks p ON p.task_number = d.parent_task_number \ + WHERE d.child_task_number = ? AND p.status <> 'done' \ + ORDER BY d.parent_task_number ASC", + ) + .bind(child) + .fetch_all(&self.pool) + .await + .context("failed to list unfinished parents") + .map_err(Into::into) + } + + // -- Blocking ----------------------------------------------------------- + + /// Park a task with a typed reason. + /// + /// Returns the status the task came to rest in, which depends on the kind: + /// a dependency wait is ordinary scheduling and rests in `Backlog`, while + /// everything else rests in `Blocked` where a human will see it. + /// + /// Re-blocking for the same reason increments `block_recurrences`; past + /// [`BLOCK_RECURRENCE_LIMIT`] the task escalates to `PendingApproval` + /// instead, which already raises a notification. That breaks the loop where + /// a sweep unblocks a card and a worker immediately re-blocks it. + pub async fn block_task( + &self, + task_number: i64, + kind: BlockKind, + reason: &str, + ) -> Result> { + let mut tx = self + .pool + .begin_with("BEGIN IMMEDIATE") + .await + .context("failed to open block transaction")?; + + let row = + sqlx::query("SELECT block_kind, block_recurrences FROM tasks WHERE task_number = ?") + .bind(task_number) + .fetch_optional(&mut *tx) + .await + .context("failed to read current block state")?; + + let Some(row) = row else { + tx.commit() + .await + .context("failed to commit empty block transaction")?; + return Ok(None); + }; + + let previous_kind = row + .try_get::, _>("block_kind") + .ok() + .flatten() + .as_deref() + .and_then(BlockKind::parse); + let previous_recurrences: i64 = row.try_get("block_recurrences").unwrap_or(0); + + // Only a repeat of the *same* reason counts. Bouncing between different + // reasons is a task making progress through different obstacles, not a + // loop, and escalating it would be noise. + let recurrences = if previous_kind == Some(kind) { + previous_recurrences + 1 + } else { + 0 + }; + + let escalated = recurrences >= BLOCK_RECURRENCE_LIMIT; + let status = if escalated { + TaskStatus::PendingApproval + } else { + kind.resting_status() + }; + + sqlx::query( + "UPDATE tasks SET status = ?, block_kind = ?, block_reason = ?, \ + block_recurrences = ?, worker_id = NULL, \ + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ + WHERE task_number = ?", + ) + .bind(status.as_str()) + .bind(kind.as_str()) + .bind(reason) + .bind(recurrences) + .bind(task_number) + .execute(&mut *tx) + .await + .context("failed to block task")?; + + tx.commit() + .await + .context("failed to commit block transaction")?; + + Ok(Some(BlockOutcome { + status, + kind, + recurrences, + escalated, + })) + } + + /// Release a parked task back to the scheduler. + /// + /// Clears the reason but deliberately keeps `block_recurrences`: the + /// counter exists to notice a task being unblocked and re-blocked in a + /// loop, and resetting it here would erase the very evidence of that. + /// A task with unfinished parents goes to `backlog`, not `ready`. + pub async fn unblock_task(&self, task_number: i64) -> Result> { + let unfinished = self.unfinished_parents(task_number).await?; + let status = if unfinished.is_empty() { + TaskStatus::Ready + } else { + TaskStatus::Backlog + }; + + let result = sqlx::query( + "UPDATE tasks SET status = ?, block_kind = NULL, block_reason = NULL, \ + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ + WHERE task_number = ? AND status IN ('blocked', 'backlog', 'pending_approval')", + ) + .bind(status.as_str()) + .bind(task_number) + .execute(&self.pool) + .await + .context("failed to unblock task")?; + + if result.rows_affected() == 0 { + return Ok(None); + } + + self.get_by_number(task_number).await + } + + // -- Ready sweep -------------------------------------------------------- + + /// Reconcile which of an agent's tasks are eligible for pickup. + /// + /// Run before claiming. Three repairs, in one pass: + /// + /// - `backlog` whose parents are all done, with no sticky block → `ready` + /// - `ready` with an unfinished parent → back to `backlog` (repairs drift + /// from a parent being reopened, or an edge added after promotion) + /// - `blocked(dependency)` whose parents are all done → `ready` + /// + /// Sticky kinds are never touched. That is the whole point of typing the + /// block: a human parked those, and a sweep that resurrects them would hand + /// the worker the same dead end it already reported. + pub async fn recompute_ready(&self, assigned_agent_id: &str) -> Result { + let mut sweep = ReadySweep::default(); + + // Promote: eligible and waiting. + let promoted: Vec = sqlx::query_scalar( + "SELECT task_number FROM tasks t \ + WHERE t.assigned_agent_id = ? \ + AND t.status = 'backlog' \ + AND (t.block_kind IS NULL OR t.block_kind = 'dependency') \ + AND NOT EXISTS (\ + SELECT 1 FROM task_dependencies d \ + JOIN tasks p ON p.task_number = d.parent_task_number \ + WHERE d.child_task_number = t.task_number AND p.status <> 'done') \ + AND EXISTS (\ + SELECT 1 FROM task_dependencies d WHERE d.child_task_number = t.task_number)", + ) + .bind(assigned_agent_id) + .fetch_all(&self.pool) + .await + .context("failed to find promotable tasks")?; + + for task_number in promoted { + let updated = sqlx::query( + "UPDATE tasks SET status = 'ready', block_kind = NULL, block_reason = NULL, \ + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ + WHERE task_number = ? AND status = 'backlog'", + ) + .bind(task_number) + .execute(&self.pool) + .await + .context("failed to promote task")?; + if updated.rows_affected() > 0 { + sweep.promoted.push(task_number); + } + } + + // Demote: promoted too early, or a parent came back. + let demoted: Vec = sqlx::query_scalar( + "SELECT task_number FROM tasks t \ + WHERE t.assigned_agent_id = ? \ + AND t.status = 'ready' \ + AND EXISTS (\ + SELECT 1 FROM task_dependencies d \ + JOIN tasks p ON p.task_number = d.parent_task_number \ + WHERE d.child_task_number = t.task_number AND p.status <> 'done')", + ) + .bind(assigned_agent_id) + .fetch_all(&self.pool) + .await + .context("failed to find tasks to demote")?; + + for task_number in demoted { + let updated = sqlx::query( + "UPDATE tasks SET status = 'backlog', block_kind = 'dependency', \ + block_reason = 'waiting on an upstream task', \ + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ + WHERE task_number = ? AND status = 'ready'", + ) + .bind(task_number) + .execute(&self.pool) + .await + .context("failed to demote task")?; + if updated.rows_affected() > 0 { + sweep.demoted.push(task_number); + } + } + + Ok(sweep) + } + // -- Attempt log -------------------------------------------------------- /// Open a new attempt row for a task. The attempt number is one past the @@ -1112,14 +1560,24 @@ impl TaskStore { TaskStatus::Ready }; + // A parked task carries a reason. Exhausting the budget is a + // `transient` block: repeated failures nobody classified further. + // Requeued tasks clear any stale reason so the card does not keep + // showing why a previous attempt stopped. + let block_kind = exhausted.then_some(BlockKind::Transient.as_str()); + let block_reason = exhausted.then_some(error); + sqlx::query( "UPDATE tasks SET consecutive_failures = ?, last_error = ?, status = ?, \ + block_kind = ?, block_reason = ?, \ worker_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ WHERE task_number = ? AND status = 'in_progress'", ) .bind(failures) .bind(error) .bind(next_status.as_str()) + .bind(block_kind) + .bind(block_reason) .bind(task_number) .execute(&mut *tx) .await @@ -1153,6 +1611,91 @@ impl TaskStore { } } +/// Why a dependency edge was refused. +#[derive(Debug, Clone, thiserror::Error)] +pub enum DependencyError { + #[error("task #{task_number} cannot depend on itself")] + SelfLoop { task_number: i64 }, + #[error("task #{task_number} does not exist")] + UnknownTask { task_number: i64 }, + #[error( + "that edge would create a cycle: {}", + .path.iter().map(|n| format!("#{n}")).collect::>().join(" -> ") + )] + WouldCycle { path: Vec }, + #[error("dependency storage error: {0}")] + Storage(String), +} + +/// What [`TaskStore::block_task`] did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockOutcome { + /// Where the task came to rest. + pub status: TaskStatus, + pub kind: BlockKind, + /// Consecutive blocks for this same kind. + pub recurrences: i64, + /// Whether the recurrence limit forced an escalation to a human. + pub escalated: bool, +} + +/// What a [`TaskStore::recompute_ready`] pass changed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReadySweep { + /// Tasks whose parents all finished, now eligible for pickup. + pub promoted: Vec, + /// Tasks that were eligible but should not have been. + pub demoted: Vec, +} + +impl ReadySweep { + pub fn is_empty(&self) -> bool { + self.promoted.is_empty() && self.demoted.is_empty() + } +} + +/// Walk the edges downward from `start`, looking for `target`. +/// +/// Returns the path that reaches it, so a rejection can name the loop instead +/// of just asserting one exists. Iterative rather than recursive: the graph is +/// user-built and a deep chain must not blow the stack. +async fn reachable_path( + tx: &mut sqlx::SqliteConnection, + start: i64, + target: i64, +) -> std::result::Result>, DependencyError> { + // Each entry is the path taken to reach its final node, so the answer is + // available without a second traversal to reconstruct it. + let mut frontier: Vec> = vec![vec![start]]; + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + seen.insert(start); + + while let Some(path) = frontier.pop() { + let node = *path.last().expect("paths are never empty"); + if node == target { + return Ok(Some(path)); + } + + let children: Vec = sqlx::query_scalar( + "SELECT child_task_number FROM task_dependencies WHERE parent_task_number = ?", + ) + .bind(node) + .fetch_all(&mut *tx) + .await + .map_err(|error| DependencyError::Storage(error.to_string()))?; + + for child in children { + if seen.insert(child) { + let mut next = path.clone(); + next.push(child); + frontier.push(next); + } + } + } + + Ok(None) +} + /// What [`TaskStore::record_failure`] decided to do with a failed attempt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FailureDisposition { @@ -1173,7 +1716,8 @@ pub enum FailureDisposition { const SELECT_COLUMNS: &str = "SELECT id, task_number, title, description, status, priority, \ owner_agent_id, assigned_agent_id, subtasks, metadata, source_memory_id, worker_id, \ created_by, approved_at, approved_by, created_at, updated_at, completed_at, \ - consecutive_failures, max_retries, last_error, project_id, repo_id, worktree_id"; + consecutive_failures, max_retries, last_error, project_id, repo_id, worktree_id, \ + block_kind, block_reason, block_recurrences"; const RUN_SELECT_COLUMNS: &str = "SELECT id, task_number, attempt, worker_id, outcome, \ summary, error, started_at, ended_at"; @@ -1306,6 +1850,18 @@ fn task_from_row(row: sqlx::sqlite::SqliteRow) -> Result { project_id: read_optional_id(&row, "project_id"), repo_id: read_optional_id(&row, "repo_id"), worktree_id: read_optional_id(&row, "worktree_id"), + block_kind: row + .try_get::, _>("block_kind") + .ok() + .flatten() + .as_deref() + .and_then(BlockKind::parse), + block_reason: row + .try_get::, _>("block_reason") + .ok() + .flatten() + .filter(|value| !value.is_empty()), + block_recurrences: row.try_get("block_recurrences").unwrap_or(0), }) } @@ -1401,7 +1957,10 @@ pub(crate) async fn create_task_schema(pool: &SqlitePool) { last_error TEXT, project_id TEXT, repo_id TEXT, - worktree_id TEXT + worktree_id TEXT, + block_kind TEXT, + block_reason TEXT, + block_recurrences INTEGER NOT NULL DEFAULT 0 ) "#, ) @@ -1429,6 +1988,20 @@ pub(crate) async fn create_task_schema(pool: &SqlitePool) { .await .expect("task_runs schema should be created"); + sqlx::query( + r#" + CREATE TABLE task_dependencies ( + parent_task_number INTEGER NOT NULL, + child_task_number INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (parent_task_number, child_task_number) + ) + "#, + ) + .execute(pool) + .await + .expect("task_dependencies schema should be created"); + sqlx::query( "CREATE TABLE task_number_seq ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -2280,4 +2853,462 @@ mod tests { assert_eq!(updated.assigned_agent_id, "agent-other"); assert_eq!(updated.owner_agent_id, "agent-test"); } + // -- Dependencies ------------------------------------------------------- + + async fn task_at(store: &TaskStore, title: &str, status: TaskStatus) -> Task { + store + .create(self_assigned_input(title, status)) + .await + .expect("should create") + } + + async fn finish(store: &TaskStore, task_number: i64) { + store + .update( + task_number, + UpdateTaskInput { + status: Some(TaskStatus::Done), + ..Default::default() + }, + ) + .await + .expect("update") + .expect("exists"); + } + + #[tokio::test] + async fn link_rejects_self_loops_and_unknown_tasks() { + let store = setup_store().await; + let task = task_at(&store, "lonely", TaskStatus::Backlog).await; + + let self_loop = store + .link_tasks(task.task_number, task.task_number) + .await + .expect_err("a task must not depend on itself"); + assert!(matches!(self_loop, DependencyError::SelfLoop { .. })); + + let unknown = store + .link_tasks(9999, task.task_number) + .await + .expect_err("an edge to a task that does not exist must be refused"); + assert!(matches!(unknown, DependencyError::UnknownTask { .. })); + } + + /// A cycle found while scheduling is a deadlock nobody can see. Reject at + /// link time, and name the path so the caller knows which edge conflicts. + #[tokio::test] + async fn link_rejects_a_three_node_cycle() { + let store = setup_store().await; + let a = task_at(&store, "a", TaskStatus::Backlog).await; + let b = task_at(&store, "b", TaskStatus::Backlog).await; + let c = task_at(&store, "c", TaskStatus::Backlog).await; + + store + .link_tasks(a.task_number, b.task_number) + .await + .expect("a -> b"); + store + .link_tasks(b.task_number, c.task_number) + .await + .expect("b -> c"); + + let error = store + .link_tasks(c.task_number, a.task_number) + .await + .expect_err("c -> a closes the loop"); + match error { + DependencyError::WouldCycle { path } => { + assert_eq!( + path, + vec![a.task_number, b.task_number, c.task_number], + "the rejection must name the path that would close" + ); + } + other => panic!("expected WouldCycle, got {other:?}"), + } + } + + #[tokio::test] + async fn a_child_is_not_claimable_until_every_parent_is_done() { + let store = setup_store().await; + let first = task_at(&store, "parent one", TaskStatus::InProgress).await; + let second = task_at(&store, "parent two", TaskStatus::InProgress).await; + let child = task_at(&store, "child", TaskStatus::Ready).await; + + for parent in [&first, &second] { + store + .link_tasks(parent.task_number, child.task_number) + .await + .expect("link"); + } + + assert!( + store + .claim_next_ready("agent-test") + .await + .expect("claim") + .is_none(), + "a ready task with unfinished parents must not be claimable" + ); + + finish(&store, first.task_number).await; + assert!( + store + .claim_next_ready("agent-test") + .await + .expect("claim") + .is_none(), + "one remaining parent still gates the child" + ); + + finish(&store, second.task_number).await; + let claimed = store + .claim_next_ready("agent-test") + .await + .expect("claim") + .expect("the child becomes claimable once every parent is done"); + assert_eq!(claimed.task_number, child.task_number); + } + + #[tokio::test] + async fn sweep_promotes_a_child_when_the_last_parent_finishes() { + let store = setup_store().await; + let parent = task_at(&store, "parent", TaskStatus::InProgress).await; + let child = task_at(&store, "child", TaskStatus::Backlog).await; + store + .link_tasks(parent.task_number, child.task_number) + .await + .expect("link"); + + let sweep = store.recompute_ready("agent-test").await.expect("sweep"); + assert!(sweep.is_empty(), "nothing to do while the parent runs"); + + finish(&store, parent.task_number).await; + + let sweep = store.recompute_ready("agent-test").await.expect("sweep"); + assert_eq!(sweep.promoted, vec![child.task_number]); + let after = store + .get_by_number(child.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::Ready); + assert!( + after.block_kind.is_none(), + "promotion clears the block reason" + ); + } + + /// Drift repair: an edge added after a task was already promoted, or a + /// parent reopened, must pull the child back out of the ready queue. + #[tokio::test] + async fn sweep_demotes_a_ready_task_that_gained_an_unfinished_parent() { + let store = setup_store().await; + let parent = task_at(&store, "parent", TaskStatus::InProgress).await; + let child = task_at(&store, "child", TaskStatus::Ready).await; + store + .link_tasks(parent.task_number, child.task_number) + .await + .expect("link"); + + let sweep = store.recompute_ready("agent-test").await.expect("sweep"); + assert_eq!(sweep.demoted, vec![child.task_number]); + let after = store + .get_by_number(child.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::Backlog); + assert_eq!(after.block_kind, Some(BlockKind::Dependency)); + } + + /// The reason typed blocks exist. A human parked these knowing the task + /// could not proceed; a sweep that resurrects them hands the worker the + /// same dead end and throws the human's decision away. + #[tokio::test] + async fn sweep_never_resurrects_a_sticky_block() { + for kind in [BlockKind::NeedsInput, BlockKind::Capability] { + let store = setup_store().await; + let task = task_at(&store, "parked", TaskStatus::InProgress).await; + store + .block_task(task.task_number, kind, "needs a human") + .await + .expect("block") + .expect("task exists"); + + let sweep = store.recompute_ready("agent-test").await.expect("sweep"); + assert!(sweep.is_empty(), "{kind} must not be swept back to ready"); + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::Blocked); + assert_eq!(after.block_kind, Some(kind)); + } + } + + /// A dependency wait is ordinary scheduling, not an incident: it rests in + /// the backlog rather than the blocked column a human is meant to triage. + #[tokio::test] + async fn a_dependency_block_rests_in_backlog_not_blocked() { + let store = setup_store().await; + let task = task_at(&store, "waiting", TaskStatus::InProgress).await; + + let outcome = store + .block_task(task.task_number, BlockKind::Dependency, "waiting on #1") + .await + .expect("block") + .expect("task exists"); + + assert_eq!(outcome.status, TaskStatus::Backlog); + assert!(!outcome.escalated); + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::Backlog); + } + + /// The loop breaker: a sweep that unblocks and a worker that re-blocks will + /// otherwise trade a card forever, burning a worker spawn each round. + #[tokio::test] + async fn repeated_blocks_for_the_same_reason_escalate_to_a_human() { + let store = setup_store().await; + let task = task_at(&store, "bouncing", TaskStatus::InProgress).await; + + // The first block is not a recurrence — it is just a block. + let first = store + .block_task(task.task_number, BlockKind::Capability, "no credential") + .await + .expect("block") + .expect("exists"); + assert_eq!(first.recurrences, 0); + assert!(!first.escalated); + assert_eq!(first.status, TaskStatus::Blocked); + + // Hitting the same wall again is tolerated once. + let second = store + .block_task(task.task_number, BlockKind::Capability, "no credential") + .await + .expect("block") + .expect("exists"); + assert_eq!(second.recurrences, 1); + assert!(!second.escalated); + + // Twice over is a loop, not bad luck. + let third = store + .block_task(task.task_number, BlockKind::Capability, "no credential") + .await + .expect("block") + .expect("exists"); + assert_eq!(third.recurrences, BLOCK_RECURRENCE_LIMIT); + assert!(third.escalated); + assert_eq!( + third.status, + TaskStatus::PendingApproval, + "escalation must land somewhere that raises a notification" + ); + } + + /// Different obstacles in sequence are progress, not a loop — escalating + /// those would be noise. + #[tokio::test] + async fn blocks_for_different_reasons_do_not_escalate() { + let store = setup_store().await; + let task = task_at(&store, "varied", TaskStatus::InProgress).await; + + store + .block_task(task.task_number, BlockKind::Capability, "no credential") + .await + .expect("block") + .expect("exists"); + let second = store + .block_task(task.task_number, BlockKind::NeedsInput, "which region?") + .await + .expect("block") + .expect("exists"); + + assert_eq!(second.recurrences, 0); + assert!(!second.escalated); + assert_eq!(second.status, TaskStatus::Blocked); + } + + #[tokio::test] + async fn unblock_lands_in_ready_or_backlog_depending_on_parents() { + let store = setup_store().await; + + let free = task_at(&store, "free", TaskStatus::InProgress).await; + store + .block_task(free.task_number, BlockKind::NeedsInput, "?") + .await + .expect("block") + .expect("exists"); + let released = store + .unblock_task(free.task_number) + .await + .expect("unblock") + .expect("exists"); + assert_eq!(released.status, TaskStatus::Ready); + assert!(released.block_kind.is_none()); + + let parent = task_at(&store, "parent", TaskStatus::InProgress).await; + let gated = task_at(&store, "gated", TaskStatus::InProgress).await; + store + .link_tasks(parent.task_number, gated.task_number) + .await + .expect("link"); + store + .block_task(gated.task_number, BlockKind::NeedsInput, "?") + .await + .expect("block") + .expect("exists"); + let released = store + .unblock_task(gated.task_number) + .await + .expect("unblock") + .expect("exists"); + assert_eq!( + released.status, + TaskStatus::Backlog, + "unblocking must not jump the dependency queue" + ); + } + + #[tokio::test] + async fn fan_in_and_fan_out_edges_round_trip() { + let store = setup_store().await; + let hub = task_at(&store, "hub", TaskStatus::Backlog).await; + let mut parents = Vec::new(); + let mut children = Vec::new(); + + for index in 0..3 { + let parent = task_at(&store, &format!("parent {index}"), TaskStatus::Backlog).await; + store + .link_tasks(parent.task_number, hub.task_number) + .await + .expect("fan-in link"); + parents.push(parent.task_number); + + let child = task_at(&store, &format!("child {index}"), TaskStatus::Backlog).await; + store + .link_tasks(hub.task_number, child.task_number) + .await + .expect("fan-out link"); + children.push(child.task_number); + } + + parents.sort_unstable(); + children.sort_unstable(); + assert_eq!( + store.list_parents(hub.task_number).await.expect("parents"), + parents + ); + assert_eq!( + store + .list_children(hub.task_number) + .await + .expect("children"), + children + ); + assert_eq!( + store + .unfinished_parents(hub.task_number) + .await + .expect("unfinished"), + parents, + "no parent has finished yet" + ); + } + + #[tokio::test] + async fn create_with_depends_on_parks_the_task_and_links_it() { + let store = setup_store().await; + let parent = task_at(&store, "upstream", TaskStatus::InProgress).await; + + let child = store + .create(CreateTaskInput { + depends_on: vec![parent.task_number], + ..self_assigned_input("downstream", TaskStatus::Ready) + }) + .await + .expect("create with dependency"); + + assert_eq!( + child.status, + TaskStatus::Backlog, + "a task created with unmet dependencies must not start out claimable" + ); + assert_eq!(child.block_kind, Some(BlockKind::Dependency)); + assert_eq!( + store + .list_parents(child.task_number) + .await + .expect("parents"), + vec![parent.task_number] + ); + } + + /// A rejected edge must not leave a task behind: a half-linked task is + /// worse than none, because the scheduler would happily run it early. + #[tokio::test] + async fn create_with_a_bad_dependency_leaves_no_orphan() { + let store = setup_store().await; + let before = store + .list(TaskListFilter::default()) + .await + .expect("list") + .len(); + + let result = store + .create(CreateTaskInput { + depends_on: vec![4242], + ..self_assigned_input("doomed", TaskStatus::Ready) + }) + .await; + + assert!(result.is_err(), "an unknown parent must fail the create"); + assert_eq!( + store + .list(TaskListFilter::default()) + .await + .expect("list") + .len(), + before, + "the failed create must not leave a task behind" + ); + } + + /// F1's budget parks a task; F3 says why. Without a kind the sweep cannot + /// tell it apart from a dependency wait. + #[tokio::test] + async fn an_exhausted_budget_parks_the_task_as_transient() { + let store = setup_store().await; + let task = task_at(&store, "doomed", TaskStatus::InProgress).await; + + for round in 0..DEFAULT_FAILURE_LIMIT { + if round > 0 { + store + .claim_next_ready("agent-test") + .await + .expect("claim") + .expect("requeued"); + } + store + .record_failure(task.task_number, TaskRunOutcome::Failed, "boom") + .await + .expect("record failure"); + } + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.status, TaskStatus::Blocked); + assert_eq!(after.block_kind, Some(BlockKind::Transient)); + assert_eq!(after.block_reason.as_deref(), Some("boom")); + } } diff --git a/src/tools/task_create.rs b/src/tools/task_create.rs index ddedfdd45..1a1bcfc5b 100644 --- a/src/tools/task_create.rs +++ b/src/tools/task_create.rs @@ -76,6 +76,9 @@ pub struct TaskCreateArgs { /// Worktree to execute in. #[serde(default)] pub worktree_id: Option, + /// Task numbers that must finish before this one may run. + #[serde(default)] + pub depends_on: Vec, } fn default_priority() -> String { @@ -131,6 +134,11 @@ impl Tool for TaskCreateTool { "worktree_id": { "type": "string", "description": "Worktree to execute in." + }, + "depends_on": { + "type": "array", + "items": {"type": "integer"}, + "description": "Task numbers that must all finish before this task becomes eligible. The task waits in the backlog until then." } }, "required": ["title"] @@ -170,6 +178,7 @@ impl Tool for TaskCreateTool { repo_id: args.repo_id, worktree_id: args.worktree_id, }, + depends_on: args.depends_on, }) .await .map_err(|error| TaskCreateError(format!("{error}")))?; @@ -287,6 +296,7 @@ mod tests { project_id: None, repo_id: None, worktree_id: None, + depends_on: Vec::new(), }) .await .expect("task create should succeed"); From 1cda9776fd7dfc98084a06849d44bf0649e02b90 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:39:50 +0000 Subject: [PATCH 13/69] feat(interface): surface dependencies and typed block reasons on the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board can now answer "why is this not moving" without opening anything. Block-kind chips split the parked tasks into the two groups that matter: `needs_input` and `capability` are styled as demanding attention because nothing moves them until a person acts, while `dependency` and `transient` stay muted because they clear themselves. Dressing up the automatic ones as problems is how people learn to ignore the ones that are. The action follows from the kind. A sticky block gets **Unblock** — the obstacle a human was asked about is gone — and a transient one gets **Retry**, which re-runs the work. Offering "retry" for a missing credential invites someone to run the same task into the same wall. Dependency badges show `←1/3 →2`, and upstream turns amber only when something is still outstanding. That is the difference between "this ran after three others" (history) and "this is waiting on three others" (why nothing is happening); without it the badge is trivia. Edge counts ride along with the task list rather than being fetched per card, because a request per row would defeat the point of a list endpoint. A failure there degrades the badges, not the board. The drawer's dependency section names the specific upstream task numbers and links them. "Blocked" tells you nothing you can act on; "waiting on #133" tells you where to go. Also restores `legal_transitions()`, deleted earlier for having no consumer. It has one now: `GET /tasks/transitions` exports the table the store already enforces, so the dashboard reads it instead of keeping a second copy in TypeScript. Hermes renders a board column its PATCH handler has no branch for, and dragging a card there 400s — that is the failure mode of two copies in two languages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/client.ts | 50 +++ interface/src/api/schema.d.ts | 392 ++++++++++++++++++ interface/src/api/types.ts | 7 + .../src/components/tasks/BlockKindChip.tsx | 74 ++++ .../components/tasks/BlockedTasksSection.tsx | 45 +- .../src/components/tasks/DependencyBadges.tsx | 61 +++ .../components/tasks/DependencySection.tsx | 132 ++++++ interface/src/routes/AgentTasks.tsx | 18 + interface/src/routes/GlobalTasks.tsx | 22 + interface/src/routes/UiLab.tsx | 44 +- src/api/server.rs | 1 + src/api/tasks.rs | 44 +- src/tasks.rs | 7 +- src/tasks/store.rs | 116 ++++++ 14 files changed, 996 insertions(+), 17 deletions(-) create mode 100644 interface/src/components/tasks/BlockKindChip.tsx create mode 100644 interface/src/components/tasks/DependencyBadges.tsx create mode 100644 interface/src/components/tasks/DependencySection.tsx diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 9a7c9b523..e5de1724f 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -663,6 +663,11 @@ export type TaskRunsResponse = Types.TaskRunsResponse; export type TaskListResponse = Types.TaskListResponse; export type TaskResponse = Types.TaskResponse; export type TaskActionResponse = Types.TaskActionResponse; +export type BlockKind = Types.BlockKind; +export type TaskEdgeSummary = Types.TaskEdgeSummary; +export type TaskDependenciesResponse = Types.TaskDependenciesResponse; +export type TaskTransition = Types.TaskTransition; +export type TaskTransitionsResponse = Types.TaskTransitionsResponse; export type TaskItem = Types.Task; export type CreateTaskRequest = Types.CreateTaskRequest; @@ -1723,6 +1728,51 @@ export const api = { /** Per-attempt execution log for a task, oldest first. */ listTaskRuns: (taskNumber: number) => fetchJson(`/tasks/${taskNumber}/runs`), + listTaskDependencies: (taskNumber: number) => + fetchJson(`/tasks/${taskNumber}/dependencies`), + /** The legal status moves, so the board never offers one the API rejects. */ + listTaskTransitions: () => + fetchJson("/tasks/transitions"), + addTaskDependency: async (taskNumber: number, parentTaskNumber: number) => { + const response = await fetch( + `${getApiBase()}/tasks/${taskNumber}/dependencies`, + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({parent_task_number: parentTaskNumber}), + }, + ); + if (!response.ok) { + // The server explains cycles and self-loops in the body; surfacing + // only a status code would strip the one detail that helps. + throw new Error((await response.text()) || `API error: ${response.status}`); + } + return (await response.json()) as TaskDependenciesResponse; + }, + removeTaskDependency: async (taskNumber: number, parentTaskNumber: number) => { + const response = await fetch( + `${getApiBase()}/tasks/${taskNumber}/dependencies/${parentTaskNumber}`, + {method: "DELETE"}, + ); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return (await response.json()) as TaskDependenciesResponse; + }, + blockTask: async (taskNumber: number, kind: BlockKind, reason: string) => { + const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/block`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({kind, reason}), + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return (await response.json()) as TaskResponse; + }, + unblockTask: async (taskNumber: number) => { + const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/unblock`, { + method: "POST", + }); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return (await response.json()) as TaskResponse; + }, /** Clear the failure budget and requeue. Used by the manual retry action. */ retryTask: async (taskNumber: number): Promise => { const response = await fetch(`${getApiBase()}/tasks/${taskNumber}/retry`, { diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 2230e785b..420bae03f 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -2150,6 +2150,27 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/transitions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * `GET /tasks/transitions` — every legal status move. + * @description The dashboard reads this instead of hand-maintaining a second transition + * table in TypeScript, so a board can never offer a move the API rejects. + */ + get: operations["list_task_transitions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{number}": { parameters: { query?: never; @@ -2203,6 +2224,63 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/block": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** `POST /tasks/{number}/block` — park a task with a typed reason. */ + post: operations["block_task"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{number}/dependencies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** `GET /tasks/{number}/dependencies` — the edges around a task. */ + get: operations["list_task_dependencies"]; + put?: never; + /** + * `POST /tasks/{number}/dependencies` — make this task wait on another. + * @description Rejects self-loops, unknown tasks, and any edge that would close a cycle. + * The cycle response names the path so the caller can see which existing edge + * conflicts, rather than being told only that something is wrong. + */ + post: operations["add_task_dependency"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{number}/dependencies/{parent}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** `DELETE /tasks/{number}/dependencies/{parent}` — drop an edge. */ + delete: operations["remove_task_dependency"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{number}/execute": { parameters: { query?: never; @@ -2261,6 +2339,26 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/unblock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * `POST /tasks/{number}/unblock` — release a parked task. + * @description Lands in `ready` when nothing upstream is outstanding, `backlog` otherwise. + */ + post: operations["unblock_task"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tools": { parameters: { query?: never; @@ -2539,6 +2637,10 @@ export interface components { platform: string; runtime_key: string; }; + AddDependencyRequest: { + /** Format: int64 */ + parent_task_number: number; + }; AgentConfigResponse: { browser: components["schemas"]["BrowserSection"]; channel: components["schemas"]["ChannelSection"]; @@ -2689,6 +2791,20 @@ export interface components { BindingsListResponse: { bindings: components["schemas"]["BindingResponse"][]; }; + /** + * @description Why a task is parked. + * + * The kinds differ in how they recover, which is the entire reason the column + * exists. `dependency` and `transient` clear themselves; `needs_input` and + * `capability` are sticky and only an explicit unblock releases them. + * @enum {string} + */ + BlockKind: "dependency" | "needs_input" | "capability" | "transient"; + BlockTaskRequest: { + /** @description dependency | needs_input | capability | transient */ + kind: string; + reason: string; + }; BrowserSection: { close_policy: components["schemas"]["ClosePolicy"]; enabled: boolean; @@ -3047,6 +3163,8 @@ export interface components { /** @description Agent assigned to execute. Defaults to `owner_agent_id`. */ assigned_agent_id?: string | null; created_by?: string | null; + /** @description Task numbers that must finish before this one may run. */ + depends_on?: number[]; description?: string | null; metadata?: unknown; /** @description Agent that owns (created) this task. */ @@ -4093,6 +4211,14 @@ export interface components { approved_at?: string | null; approved_by?: string | null; assigned_agent_id: string; + block_kind?: null | components["schemas"]["BlockKind"]; + /** @description Human-readable explanation shown on the card. */ + block_reason?: string | null; + /** + * Format: int64 + * @description Consecutive blocks for the same reason. See [`BLOCK_RECURRENCE_LIMIT`]. + */ + block_recurrences: number; completed_at?: string | null; /** * Format: int64 @@ -4147,7 +4273,43 @@ export interface components { message: string; success: boolean; }; + TaskDependenciesResponse: { + /** + * @description The subset of `parents` that has not finished yet — what the board + * should name when explaining why a task is not moving. + */ + blocked_by: number[]; + /** @description Tasks waiting on this one. */ + children: number[]; + /** @description Tasks this one waits on. */ + parents: number[]; + }; + /** @description How many edges touch a task, and how many still gate it. */ + TaskEdgeSummary: { + /** + * Format: int64 + * @description The subset of `parents` that has not finished — why the task is waiting. + */ + blocked_by: number; + /** + * Format: int64 + * @description Tasks waiting on this one. + */ + children: number; + /** + * Format: int64 + * @description Tasks this one waits on. + */ + parents: number; + /** Format: int64 */ + task_number: number; + }; TaskListResponse: { + /** + * @description Edge counts for every task that has any. Tasks with no dependencies are + * absent rather than listed with zeroes. + */ + edges: components["schemas"]["TaskEdgeSummary"][]; tasks: components["schemas"]["Task"][]; }; /** @enum {string} */ @@ -4183,6 +4345,13 @@ export interface components { completed: boolean; title: string; }; + TaskTransition: { + from: components["schemas"]["TaskStatus"]; + to: components["schemas"]["TaskStatus"]; + }; + TaskTransitionsResponse: { + transitions: components["schemas"]["TaskTransition"][]; + }; /** @description A unified timeline item combining messages, branch runs, and worker runs. */ TimelineItem: { attachments?: components["schemas"]["SavedAttachmentMeta"][]; @@ -9890,6 +10059,25 @@ export interface operations { }; }; }; + list_task_transitions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskTransitionsResponse"]; + }; + }; + }; + }; get_task: { parameters: { query?: never; @@ -10089,6 +10277,174 @@ export interface operations { }; }; }; + block_task: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BlockTaskRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unknown block kind */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + list_task_dependencies: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskDependenciesResponse"]; + }; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + add_task_dependency: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Child task number */ + number: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddDependencyRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskDependenciesResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Edge would create a cycle */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description A task cannot depend on itself */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + remove_task_dependency: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Child task number */ + number: number; + /** @description Parent task number */ + parent: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskDependenciesResponse"]; + }; + }; + /** @description Edge not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; execute_task: { parameters: { query?: never; @@ -10201,6 +10557,42 @@ export interface operations { }; }; }; + unblock_task: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskResponse"]; + }; + }; + /** @description Task not found or not blocked */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_tools: { parameters: { query?: never; diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index 95709dfd3..13046680a 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -373,6 +373,13 @@ export type TaskActionResponse = components["schemas"]["TaskActionResponse"]; export type TaskRun = components["schemas"]["TaskRun"]; export type TaskRunOutcome = components["schemas"]["TaskRunOutcome"]; export type TaskRunsResponse = components["schemas"]["TaskRunsResponse"]; +export type BlockKind = components["schemas"]["BlockKind"]; +export type TaskEdgeSummary = components["schemas"]["TaskEdgeSummary"]; +export type TaskDependenciesResponse = + components["schemas"]["TaskDependenciesResponse"]; +export type TaskTransition = components["schemas"]["TaskTransition"]; +export type TaskTransitionsResponse = + components["schemas"]["TaskTransitionsResponse"]; // Requests export type CreateTaskRequest = components["schemas"]["CreateTaskRequest"]; diff --git a/interface/src/components/tasks/BlockKindChip.tsx b/interface/src/components/tasks/BlockKindChip.tsx new file mode 100644 index 000000000..906462088 --- /dev/null +++ b/interface/src/components/tasks/BlockKindChip.tsx @@ -0,0 +1,74 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faArrowRightToBracket, + faHourglassHalf, + faKey, + faRotate, +} from "@fortawesome/free-solid-svg-icons"; +import type { BlockKind } from "@/api/client"; + +/** + * Why a task is parked, and — more usefully — whether it is waiting on the + * system or on you. + * + * The two sticky kinds are styled as demanding attention because nothing will + * move them until a human acts. The two automatic kinds are muted: they clear + * themselves, and dressing them up as problems would train people to ignore the + * ones that are. + */ +const STYLES: Record< + BlockKind, + { label: string; icon: typeof faKey; className: string; actionable: boolean } +> = { + needs_input: { + label: "Needs input", + icon: faArrowRightToBracket, + className: "border-status-warning/40 bg-status-warning/10 text-status-warning", + actionable: true, + }, + capability: { + label: "Missing access", + icon: faKey, + className: "border-status-error/40 bg-status-error/10 text-status-error", + actionable: true, + }, + dependency: { + label: "Waiting upstream", + icon: faHourglassHalf, + className: "border-app-line bg-app-box/60 text-ink-faint", + actionable: false, + }, + transient: { + label: "Retrying", + icon: faRotate, + className: "border-app-line bg-app-box/60 text-ink-dull", + actionable: false, + }, +}; + +export interface BlockKindChipProps { + kind?: BlockKind | null; + /** Shown on hover — usually the server's `block_reason`. */ + reason?: string | null; +} + +export function BlockKindChip({ kind, reason }: BlockKindChipProps) { + if (!kind) return null; + const style = STYLES[kind]; + if (!style) return null; + + return ( + + + {style.label} + + ); +} + +/** Whether this block is one only a human can clear. */ +export function isActionableBlock(kind?: BlockKind | null): boolean { + return kind ? (STYLES[kind]?.actionable ?? false) : false; +} diff --git a/interface/src/components/tasks/BlockedTasksSection.tsx b/interface/src/components/tasks/BlockedTasksSection.tsx index 3c505471a..f326dcb59 100644 --- a/interface/src/components/tasks/BlockedTasksSection.tsx +++ b/interface/src/components/tasks/BlockedTasksSection.tsx @@ -1,8 +1,15 @@ import { Badge, Button } from "@spacedrive/primitives"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBan, faChevronDown, faRotateRight } from "@fortawesome/free-solid-svg-icons"; -import type { TaskItem } from "@/api/client"; +import { + faBan, + faChevronDown, + faLockOpen, + faRotateRight, +} from "@fortawesome/free-solid-svg-icons"; +import type { TaskEdgeSummary, TaskItem } from "@/api/client"; import { RepoChip, type BindingNames } from "./RepoChip"; +import { BlockKindChip, isActionableBlock } from "./BlockKindChip"; +import { DependencyBadges } from "./DependencyBadges"; /** * Blocked tasks, rendered locally rather than through `TaskList`. @@ -32,6 +39,10 @@ export interface BlockedTasksSectionProps { retryingTaskNumber?: number | null; resolveAgentName?: (agentId: string) => string; bindingNames?: BindingNames; + /** Edge counts keyed by task number, from the list response. */ + edges?: Map; + /** Release a task a human has resolved. Shown for sticky blocks only. */ + onUnblock?: (task: TaskItem) => void; } export function BlockedTasksSection({ @@ -44,6 +55,8 @@ export function BlockedTasksSection({ retryingTaskNumber, resolveAgentName, bindingNames, + edges, + onUnblock, }: BlockedTasksSectionProps) { if (tasks.length === 0) return null; @@ -94,6 +107,11 @@ export function BlockedTasksSection({ {task.title} + + {task.consecutive_failures > 0 && ( {task.consecutive_failures} @@ -104,12 +122,12 @@ export function BlockedTasksSection({ {/* The reason is why a human is here, but it must not outshout the title — muted, clamped, full text on hover. */} - {task.last_error && ( + {(task.block_reason ?? task.last_error) && (

- {task.last_error} + {task.block_reason ?? task.last_error}

)} @@ -122,7 +140,22 @@ export function BlockedTasksSection({ {/* Always visible: on a queue that exists for human attention, the primary action must not be hidden behind hover. */} - {onRetry && ( + {onUnblock && isActionableBlock(task.block_kind) && ( + + )} + {onRetry && !isActionableBlock(task.block_kind) && ( + ); +} diff --git a/interface/src/routes/AgentTasks.tsx b/interface/src/routes/AgentTasks.tsx index 13ef4c1cc..352c9eb48 100644 --- a/interface/src/routes/AgentTasks.tsx +++ b/interface/src/routes/AgentTasks.tsx @@ -21,6 +21,8 @@ import { getGithubReferences, } from "@/components/TaskUtils"; import {BlockedTasksSection} from "@/components/tasks/BlockedTasksSection"; +import {indexEdges} from "@/components/tasks/DependencyBadges"; +import {DependencySection} from "@/components/tasks/DependencySection"; import {TaskRunHistory} from "@/components/tasks/TaskRunHistory"; const TASK_LIMIT = 200; @@ -50,6 +52,9 @@ export function AgentTasks({agentId}: {agentId: string}) { // `blocked` is not in @spacedrive/ai's TaskStatus union, so those tasks are // split out and rendered by BlockedTasksSection instead. + // Edge counts arrive with the list, so badges cost no extra requests. + const edgesByTask = useMemo(() => indexEdges(data?.edges), [data?.edges]); + const blockedTasks = useMemo( () => (data?.tasks ?? []).filter((t) => t.status === "blocked"), [data], @@ -116,6 +121,14 @@ export function AgentTasks({agentId}: {agentId: string}) { onSuccess: () => void invalidate(), }); + // Distinct from retry: retry re-runs the work, unblock says the obstacle a + // human was asked about is gone. A missing credential is not fixed by + // running the same task again. + const unblockMutation = useMutation({ + mutationFn: (taskNumber: number) => api.unblockTask(taskNumber), + onSuccess: () => void invalidate(), + }); + const handleStatusChange = useCallback( (task: Task, status: UiTaskStatus) => { const t = task as unknown as TaskItem; @@ -232,6 +245,8 @@ export function AgentTasks({agentId}: {agentId: string}) { } onTaskClick={(task) => setActiveTaskId(task.id)} activeTaskId={activeTaskId} + edges={edgesByTask} + onUnblock={(task) => unblockMutation.mutate(task.task_number)} /> setActiveTaskId(null)} /> {/* GitHub metadata (not part of the shared TaskDetail) */} + diff --git a/interface/src/routes/GlobalTasks.tsx b/interface/src/routes/GlobalTasks.tsx index cf9c95f98..0b6d77cdc 100644 --- a/interface/src/routes/GlobalTasks.tsx +++ b/interface/src/routes/GlobalTasks.tsx @@ -27,6 +27,8 @@ import { getGithubReferences, } from "@/components/TaskUtils"; import {BlockedTasksSection} from "@/components/tasks/BlockedTasksSection"; +import {indexEdges} from "@/components/tasks/DependencyBadges"; +import {DependencySection} from "@/components/tasks/DependencySection"; import {TaskRunHistory} from "@/components/tasks/TaskRunHistory"; import {RepoChip} from "@/components/tasks/RepoChip"; import {ALL_REPOS, RepoFilter} from "@/components/tasks/RepoFilter"; @@ -127,6 +129,9 @@ export function GlobalTasks() { const tasks = (data?.tasks ?? []) as unknown as Task[]; + // Edge counts arrive with the list, so badges cost no extra requests. + const edgesByTask = useMemo(() => indexEdges(data?.edges), [data?.edges]); + const {names: bindingNames} = useBindingNames(); const [repoFilter, setRepoFilter] = useState(ALL_REPOS); @@ -218,6 +223,14 @@ export function GlobalTasks() { onSuccess: () => void invalidate(), }); + // Distinct from retry: retry re-runs the work, unblock says the obstacle a + // human was asked about is gone. A missing credential is not fixed by + // running the same task again. + const unblockMutation = useMutation({ + mutationFn: (taskNumber: number) => api.unblockTask(taskNumber), + onSuccess: () => void invalidate(), + }); + const handleStatusChange = useCallback( (task: Task, status: UiTaskStatus) => { const t = task as unknown as TaskItem; @@ -355,6 +368,8 @@ export function GlobalTasks() { activeTaskId={activeTaskId} resolveAgentName={resolveAgentName} bindingNames={bindingNames} + edges={edgesByTask} + onUnblock={(task) => unblockMutation.mutate(task.task_number)} /> + { + const target = rawTasks.find((t) => t.task_number === number); + if (target) setActiveTaskId(target.id); + }} + /> ): TaskItem { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), consecutive_failures: 0, + block_recurrences: 0, ...overrides, }; } @@ -54,7 +57,8 @@ const BLOCKED: TaskItem[] = [ max_retries: 2, project_id: "proj-platform", repo_id: "repo-web", - last_error: + block_kind: "transient", + block_reason: "worker exceeded 1800s wall-clock timeout after 10 segments. Last tool call: shell(`bun run codegen`) — no output for 22m.", }), fixtureTask({ @@ -63,19 +67,31 @@ const BLOCKED: TaskItem[] = [ consecutive_failures: 2, project_id: "proj-platform", repo_id: "repo-auth", - last_error: "capability: no secret named STAGING_DB_URL is available to this agent", + block_kind: "capability", + block_reason: "no secret named STAGING_DB_URL is available to this agent", }), fixtureTask({ task_number: 96, title: "Backfill wiki pages for the ingestion subsystem", priority: "low", - consecutive_failures: 3, - max_retries: 3, - last_error: - "context overflow after 2 compaction attempts: system prompt alone exceeds the context window", + consecutive_failures: 0, + block_kind: "needs_input", + block_reason: + "two candidate page hierarchies — flat per-module, or nested by subsystem. Which?", }), ]; +const EDGES: TaskEdgeSummary[] = [ + {task_number: 142, parents: 3, children: 2, blocked_by: 1}, + {task_number: 138, parents: 0, children: 4, blocked_by: 0}, +]; + +const DEPENDENCIES: TaskDependenciesResponse = { + parents: [128, 131, 133], + children: [151, 152], + blocked_by: [133], +}; + const RUNS: TaskRun[] = [ { id: "r1", @@ -166,6 +182,8 @@ export function UiLab() { retryingTaskNumber={retrying} resolveAgentName={(id) => AGENTS[id] ?? id} bindingNames={BINDING_NAMES} + edges={indexEdges(EDGES)} + onUnblock={() => {}} />
@@ -210,6 +228,18 @@ export function UiLab() {
+
+

+ DependencySection +

+
+ {}} + /> +
+
+

TaskRunHistory diff --git a/src/api/server.rs b/src/api/server.rs index debc1e519..7df19ae15 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -149,6 +149,7 @@ pub fn api_router() -> OpenApiRouter> { tasks::add_task_dependency )) .routes(routes!(tasks::remove_task_dependency)) + .routes(routes!(tasks::list_task_transitions)) .routes(routes!(tasks::block_task)) .routes(routes!(tasks::unblock_task)) // Wiki routes diff --git a/src/api/tasks.rs b/src/api/tasks.rs index 4d4b28d95..2b068c6ab 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -120,6 +120,9 @@ pub(super) struct AssignRequest { #[derive(Serialize, utoipa::ToSchema)] pub(super) struct TaskListResponse { tasks: Vec, + /// Edge counts for every task that has any. Tasks with no dependencies are + /// absent rather than listed with zeroes. + edges: Vec, } #[derive(Serialize, utoipa::ToSchema)] @@ -138,6 +141,17 @@ pub(super) struct TaskRunsResponse { runs: Vec, } +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct TaskTransition { + from: crate::tasks::TaskStatus, + to: crate::tasks::TaskStatus, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct TaskTransitionsResponse { + transitions: Vec, +} + #[derive(Serialize, utoipa::ToSchema)] pub(super) struct TaskDependenciesResponse { /// Tasks this one waits on. @@ -267,7 +281,35 @@ pub(super) async fn list_tasks( StatusCode::INTERNAL_SERVER_ERROR })?; - Ok(Json(TaskListResponse { tasks })) + // Edge counts ride along with the list rather than being fetched per card. + // The board draws a badge on every row; a request per row would defeat the + // point of a list endpoint. A failure here degrades the badges, not the + // board, so it is logged rather than propagated. + let edges = store.dependency_summaries().await.unwrap_or_else(|error| { + tracing::warn!(%error, "failed to summarize task dependencies"); + Vec::new() + }); + + Ok(Json(TaskListResponse { tasks, edges })) +} + +/// `GET /tasks/transitions` — every legal status move. +/// +/// The dashboard reads this instead of hand-maintaining a second transition +/// table in TypeScript, so a board can never offer a move the API rejects. +#[utoipa::path( + get, + path = "/tasks/transitions", + responses((status = 200, body = TaskTransitionsResponse)), + tag = "tasks", +)] +pub(super) async fn list_task_transitions() -> Json { + Json(TaskTransitionsResponse { + transitions: crate::tasks::legal_transitions() + .into_iter() + .map(|(from, to)| TaskTransition { from, to }) + .collect(), + }) } /// `GET /tasks/{number}` — get a task by globally unique number. diff --git a/src/tasks.rs b/src/tasks.rs index c7cdae907..ac6c131b5 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -5,7 +5,8 @@ pub mod store; pub use store::{ BLOCK_RECURRENCE_LIMIT, BlockKind, BlockOutcome, CreateTaskInput, DEFAULT_FAILURE_LIMIT, - DependencyError, FailureDisposition, ReadySweep, Task, TaskBindingPatch, TaskListFilter, - TaskPriority, TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, TaskStore, TaskSubtask, - TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, can_transition, + DependencyError, FailureDisposition, ReadySweep, Task, TaskBindingPatch, TaskEdgeSummary, + TaskListFilter, TaskPriority, TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, + TaskStore, TaskSubtask, TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, + can_transition, legal_transitions, }; diff --git a/src/tasks/store.rs b/src/tasks/store.rs index d47738714..1df2190f6 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -1156,6 +1156,47 @@ impl TaskStore { .map_err(Into::into) } + /// Edge counts for every task that has any, in one query. + /// + /// The board draws a dependency badge on each card. Asking per card would + /// be a request per row on a view whose whole purpose is showing many rows, + /// so this returns the entire adjacency summary and lets the caller index + /// into it. Tasks with no edges are absent rather than present with zeroes. + pub async fn dependency_summaries(&self) -> Result> { + let rows = sqlx::query( + "SELECT task_number, \ + SUM(is_parent_side) AS children, \ + SUM(1 - is_parent_side) AS parents, \ + SUM(blocking) AS blocked_by \ + FROM ( \ + SELECT d.parent_task_number AS task_number, 1 AS is_parent_side, 0 AS blocking \ + FROM task_dependencies d \ + UNION ALL \ + SELECT d.child_task_number AS task_number, 0 AS is_parent_side, \ + CASE WHEN p.status <> 'done' THEN 1 ELSE 0 END AS blocking \ + FROM task_dependencies d \ + JOIN tasks p ON p.task_number = d.parent_task_number \ + ) \ + GROUP BY task_number", + ) + .fetch_all(&self.pool) + .await + .context("failed to summarize task dependencies")?; + + rows.into_iter() + .map(|row| { + Ok(TaskEdgeSummary { + task_number: row + .try_get("task_number") + .context("failed to read edge summary task_number")?, + parents: row.try_get("parents").unwrap_or(0), + children: row.try_get("children").unwrap_or(0), + blocked_by: row.try_get("blocked_by").unwrap_or(0), + }) + }) + .collect() + } + /// Parents of `child` that have not reached a terminal status. pub async fn unfinished_parents(&self, child: i64) -> Result> { sqlx::query_scalar( @@ -1639,6 +1680,18 @@ pub struct BlockOutcome { pub escalated: bool, } +/// How many edges touch a task, and how many still gate it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct TaskEdgeSummary { + pub task_number: i64, + /// Tasks this one waits on. + pub parents: i64, + /// Tasks waiting on this one. + pub children: i64, + /// The subset of `parents` that has not finished — why the task is waiting. + pub blocked_by: i64, +} + /// What a [`TaskStore::recompute_ready`] pass changed. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ReadySweep { @@ -1748,6 +1801,24 @@ pub fn can_transition(current: TaskStatus, next: TaskStatus) -> bool { ) } +/// Every legal `(from, to)` status move. +/// +/// Exported over the API so the dashboard's drag-and-drop consumes the same +/// table the store enforces. Hermes renders a board column its PATCH handler +/// has no branch for, so dragging a card there 400s — the failure mode of +/// keeping two copies of this in two languages. +pub fn legal_transitions() -> Vec<(TaskStatus, TaskStatus)> { + let mut pairs = Vec::new(); + for from in TaskStatus::ALL { + for to in TaskStatus::ALL { + if from != to && can_transition(from, to) { + pairs.push((from, to)); + } + } + } + pairs +} + fn merge_json_object(current: Value, patch: Option) -> Value { let Some(patch) = patch else { return current; @@ -3311,4 +3382,49 @@ mod tests { assert_eq!(after.block_kind, Some(BlockKind::Transient)); assert_eq!(after.block_reason.as_deref(), Some("boom")); } + #[tokio::test] + async fn dependency_summaries_counts_both_directions_in_one_pass() { + let store = setup_store().await; + let done_parent = task_at(&store, "done parent", TaskStatus::InProgress).await; + let live_parent = task_at(&store, "live parent", TaskStatus::InProgress).await; + let hub = task_at(&store, "hub", TaskStatus::Backlog).await; + let child = task_at(&store, "child", TaskStatus::Backlog).await; + let lonely = task_at(&store, "lonely", TaskStatus::Backlog).await; + + for parent in [&done_parent, &live_parent] { + store + .link_tasks(parent.task_number, hub.task_number) + .await + .expect("link"); + } + store + .link_tasks(hub.task_number, child.task_number) + .await + .expect("link"); + finish(&store, done_parent.task_number).await; + + let summaries = store.dependency_summaries().await.expect("summaries"); + let by_number: std::collections::HashMap = summaries + .into_iter() + .map(|summary| (summary.task_number, summary)) + .collect(); + + let hub_summary = by_number.get(&hub.task_number).expect("hub has edges"); + assert_eq!(hub_summary.parents, 2); + assert_eq!(hub_summary.children, 1); + assert_eq!( + hub_summary.blocked_by, 1, + "only the unfinished parent still gates the hub" + ); + + let child_summary = by_number.get(&child.task_number).expect("child has edges"); + assert_eq!(child_summary.parents, 1); + assert_eq!(child_summary.children, 0); + assert_eq!(child_summary.blocked_by, 1); + + assert!( + !by_number.contains_key(&lonely.task_number), + "a task with no edges must be absent, not present with zeroes" + ); + } } From f7f5b8f7c95d4ab526396e48637fdef1dc829426 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:03:41 +0000 Subject: [PATCH 14/69] feat(tasks): typed input/output contracts between tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency edges say *that* one task waits for another. They say nothing about what passes between them, so a downstream task discovers a missing field at runtime by reading a prompt and guessing. This makes the handoff declared and checked. This is the deliberate divergence from Hermes, whose handoff is an untyped `task_runs.metadata` dict: nothing declares what a task needs, nothing validates what it produced, and the failure surfaces as far as possible from its cause. `resolve_inputs` assembles a task's inputs from its bindings — either a JSON Pointer into an upstream task's outputs, or a literal baked into the graph — and checks the result against the declared input schema. Every failure here is a *graph* problem rather than a worker problem: the upstream task did not produce what this one was promised. That distinction matters because it decides who pays. These will block with `dependency` rather than spending the failure budget on an agent that has not run yet. `submit_outputs` rejects an output that misses its declared schema and does not persist it. Rejecting is the entire point — without it a contract is a comment, and a downstream task inherits the gap with no idea who broke it. The rejection carries the validation errors so a worker can correct and retry inside its own segment budget instead of failing the task outright. Same move as Hermes's `HallucinatedCardsError`, generalised from "did you really file those cards" to "does your output match its declared shape". `ContractProblem` is deliberately granular. "Validation failed" sends a person reading prompts to guess; naming the key, the upstream task, and the pointer points straight at the broken edge. A schema that will not compile is itself reported rather than silently skipped, since a task declaring an unusable contract is misconfigured and quietly accepting anything hides it. Resolved inputs are persisted rather than recomputed, so the value a worker actually saw survives a crash and stays readable after upstream tasks change. Every column is nullable and every check is skipped when the schema is absent. A task without a contract stays on exactly the path it was on, which is covered by a regression test since that is nearly every task today. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- Cargo.lock | 230 +++++- Cargo.toml | 1 + .../global/20260802000005_task_contracts.sql | 55 ++ src/tasks/store.rs | 771 +++++++++++++++++- 4 files changed, 1052 insertions(+), 5 deletions(-) create mode 100644 migrations/global/20260802000005_task_contracts.sql diff --git a/Cargo.lock b/Cargo.lock index ef4e572ab..8148359bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -800,6 +800,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit_field" version = "0.10.3" @@ -970,6 +985,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "8.0.2" @@ -2788,6 +2809,9 @@ name = "email_address" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] [[package]] name = "emojis" @@ -2976,6 +3000,17 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-float2" version = "0.2.3" @@ -3090,6 +3125,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.11.1" @@ -3113,6 +3159,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -3137,6 +3189,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs4" version = "0.8.4" @@ -3614,7 +3676,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -3623,6 +3685,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -4546,6 +4619,61 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "508004a5500f2e1f68af048f70feea2de86d35ab115d85716530860822aef397" +dependencies = [ + "ahash", + "async-trait", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "reqwest 0.13.2", + "serde", + "serde_json", + "strum 0.28.0", + "tokio", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8b30cafa78358ae6cd1494a7d6410b89530e28bf567f862c869c667e900d9f" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5526bd381d230af94908d07e6835a33fd82a465e12f5f1e9c81f5c2aa23b3c21" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -4786,7 +4914,7 @@ dependencies = [ "prost-types", "rand 0.9.2", "snafu", - "strum", + "strum 0.26.3", "tokio", "tracing", "xxhash-rust", @@ -5555,6 +5683,12 @@ dependencies = [ "libc", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -5865,6 +5999,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -5891,6 +6039,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -6279,6 +6433,12 @@ dependencies = [ "ureq", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "ownedbytes" version = "0.9.0" @@ -7208,6 +7368,25 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "referencing" +version = "0.49.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af3eb523cce0df0af3c30d624b829b2dabd233172b5bc2615fcd03ceae8f746" +dependencies = [ + "ahash", + "async-trait", + "fluent-uri", + "futures", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -7342,6 +7521,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.13", @@ -8471,6 +8651,7 @@ dependencies = [ "ignore", "imap", "indoc", + "jsonschema", "lance-index", "lancedb", "lettre", @@ -8870,7 +9051,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -8886,6 +9076,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -10027,6 +10229,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -10228,6 +10436,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "v_frame" version = "0.3.9" @@ -10257,6 +10475,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index f6b6a0195..24a4c3a45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,6 +160,7 @@ open = "5.3.3" urlencoding = "2.1.3" url = "2" moka = "0.12.13" +jsonschema = { version = "0.49.3", default-features = false, features = ["resolve-async"] } [features] metrics = ["dep:prometheus"] diff --git a/migrations/global/20260802000005_task_contracts.sql b/migrations/global/20260802000005_task_contracts.sql new file mode 100644 index 000000000..43d514749 --- /dev/null +++ b/migrations/global/20260802000005_task_contracts.sql @@ -0,0 +1,55 @@ +-- Typed input/output contracts between tasks. +-- +-- Dependency edges (the previous migration) say *that* one task waits for +-- another. They say nothing about what actually passes between them, so a +-- downstream task discovers a missing field at runtime by reading a prompt and +-- guessing. These columns make the handoff declared and checked. +-- +-- Every column is nullable and every check is skipped when the schema is +-- absent, so a task without a contract behaves exactly as it does today. +-- Contracts are opt-in and become the norm once there is a builder to author +-- them. + +-- JSON Schema describing what this task requires before it can run. +ALTER TABLE tasks ADD COLUMN input_schema TEXT; + +-- JSON Schema describing what this task must produce to be considered done. +ALTER TABLE tasks ADD COLUMN output_schema TEXT; + +-- The resolved input object, written at claim time once every binding has been +-- read from its source. Persisted rather than recomputed so the value a worker +-- actually saw survives a crash and stays auditable after upstream tasks change. +ALTER TABLE tasks ADD COLUMN inputs TEXT; + +-- The validated output object, written on completion. This is what downstream +-- tasks read from. +ALTER TABLE tasks ADD COLUMN outputs TEXT; + +-- Where each of a task's inputs comes from. +-- +-- A binding is either a pointer into an upstream task's outputs, or a literal +-- baked into the graph. `source_task_number` NULL means literal. Keeping both +-- in one table means the resolver has a single code path and the UI has a +-- single place to show "where does this value come from". +CREATE TABLE IF NOT EXISTS task_input_bindings ( + child_task_number INTEGER NOT NULL, + -- Key in the child's input object. + input_key TEXT NOT NULL, + -- Upstream task to read from. NULL for a literal. + source_task_number INTEGER, + -- RFC 6901 JSON Pointer into that task's outputs, e.g. "/image/tag". + -- Empty string means the whole outputs object. + source_pointer TEXT, + -- JSON literal, used when source_task_number IS NULL. + literal_value TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (child_task_number, input_key) +); + +-- Resolution walks every binding for one child at claim time. +CREATE INDEX IF NOT EXISTS idx_task_bindings_child + ON task_input_bindings(child_task_number); + +-- Completing a task asks which downstream inputs just became resolvable. +CREATE INDEX IF NOT EXISTS idx_task_bindings_source + ON task_input_bindings(source_task_number); diff --git a/src/tasks/store.rs b/src/tasks/store.rs index 1df2190f6..fb6a6ebab 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -248,6 +248,15 @@ pub struct Task { pub block_reason: Option, /// Consecutive blocks for the same reason. See [`BLOCK_RECURRENCE_LIMIT`]. pub block_recurrences: i64, + /// JSON Schema this task's resolved inputs must satisfy before it runs. + pub input_schema: Option, + /// JSON Schema this task's outputs must satisfy to complete. + pub output_schema: Option, + /// Inputs as resolved at claim time. Persisted so the value the worker + /// actually saw survives a crash and stays readable after upstream changes. + pub inputs: Option, + /// Validated outputs. What downstream tasks read from. + pub outputs: Option, } /// The codebase a task acts on. Every field is optional and independently @@ -1417,6 +1426,253 @@ impl TaskStore { Ok(sweep) } + // -- Contracts ---------------------------------------------------------- + + /// Declare where one of a task's inputs comes from. + /// + /// Replaces any existing binding for the same key, so re-pointing an input + /// is one call rather than delete-then-add. + pub async fn set_input_binding(&self, binding: &TaskInputBinding) -> Result<()> { + sqlx::query( + "INSERT INTO task_input_bindings \ + (child_task_number, input_key, source_task_number, source_pointer, literal_value) \ + VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT (child_task_number, input_key) DO UPDATE SET \ + source_task_number = excluded.source_task_number, \ + source_pointer = excluded.source_pointer, \ + literal_value = excluded.literal_value", + ) + .bind(binding.child_task_number) + .bind(&binding.input_key) + .bind(binding.source_task_number) + .bind(&binding.source_pointer) + .bind(binding.literal_value.as_ref().map(|v| v.to_string())) + .execute(&self.pool) + .await + .context("failed to set task input binding")?; + + Ok(()) + } + + pub async fn remove_input_binding( + &self, + child_task_number: i64, + input_key: &str, + ) -> Result { + let result = sqlx::query( + "DELETE FROM task_input_bindings WHERE child_task_number = ? AND input_key = ?", + ) + .bind(child_task_number) + .bind(input_key) + .execute(&self.pool) + .await + .context("failed to remove task input binding")?; + + Ok(result.rows_affected() > 0) + } + + pub async fn list_input_bindings( + &self, + child_task_number: i64, + ) -> Result> { + let rows = sqlx::query( + "SELECT child_task_number, input_key, source_task_number, source_pointer, literal_value \ + FROM task_input_bindings WHERE child_task_number = ? ORDER BY input_key ASC", + ) + .bind(child_task_number) + .fetch_all(&self.pool) + .await + .context("failed to list task input bindings")?; + + rows.into_iter() + .map(|row| { + Ok(TaskInputBinding { + child_task_number: row + .try_get("child_task_number") + .context("failed to read binding child_task_number")?, + input_key: row + .try_get("input_key") + .context("failed to read binding input_key")?, + source_task_number: row.try_get("source_task_number").ok().flatten(), + source_pointer: row.try_get("source_pointer").ok().flatten(), + literal_value: row + .try_get::, _>("literal_value") + .ok() + .flatten() + .and_then(|raw| serde_json::from_str(&raw).ok()), + }) + }) + .collect() + } + + /// Assemble a task's inputs from its bindings and check them against its + /// input schema. + /// + /// Returns the resolved object on success. Every failure mode here is a + /// *graph* problem, not a worker problem — the upstream task has not + /// produced what this one was promised — which is why the caller blocks + /// with `dependency` rather than spending the failure budget on it. + pub async fn resolve_inputs(&self, task_number: i64) -> Result { + let Some(task) = self.get_by_number(task_number).await? else { + return Ok(ContractResolution::Unresolved { + problems: vec![ContractProblem::TaskMissing { task_number }], + }); + }; + + let bindings = self.list_input_bindings(task_number).await?; + + // No contract and no bindings is the overwhelmingly common case today. + // Skipping it entirely keeps existing tasks on exactly the old path. + if bindings.is_empty() && task.input_schema.is_none() { + return Ok(ContractResolution::NotRequired); + } + + let mut resolved = serde_json::Map::new(); + let mut problems = Vec::new(); + + for binding in &bindings { + match self.resolve_one_binding(binding).await { + Ok(value) => { + resolved.insert(binding.input_key.clone(), value); + } + Err(problem) => problems.push(problem), + } + } + + let inputs = Value::Object(resolved); + + if let Some(schema) = &task.input_schema { + problems.extend(validation_problems(schema, &inputs, ContractSide::Input)); + } + + if problems.is_empty() { + Ok(ContractResolution::Resolved { inputs }) + } else { + Ok(ContractResolution::Unresolved { problems }) + } + } + + async fn resolve_one_binding( + &self, + binding: &TaskInputBinding, + ) -> std::result::Result { + // A literal needs no upstream task at all. + let Some(source) = binding.source_task_number else { + return binding + .literal_value + .clone() + .ok_or_else(|| ContractProblem::EmptyLiteral { + input_key: binding.input_key.clone(), + }); + }; + + let task = self + .get_by_number(source) + .await + .map_err(|error| ContractProblem::Storage { + input_key: binding.input_key.clone(), + message: error.to_string(), + })? + .ok_or(ContractProblem::SourceMissing { + input_key: binding.input_key.clone(), + source_task_number: source, + })?; + + let outputs = task.outputs.ok_or(ContractProblem::SourceHasNoOutputs { + input_key: binding.input_key.clone(), + source_task_number: source, + })?; + + let pointer = binding.source_pointer.as_deref().unwrap_or(""); + // RFC 6901: the empty pointer selects the whole document. + let value = if pointer.is_empty() { + Some(&outputs) + } else { + outputs.pointer(pointer) + }; + + value + .cloned() + .ok_or_else(|| ContractProblem::PointerMissed { + input_key: binding.input_key.clone(), + source_task_number: source, + pointer: pointer.to_string(), + }) + } + + /// Persist a task's resolved inputs. + pub async fn set_inputs(&self, task_number: i64, inputs: &Value) -> Result<()> { + sqlx::query( + "UPDATE tasks SET inputs = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ + WHERE task_number = ?", + ) + .bind(inputs.to_string()) + .bind(task_number) + .execute(&self.pool) + .await + .context("failed to persist task inputs")?; + + Ok(()) + } + + /// Check a proposed output against the task's declared output schema and + /// persist it if it fits. + /// + /// Rejecting is the entire point. Without it a contract is a comment: the + /// worker says it produced something, nothing checks, and the downstream + /// task discovers the gap at runtime with no idea who broke it. The + /// rejection carries the validation errors so the worker can correct and + /// retry inside its own budget rather than failing the task. + pub async fn submit_outputs( + &self, + task_number: i64, + outputs: &Value, + ) -> Result { + let Some(task) = self.get_by_number(task_number).await? else { + return Ok(OutputSubmission::TaskMissing); + }; + + if let Some(schema) = &task.output_schema { + let problems = validation_problems(schema, outputs, ContractSide::Output); + if !problems.is_empty() { + return Ok(OutputSubmission::Rejected { problems }); + } + } + + sqlx::query( + "UPDATE tasks SET outputs = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \ + WHERE task_number = ?", + ) + .bind(outputs.to_string()) + .bind(task_number) + .execute(&self.pool) + .await + .context("failed to persist task outputs")?; + + Ok(OutputSubmission::Accepted) + } + + /// Set or clear a task's declared contract. + pub async fn set_contract( + &self, + task_number: i64, + input_schema: Option<&Value>, + output_schema: Option<&Value>, + ) -> Result<()> { + sqlx::query( + "UPDATE tasks SET input_schema = ?, output_schema = ?, \ + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE task_number = ?", + ) + .bind(input_schema.map(|value| value.to_string())) + .bind(output_schema.map(|value| value.to_string())) + .bind(task_number) + .execute(&self.pool) + .await + .context("failed to set task contract")?; + + Ok(()) + } + // -- Attempt log -------------------------------------------------------- /// Open a new attempt row for a task. The attempt number is one past the @@ -1680,6 +1936,124 @@ pub struct BlockOutcome { pub escalated: bool, } +/// Where one of a task's inputs comes from. +/// +/// Either a pointer into an upstream task's outputs, or a literal baked into +/// the graph. `source_task_number` being `None` means literal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct TaskInputBinding { + pub child_task_number: i64, + /// Key in the child's input object. + pub input_key: String, + /// Upstream task to read from. `None` for a literal. + pub source_task_number: Option, + /// RFC 6901 JSON Pointer into that task's outputs. Empty selects the whole + /// outputs object. + pub source_pointer: Option, + /// JSON literal, used when `source_task_number` is `None`. + pub literal_value: Option, +} + +/// Which half of a contract a problem came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ContractSide { + Input, + Output, +} + +/// A specific reason a contract could not be satisfied. +/// +/// Deliberately granular. "Validation failed" sends a human reading prompts and +/// guessing; naming the key and the upstream task that should have supplied it +/// points straight at the broken edge. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema, thiserror::Error)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ContractProblem { + #[error("task #{task_number} does not exist")] + TaskMissing { task_number: i64 }, + #[error("input `{input_key}` is bound to task #{source_task_number}, which does not exist")] + SourceMissing { + input_key: String, + source_task_number: i64, + }, + #[error( + "input `{input_key}` needs output from task #{source_task_number}, which has not produced any yet" + )] + SourceHasNoOutputs { + input_key: String, + source_task_number: i64, + }, + #[error("input `{input_key}`: task #{source_task_number} produced no value at `{pointer}`")] + PointerMissed { + input_key: String, + source_task_number: i64, + pointer: String, + }, + #[error("input `{input_key}` is declared a literal but carries no value")] + EmptyLiteral { input_key: String }, + #[error("{side:?} at `{path}` does not match the declared schema: {message}")] + SchemaViolation { + side: ContractSide, + /// JSON Pointer to the offending value, `""` for the document root. + path: String, + message: String, + }, + #[error("declared {side:?} schema is not a valid JSON Schema: {message}")] + InvalidSchema { side: ContractSide, message: String }, + #[error("input `{input_key}` could not be read: {message}")] + Storage { input_key: String, message: String }, +} + +/// The result of assembling a task's inputs. +#[derive(Debug, Clone, PartialEq)] +pub enum ContractResolution { + /// The task declares no contract and has no bindings — nothing to do. + NotRequired, + /// Inputs assembled and validated. + Resolved { inputs: Value }, + /// The graph cannot supply what this task was promised. + Unresolved { problems: Vec }, +} + +/// The result of a worker submitting its outputs. +#[derive(Debug, Clone, PartialEq)] +pub enum OutputSubmission { + Accepted, + /// The output does not match the declared schema. The worker is told why + /// and may correct it within its own segment budget. + Rejected { + problems: Vec, + }, + TaskMissing, +} + +/// Validate `value` against `schema`, converting failures into problems. +/// +/// A schema that will not compile is itself reported as a problem rather than +/// silently skipped: a task declaring an unusable contract is misconfigured, +/// and quietly accepting anything would hide that. +fn validation_problems(schema: &Value, value: &Value, side: ContractSide) -> Vec { + let validator = match jsonschema::validator_for(schema) { + Ok(validator) => validator, + Err(error) => { + return vec![ContractProblem::InvalidSchema { + side, + message: error.to_string(), + }]; + } + }; + + validator + .iter_errors(value) + .map(|error| ContractProblem::SchemaViolation { + side, + path: error.instance_path().to_string(), + message: error.to_string(), + }) + .collect() +} + /// How many edges touch a task, and how many still gate it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct TaskEdgeSummary { @@ -1770,7 +2144,8 @@ const SELECT_COLUMNS: &str = "SELECT id, task_number, title, description, status owner_agent_id, assigned_agent_id, subtasks, metadata, source_memory_id, worker_id, \ created_by, approved_at, approved_by, created_at, updated_at, completed_at, \ consecutive_failures, max_retries, last_error, project_id, repo_id, worktree_id, \ - block_kind, block_reason, block_recurrences"; + block_kind, block_reason, block_recurrences, \ + input_schema, output_schema, inputs, outputs"; const RUN_SELECT_COLUMNS: &str = "SELECT id, task_number, attempt, worker_id, outcome, \ summary, error, started_at, ended_at"; @@ -1933,9 +2308,33 @@ fn task_from_row(row: sqlx::sqlite::SqliteRow) -> Result { .flatten() .filter(|value| !value.is_empty()), block_recurrences: row.try_get("block_recurrences").unwrap_or(0), + input_schema: read_optional_json(&row, "input_schema"), + output_schema: read_optional_json(&row, "output_schema"), + inputs: read_optional_json(&row, "inputs"), + outputs: read_optional_json(&row, "outputs"), }) } +/// Read a nullable TEXT column holding JSON. +/// +/// A column that fails to parse is treated as absent rather than failing the +/// whole read: one malformed contract should not make a task unreadable. +fn read_optional_json(row: &sqlx::sqlite::SqliteRow, column: &str) -> Option { + let raw = row + .try_get::, _>(column) + .ok() + .flatten() + .filter(|value| !value.is_empty())?; + + match serde_json::from_str(&raw) { + Ok(value) => Some(value), + Err(error) => { + tracing::warn!(%error, column, "task column held invalid JSON — treating as absent"); + None + } + } +} + /// Read a nullable TEXT id, treating the empty string as absent. fn read_optional_id(row: &sqlx::sqlite::SqliteRow, column: &str) -> Option { row.try_get::, _>(column) @@ -2031,7 +2430,11 @@ pub(crate) async fn create_task_schema(pool: &SqlitePool) { worktree_id TEXT, block_kind TEXT, block_reason TEXT, - block_recurrences INTEGER NOT NULL DEFAULT 0 + block_recurrences INTEGER NOT NULL DEFAULT 0, + input_schema TEXT, + output_schema TEXT, + inputs TEXT, + outputs TEXT ) "#, ) @@ -2073,6 +2476,23 @@ pub(crate) async fn create_task_schema(pool: &SqlitePool) { .await .expect("task_dependencies schema should be created"); + sqlx::query( + r#" + CREATE TABLE task_input_bindings ( + child_task_number INTEGER NOT NULL, + input_key TEXT NOT NULL, + source_task_number INTEGER, + source_pointer TEXT, + literal_value TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + PRIMARY KEY (child_task_number, input_key) + ) + "#, + ) + .execute(pool) + .await + .expect("task_input_bindings schema should be created"); + sqlx::query( "CREATE TABLE task_number_seq ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -3427,4 +3847,351 @@ mod tests { "a task with no edges must be absent, not present with zeroes" ); } + // -- Contracts ---------------------------------------------------------- + + fn tag_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["tag"], + "properties": {"tag": {"type": "string"}}, + }) + } + + #[tokio::test] + async fn a_binding_resolves_through_a_json_pointer_into_a_parent_output() { + let store = setup_store().await; + let parent = task_at(&store, "build", TaskStatus::InProgress).await; + let child = task_at(&store, "deploy", TaskStatus::Backlog).await; + + let accepted = store + .submit_outputs( + parent.task_number, + &serde_json::json!({"image": {"tag": "v1.4.2", "digest": "sha256:abc"}}), + ) + .await + .expect("submit"); + assert_eq!(accepted, OutputSubmission::Accepted); + + store + .set_contract(child.task_number, Some(&tag_schema()), None) + .await + .expect("set contract"); + store + .set_input_binding(&TaskInputBinding { + child_task_number: child.task_number, + input_key: "tag".into(), + source_task_number: Some(parent.task_number), + source_pointer: Some("/image/tag".into()), + literal_value: None, + }) + .await + .expect("bind"); + + let resolution = store + .resolve_inputs(child.task_number) + .await + .expect("resolve"); + assert_eq!( + resolution, + ContractResolution::Resolved { + inputs: serde_json::json!({"tag": "v1.4.2"}) + } + ); + } + + #[tokio::test] + async fn a_literal_binding_needs_no_upstream_task() { + let store = setup_store().await; + let task = task_at(&store, "deploy", TaskStatus::Backlog).await; + + store + .set_input_binding(&TaskInputBinding { + child_task_number: task.task_number, + input_key: "environment".into(), + source_task_number: None, + source_pointer: None, + literal_value: Some(serde_json::json!("staging")), + }) + .await + .expect("bind"); + + let resolution = store + .resolve_inputs(task.task_number) + .await + .expect("resolve"); + assert_eq!( + resolution, + ContractResolution::Resolved { + inputs: serde_json::json!({"environment": "staging"}) + } + ); + } + + /// The failure that actually happens in a hand-built graph: the edge exists + /// but the upstream task never produced the field. The problem must name + /// the key, the task, and the pointer — "validation failed" would send + /// somebody reading prompts to guess. + #[tokio::test] + async fn an_unresolved_pointer_names_the_key_task_and_path() { + let store = setup_store().await; + let parent = task_at(&store, "build", TaskStatus::InProgress).await; + let child = task_at(&store, "deploy", TaskStatus::Backlog).await; + + store + .submit_outputs( + parent.task_number, + &serde_json::json!({"digest": "sha256:abc"}), + ) + .await + .expect("submit"); + store + .set_input_binding(&TaskInputBinding { + child_task_number: child.task_number, + input_key: "tag".into(), + source_task_number: Some(parent.task_number), + source_pointer: Some("/image/tag".into()), + literal_value: None, + }) + .await + .expect("bind"); + + let resolution = store + .resolve_inputs(child.task_number) + .await + .expect("resolve"); + match resolution { + ContractResolution::Unresolved { problems } => { + assert_eq!( + problems, + vec![ContractProblem::PointerMissed { + input_key: "tag".into(), + source_task_number: parent.task_number, + pointer: "/image/tag".into(), + }] + ); + } + other => panic!("expected Unresolved, got {other:?}"), + } + } + + #[tokio::test] + async fn a_parent_that_has_not_produced_output_yet_is_reported_as_such() { + let store = setup_store().await; + let parent = task_at(&store, "build", TaskStatus::InProgress).await; + let child = task_at(&store, "deploy", TaskStatus::Backlog).await; + + store + .set_input_binding(&TaskInputBinding { + child_task_number: child.task_number, + input_key: "tag".into(), + source_task_number: Some(parent.task_number), + source_pointer: Some("/tag".into()), + literal_value: None, + }) + .await + .expect("bind"); + + match store + .resolve_inputs(child.task_number) + .await + .expect("resolve") + { + ContractResolution::Unresolved { problems } => assert_eq!( + problems, + vec![ContractProblem::SourceHasNoOutputs { + input_key: "tag".into(), + source_task_number: parent.task_number, + }] + ), + other => panic!("expected Unresolved, got {other:?}"), + } + } + + #[tokio::test] + async fn inputs_that_miss_the_declared_schema_are_unresolved() { + let store = setup_store().await; + let task = task_at(&store, "deploy", TaskStatus::Backlog).await; + + store + .set_contract(task.task_number, Some(&tag_schema()), None) + .await + .expect("set contract"); + store + .set_input_binding(&TaskInputBinding { + child_task_number: task.task_number, + input_key: "tag".into(), + source_task_number: None, + source_pointer: None, + literal_value: Some(serde_json::json!(42)), + }) + .await + .expect("bind"); + + match store + .resolve_inputs(task.task_number) + .await + .expect("resolve") + { + ContractResolution::Unresolved { problems } => { + assert!( + problems.iter().any(|p| matches!( + p, + ContractProblem::SchemaViolation { + side: ContractSide::Input, + .. + } + )), + "a number where a string was declared must be a schema violation: {problems:?}" + ); + } + other => panic!("expected Unresolved, got {other:?}"), + } + } + + /// The whole point of the contract. Without rejection it is a comment: the + /// worker claims it produced something, nothing checks, and the downstream + /// task discovers the gap at runtime with no idea who broke it. + #[tokio::test] + async fn an_output_that_misses_its_schema_is_rejected_and_not_persisted() { + let store = setup_store().await; + let task = task_at(&store, "build", TaskStatus::InProgress).await; + + store + .set_contract(task.task_number, None, Some(&tag_schema())) + .await + .expect("set contract"); + + let submission = store + .submit_outputs( + task.task_number, + &serde_json::json!({"digest": "sha256:abc"}), + ) + .await + .expect("submit"); + + match submission { + OutputSubmission::Rejected { problems } => { + assert!(!problems.is_empty()); + assert!( + problems[0].to_string().contains("tag"), + "the rejection must say what is missing: {problems:?}" + ); + } + other => panic!("expected Rejected, got {other:?}"), + } + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert!( + after.outputs.is_none(), + "a rejected output must not be readable by downstream tasks" + ); + } + + #[tokio::test] + async fn a_valid_output_is_accepted_and_persisted() { + let store = setup_store().await; + let task = task_at(&store, "build", TaskStatus::InProgress).await; + store + .set_contract(task.task_number, None, Some(&tag_schema())) + .await + .expect("set contract"); + + let submission = store + .submit_outputs(task.task_number, &serde_json::json!({"tag": "v1.4.2"})) + .await + .expect("submit"); + assert_eq!(submission, OutputSubmission::Accepted); + + let after = store + .get_by_number(task.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(after.outputs, Some(serde_json::json!({"tag": "v1.4.2"}))); + } + + /// A task declaring an unusable schema is misconfigured. Quietly accepting + /// anything would hide that, so it surfaces as a problem in its own right. + #[tokio::test] + async fn an_invalid_schema_is_reported_rather_than_ignored() { + let store = setup_store().await; + let task = task_at(&store, "build", TaskStatus::InProgress).await; + store + .set_contract( + task.task_number, + None, + Some(&serde_json::json!({"type": "not-a-real-type"})), + ) + .await + .expect("set contract"); + + match store + .submit_outputs(task.task_number, &serde_json::json!({"anything": true})) + .await + .expect("submit") + { + OutputSubmission::Rejected { problems } => assert!( + matches!(problems.as_slice(), [ContractProblem::InvalidSchema { .. }]), + "expected InvalidSchema, got {problems:?}" + ), + other => panic!("expected Rejected, got {other:?}"), + } + } + + /// Regression guard: the overwhelming majority of tasks have no contract + /// and must stay on exactly the path they were on before F4. + #[tokio::test] + async fn a_task_without_a_contract_is_unaffected() { + let store = setup_store().await; + let task = task_at(&store, "ordinary", TaskStatus::InProgress).await; + + assert_eq!( + store + .resolve_inputs(task.task_number) + .await + .expect("resolve"), + ContractResolution::NotRequired + ); + assert_eq!( + store + .submit_outputs(task.task_number, &serde_json::json!({"whatever": 1})) + .await + .expect("submit"), + OutputSubmission::Accepted, + "with no declared schema any output is acceptable" + ); + } + + #[tokio::test] + async fn rebinding_an_input_replaces_rather_than_duplicates() { + let store = setup_store().await; + let task = task_at(&store, "deploy", TaskStatus::Backlog).await; + + for value in ["staging", "production"] { + store + .set_input_binding(&TaskInputBinding { + child_task_number: task.task_number, + input_key: "environment".into(), + source_task_number: None, + source_pointer: None, + literal_value: Some(serde_json::json!(value)), + }) + .await + .expect("bind"); + } + + let bindings = store + .list_input_bindings(task.task_number) + .await + .expect("list"); + assert_eq!(bindings.len(), 1); + assert_eq!( + bindings[0].literal_value, + Some(serde_json::json!("production")) + ); + } } From 189425267405fd0acf5803e7c91ee3517c1258cc Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:25:14 +0000 Subject: [PATCH 15/69] feat(tasks): enforce contracts at claim and completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage from the previous commit only becomes load-bearing once something actually reads and writes it. This wires both ends. At claim time, `pickup_one_ready_task` resolves the input contract before spending anything on a worker. A task whose inputs cannot be assembled parks as a `dependency` block rather than failing: the upstream task never produced what this one was promised, the agent has not run, and charging its failure budget for a broken edge would eventually park a task that was never at fault. The block clears itself once the upstream lands. A storage error during resolution is treated differently from an unresolved input — that one is our bug, not the graph's, so the run proceeds rather than punishing the task for it. Resolved inputs and the required output shape go into the worker prompt as data, not prose, because the return value is checked. A worker left to infer the shape gets its completion rejected and spends a segment recovering. `task_complete` is the tool that submits it. Rejection is returned as an error rather than a success carrying a warning: a tool result the model can read past is one it will read past, and then the contract has bought nothing. The message names what is wrong and says the call can be retried, so a formatting slip costs a segment instead of the task. Output submission is scoped to the worker's own task, exactly like `task_update`. A worker writing outputs onto someone else's task would be laundering invented values into a downstream task's inputs — which is the specific failure the contract exists to prevent. `GET /tasks/{n}/contract` resolves live rather than replaying the last claim, so the view shows what the task would receive if it ran now. A graph that has drifted since the last attempt is exactly the case worth seeing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- src/agent/cortex.rs | 83 ++++++++++ src/api/server.rs | 2 + src/api/tasks.rs | 211 ++++++++++++++++++++++++++ src/tasks.rs | 9 +- src/tasks/store.rs | 55 +++++++ src/tools.rs | 8 +- src/tools/task_complete.rs | 299 +++++++++++++++++++++++++++++++++++++ 7 files changed, 662 insertions(+), 5 deletions(-) create mode 100644 src/tools/task_complete.rs diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index 4e2a7f5cb..b12616473 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -4400,6 +4400,68 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho ) .map_err(|error| anyhow::anyhow!("failed to append tool-use enforcement: {error}"))?; + // Resolve the input contract before spending anything on a worker. + // + // A task whose inputs cannot be assembled is a broken *graph*, not a failed + // agent — the upstream task never produced what this one was promised, and + // the worker has not run yet. So it parks as a `dependency` block, which + // costs no failure budget and clears itself once the upstream lands. + let resolved_inputs = match deps.task_store.resolve_inputs(task.task_number).await { + Ok(crate::tasks::ContractResolution::NotRequired) => None, + Ok(crate::tasks::ContractResolution::Resolved { inputs }) => { + if let Err(error) = deps.task_store.set_inputs(task.task_number, &inputs).await { + tracing::warn!(%error, task_number = task.task_number, "failed to persist resolved inputs"); + } + Some(inputs) + } + Ok(crate::tasks::ContractResolution::Unresolved { problems }) => { + let reason = problems + .iter() + .map(|problem| problem.to_string()) + .collect::>() + .join("; "); + + logger.log( + "task_pickup_inputs_unresolved", + &format!( + "Task #{} cannot start — its inputs are not available: {reason}", + task.task_number + ), + Some(serde_json::json!({ + "task_number": task.task_number, + "problems": problems, + })), + ); + + if let Err(error) = deps + .task_store + .block_task( + task.task_number, + crate::tasks::BlockKind::Dependency, + &reason, + ) + .await + { + tracing::warn!(%error, task_number = task.task_number, "failed to park task with unresolved inputs"); + } + + let _ = deps.event_tx.send(ProcessEvent::TaskUpdated { + agent_id: deps.agent_id.clone(), + task_number: task.task_number, + status: TaskStatus::Backlog.as_str().to_string(), + action: "updated".to_string(), + }); + return Ok(()); + } + Err(error) => { + // A storage failure here is our bug, not the graph's. Blocking the + // task would punish it for our problem, so the run proceeds without + // resolved inputs and the worker sees the task as contract-free. + tracing::warn!(%error, task_number = task.task_number, "failed to resolve task inputs"); + None + } + }; + let mut task_prompt = format!("Execute task #{}: {}", task.task_number, task.title); if let Some(description) = &task.description { task_prompt.push_str("\n\nDescription:\n"); @@ -4413,6 +4475,27 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho } } + // The contract goes in the prompt as data, not prose. The worker is told + // exactly what it was given and exactly what shape it must return, because + // the return value is checked — a worker that guesses the shape gets its + // completion rejected and has to spend a segment recovering. + if let Some(inputs) = &resolved_inputs { + task_prompt.push_str("\n\nInputs (resolved from upstream tasks):\n"); + task_prompt + .push_str(&serde_json::to_string_pretty(inputs).unwrap_or_else(|_| inputs.to_string())); + } + if let Some(schema) = &task.output_schema { + task_prompt.push_str("\n\nRequired output shape (JSON Schema):\n"); + task_prompt + .push_str(&serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string())); + task_prompt.push_str( + "\n\nWhen the work is done, call `task_complete` with an `outputs` object \ + matching that schema. It is validated: if it does not match you will be \ + told what is wrong and must correct it. Downstream tasks read these values, \ + so do not invent them.", + ); + } + // A task bound to a project/repo/worktree runs *in* that directory — the // worker's shell and file tools are rooted there, not merely told about it. // diff --git a/src/api/server.rs b/src/api/server.rs index 7df19ae15..f53de2736 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -150,6 +150,8 @@ pub fn api_router() -> OpenApiRouter> { )) .routes(routes!(tasks::remove_task_dependency)) .routes(routes!(tasks::list_task_transitions)) + .routes(routes!(tasks::get_task_contract, tasks::set_task_contract)) + .routes(routes!(tasks::set_task_binding, tasks::remove_task_binding)) .routes(routes!(tasks::block_task)) .routes(routes!(tasks::unblock_task)) // Wiki routes diff --git a/src/api/tasks.rs b/src/api/tasks.rs index 2b068c6ab..796874cae 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -168,6 +168,42 @@ pub(super) struct AddDependencyRequest { parent_task_number: i64, } +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct TaskContractResponse { + input_schema: Option, + output_schema: Option, + /// Inputs as they were resolved at the last claim. + inputs: Option, + outputs: Option, + /// What the bindings resolve to right now, which may differ from `inputs` + /// if the graph changed since the last attempt. + resolved_inputs: Option, + bindings: Vec, + /// Why resolution fails, if it does. Empty when the contract is satisfied. + problems: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub(super) struct SetContractRequest { + #[serde(default)] + input_schema: Option, + #[serde(default)] + output_schema: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub(super) struct SetBindingRequest { + /// Upstream task to read from. Omit for a literal. + #[serde(default)] + source_task_number: Option, + /// RFC 6901 JSON Pointer into that task's outputs. + #[serde(default)] + source_pointer: Option, + /// Literal JSON value, used when no source task is given. + #[serde(default)] + literal_value: Option, +} + #[derive(Deserialize, utoipa::ToSchema)] pub(super) struct BlockTaskRequest { /// dependency | needs_input | capability | transient @@ -966,3 +1002,178 @@ pub(super) async fn unblock_task( emit_task_event(&state, &task, "updated"); Ok(Json(TaskResponse { task })) } + +/// `GET /tasks/{number}/contract` — the declared contract, its bindings, and +/// what those bindings currently resolve to. +/// +/// Resolution runs live rather than being read back from the last claim, so the +/// page shows what the task *would* get if it ran now. A graph that has drifted +/// since the last attempt is exactly the case worth seeing. +#[utoipa::path( + get, + path = "/tasks/{number}/contract", + params(("number" = i64, Path, description = "Task number")), + responses( + (status = 200, body = TaskContractResponse), + (status = 404, description = "Task not found"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn get_task_contract( + State(state): State>, + Path(number): Path, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + let task = store + .get_by_number(number) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to read task for contract"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + let bindings = store.list_input_bindings(number).await.map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to list input bindings"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let (resolved_inputs, problems) = match store.resolve_inputs(number).await { + Ok(crate::tasks::ContractResolution::Resolved { inputs }) => (Some(inputs), Vec::new()), + Ok(crate::tasks::ContractResolution::Unresolved { problems }) => (None, problems), + Ok(crate::tasks::ContractResolution::NotRequired) => (None, Vec::new()), + Err(error) => { + tracing::warn!(%error, task_number = number, "failed to resolve inputs for contract view"); + (None, Vec::new()) + } + }; + + Ok(Json(TaskContractResponse { + input_schema: task.input_schema, + output_schema: task.output_schema, + inputs: task.inputs, + outputs: task.outputs, + resolved_inputs, + bindings, + problems, + })) +} + +/// `PUT /tasks/{number}/contract` — declare what a task needs and produces. +#[utoipa::path( + put, + path = "/tasks/{number}/contract", + params(("number" = i64, Path, description = "Task number")), + request_body = SetContractRequest, + responses( + (status = 200, body = TaskContractResponse), + (status = 404, description = "Task not found"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn set_task_contract( + State(state): State>, + Path(number): Path, + Json(request): Json, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + store + .set_contract( + number, + request.input_schema.as_ref(), + request.output_schema.as_ref(), + ) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to set task contract"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + get_task_contract(State(state), Path(number)).await +} + +/// `PUT /tasks/{number}/bindings/{key}` — point one input at its source. +#[utoipa::path( + put, + path = "/tasks/{number}/bindings/{key}", + params( + ("number" = i64, Path, description = "Task number"), + ("key" = String, Path, description = "Input key"), + ), + request_body = SetBindingRequest, + responses( + (status = 200, body = TaskContractResponse), + (status = 422, description = "A binding must name either a source task or a literal"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn set_task_binding( + State(state): State>, + Path((number, key)): Path<(i64, String)>, + Json(request): Json, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + // A binding that names neither a source nor a literal resolves to nothing + // and would fail silently at claim time — reject it while somebody is + // looking at it. + if request.source_task_number.is_none() && request.literal_value.is_none() { + return Err(StatusCode::UNPROCESSABLE_ENTITY); + } + + store + .set_input_binding(&crate::tasks::TaskInputBinding { + child_task_number: number, + input_key: key, + source_task_number: request.source_task_number, + source_pointer: request.source_pointer, + literal_value: request.literal_value, + }) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to set input binding"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + get_task_contract(State(state), Path(number)).await +} + +/// `DELETE /tasks/{number}/bindings/{key}` — unbind one input. +#[utoipa::path( + delete, + path = "/tasks/{number}/bindings/{key}", + params( + ("number" = i64, Path, description = "Task number"), + ("key" = String, Path, description = "Input key"), + ), + responses( + (status = 200, body = TaskContractResponse), + (status = 404, description = "Binding not found"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn remove_task_binding( + State(state): State>, + Path((number, key)): Path<(i64, String)>, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + let removed = store + .remove_input_binding(number, &key) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to remove input binding"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + if !removed { + return Err(StatusCode::NOT_FOUND); + } + + get_task_contract(State(state), Path(number)).await +} diff --git a/src/tasks.rs b/src/tasks.rs index ac6c131b5..708a43e02 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -4,9 +4,10 @@ pub mod migration; pub mod store; pub use store::{ - BLOCK_RECURRENCE_LIMIT, BlockKind, BlockOutcome, CreateTaskInput, DEFAULT_FAILURE_LIMIT, - DependencyError, FailureDisposition, ReadySweep, Task, TaskBindingPatch, TaskEdgeSummary, + BLOCK_RECURRENCE_LIMIT, BlockKind, BlockOutcome, ContractProblem, ContractResolution, + ContractSide, CreateTaskInput, DEFAULT_FAILURE_LIMIT, DependencyError, FailureDisposition, + OutputSubmission, ReadySweep, Task, TaskBindingPatch, TaskEdgeSummary, TaskInputBinding, TaskListFilter, TaskPriority, TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, - TaskStore, TaskSubtask, TaskUpdateResult, UpdateTaskInput, WorkerTaskUpdateResult, - can_transition, legal_transitions, + TaskStore, TaskSubtask, TaskUpdateResult, UpdateTaskInput, WorkerOutputSubmission, + WorkerTaskUpdateResult, can_transition, legal_transitions, }; diff --git a/src/tasks/store.rs b/src/tasks/store.rs index fb6a6ebab..3efcce0b5 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -1652,6 +1652,49 @@ impl TaskStore { Ok(OutputSubmission::Accepted) } + /// Submit outputs for the task a specific worker is executing. + /// + /// Scoped exactly like `update_worker_task`: a worker may only complete the + /// task it was spawned for. Without this a worker could write outputs onto + /// another agent's task, and downstream tasks would consume them as fact. + pub async fn submit_worker_outputs( + &self, + worker_id: &str, + task_number: i64, + outputs: &Value, + ) -> Result { + let owns: Option = sqlx::query_scalar( + "SELECT task_number FROM tasks WHERE worker_id = ? AND task_number = ?", + ) + .bind(worker_id) + .bind(task_number) + .fetch_optional(&self.pool) + .await + .context("failed to check worker task ownership")?; + + if owns.is_none() { + let assigned: Option = sqlx::query_scalar( + "SELECT task_number FROM tasks WHERE worker_id = ? \ + ORDER BY task_number DESC LIMIT 1", + ) + .bind(worker_id) + .fetch_optional(&self.pool) + .await + .context("failed to look up the worker's own task")?; + + return Ok(match assigned { + Some(assigned_task_number) => WorkerOutputSubmission::WrongTask { + assigned_task_number, + }, + None => WorkerOutputSubmission::NotAssigned, + }); + } + + Ok(WorkerOutputSubmission::Submitted( + self.submit_outputs(task_number, outputs).await?, + )) + } + /// Set or clear a task's declared contract. pub async fn set_contract( &self, @@ -2016,6 +2059,18 @@ pub enum ContractResolution { Unresolved { problems: Vec }, } +/// The result of a worker submitting outputs for its own task. +#[derive(Debug, Clone, PartialEq)] +pub enum WorkerOutputSubmission { + Submitted(OutputSubmission), + /// The worker is not bound to any task. + NotAssigned, + /// The worker tried to write to a task other than its own. + WrongTask { + assigned_task_number: i64, + }, +} + /// The result of a worker submitting its outputs. #[derive(Debug, Clone, PartialEq)] pub enum OutputSubmission { diff --git a/src/tools.rs b/src/tools.rs index 620df58e8..8decb94de 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -20,6 +20,7 @@ //! **Worker ToolServer** (one per worker, created at spawn time): //! - `shell`, `file_read`/`file_write`/`file_edit`/`file_list` — stateless, registered at creation //! - `task_update` — scoped to the worker's assigned task +//! - `task_complete` — structured, schema-validated result for that task //! - `set_status` — per-worker instance, registered at creation //! //! **Cortex ToolServer** (one per agent): @@ -60,6 +61,7 @@ pub mod skills_search; pub mod skip; pub mod spacebot_docs; pub mod spawn_worker; +pub mod task_complete; pub mod task_create; pub mod task_list; pub mod task_update; @@ -149,6 +151,9 @@ pub use spacebot_docs::{ pub use spawn_worker::{ DetachedSpawnWorkerTool, SpawnWorkerArgs, SpawnWorkerError, SpawnWorkerOutput, SpawnWorkerTool, }; +pub use task_complete::{ + TaskCompleteArgs, TaskCompleteError, TaskCompleteOutput, TaskCompleteTool, +}; pub use task_create::{TaskCreateArgs, TaskCreateError, TaskCreateOutput, TaskCreateTool}; pub use task_list::{TaskListArgs, TaskListError, TaskListOutput, TaskListTool}; pub use task_update::{TaskUpdateArgs, TaskUpdateError, TaskUpdateOutput, TaskUpdateTool}; @@ -962,10 +967,11 @@ pub fn create_worker_tool_server( ), ) .tool(TaskUpdateTool::for_worker( - task_store, + task_store.clone(), agent_id.clone(), worker_id, )) + .tool(TaskCompleteTool::new(task_store, worker_id)) .tool({ let mut status_tool = SetStatusTool::new(agent_id.clone(), worker_id, channel_id, event_tx.clone()); diff --git a/src/tools/task_complete.rs b/src/tools/task_complete.rs new file mode 100644 index 000000000..2cf227b28 --- /dev/null +++ b/src/tools/task_complete.rs @@ -0,0 +1,299 @@ +//! Structured completion for task workers. +//! +//! `task_update` carries prose and `set_outcome` carries a verdict; neither +//! carries a value a downstream task can read. This does: the worker submits an +//! `outputs` object, it is checked against the task's declared `output_schema`, +//! and a mismatch is rejected with the reasons rather than accepted and +//! discovered later by whatever consumes it. +//! +//! Rejection returns an error the worker can act on, so it corrects and retries +//! inside its own segment budget. That is deliberately cheaper than failing the +//! task: a wrong shape is usually a formatting slip, not a failed job. + +use crate::WorkerId; +use crate::tasks::{OutputSubmission, TaskStore, WorkerOutputSubmission}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct TaskCompleteTool { + task_store: Arc, + worker_id: WorkerId, +} + +impl TaskCompleteTool { + pub fn new(task_store: Arc, worker_id: WorkerId) -> Self { + Self { + task_store, + worker_id, + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("task_complete failed: {0}")] +pub struct TaskCompleteError(String); + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TaskCompleteArgs { + pub task_number: i64, + /// Human-readable account of what was done. + pub summary: String, + /// Machine-readable result, validated against the task's output schema. + pub outputs: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct TaskCompleteOutput { + pub success: bool, + pub task_number: i64, + pub message: String, +} + +impl Tool for TaskCompleteTool { + const NAME: &'static str = "task_complete"; + + type Error = TaskCompleteError; + type Args = TaskCompleteArgs; + type Output = TaskCompleteOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Submit the structured result of your assigned task. The `outputs` \ + object is validated against the task's declared output schema and read by \ + downstream tasks, so every value must be one you actually produced. If it does \ + not match the schema you will be told what is wrong and can call this again." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task_number": { + "type": "integer", + "description": "Your assigned task number." + }, + "summary": { + "type": "string", + "description": "What you did, for a human reading the board." + }, + "outputs": { + "type": "object", + "description": "The task's result, matching the output schema given in your instructions." + } + }, + "required": ["task_number", "summary", "outputs"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let submission = self + .task_store + .submit_worker_outputs(&self.worker_id.to_string(), args.task_number, &args.outputs) + .await + .map_err(|error| TaskCompleteError(format!("{error}")))?; + + match submission { + WorkerOutputSubmission::Submitted(OutputSubmission::Accepted) => { + Ok(TaskCompleteOutput { + success: true, + task_number: args.task_number, + message: format!("Recorded outputs for task #{}.", args.task_number), + }) + } + WorkerOutputSubmission::Submitted(OutputSubmission::Rejected { problems }) => { + // An error rather than a success-with-warning: a tool result the + // model can read past is one it will read past, and the whole + // value of the contract is that this does not silently pass. + let detail = problems + .iter() + .map(|problem| problem.to_string()) + .collect::>() + .join("; "); + Err(TaskCompleteError(format!( + "outputs do not match the task's declared output schema: {detail}. \ + Correct the object and call task_complete again." + ))) + } + WorkerOutputSubmission::Submitted(OutputSubmission::TaskMissing) => Err( + TaskCompleteError(format!("task #{} does not exist", args.task_number)), + ), + WorkerOutputSubmission::WrongTask { + assigned_task_number, + } => Err(TaskCompleteError(format!( + "you are assigned to task #{assigned_task_number}, not #{}", + args.task_number + ))), + WorkerOutputSubmission::NotAssigned => Err(TaskCompleteError( + "you are not assigned to a task, so there is nothing to complete".to_string(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tasks::{CreateTaskInput, TaskStatus, UpdateTaskInput}; + use sqlx::sqlite::SqlitePoolOptions; + + async fn store_with_task(worker_id: WorkerId) -> (Arc, i64) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite should connect"); + crate::tasks::store::create_task_schema(&pool).await; + sqlx::query("INSERT INTO task_number_seq (id, next_number) VALUES (1, 1)") + .execute(&pool) + .await + .expect("seed sequence"); + + let store = Arc::new(TaskStore::new(pool)); + let task = store + .create(CreateTaskInput { + owner_agent_id: "agent-1".into(), + assigned_agent_id: "agent-1".into(), + title: "build".into(), + status: TaskStatus::InProgress, + created_by: "test".into(), + ..Default::default() + }) + .await + .expect("create task"); + + store + .update( + task.task_number, + UpdateTaskInput { + worker_id: Some(worker_id.to_string()), + ..Default::default() + }, + ) + .await + .expect("bind worker") + .expect("exists"); + + (store, task.task_number) + } + + fn schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["tag"], + "properties": {"tag": {"type": "string"}}, + }) + } + + #[tokio::test] + async fn accepts_output_matching_the_declared_schema() { + let worker_id = uuid::Uuid::new_v4(); + let (store, task_number) = store_with_task(worker_id).await; + store + .set_contract(task_number, None, Some(&schema())) + .await + .expect("set contract"); + + let tool = TaskCompleteTool::new(store.clone(), worker_id); + let output = tool + .call(TaskCompleteArgs { + task_number, + summary: "built it".into(), + outputs: serde_json::json!({"tag": "v1.0.0"}), + }) + .await + .expect("valid output should be accepted"); + + assert!(output.success); + let task = store + .get_by_number(task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(task.outputs, Some(serde_json::json!({"tag": "v1.0.0"}))); + } + + /// The rejection has to reach the model as an error it must handle. A + /// success-with-warning is something it will read past, and then the + /// contract has bought nothing. + #[tokio::test] + async fn rejects_mismatched_output_and_says_what_is_wrong() { + let worker_id = uuid::Uuid::new_v4(); + let (store, task_number) = store_with_task(worker_id).await; + store + .set_contract(task_number, None, Some(&schema())) + .await + .expect("set contract"); + + let tool = TaskCompleteTool::new(store.clone(), worker_id); + let error = tool + .call(TaskCompleteArgs { + task_number, + summary: "built it".into(), + outputs: serde_json::json!({"digest": "sha256:abc"}), + }) + .await + .expect_err("output missing a required field must be rejected"); + + let message = error.to_string(); + assert!(message.contains("tag"), "must name the problem: {message}"); + assert!( + message.contains("task_complete again"), + "must tell the worker it can retry: {message}" + ); + + let task = store + .get_by_number(task_number) + .await + .expect("fetch") + .expect("exists"); + assert!( + task.outputs.is_none(), + "a rejected output must not be visible downstream" + ); + } + + /// A worker writing outputs onto someone else's task would be laundering + /// invented values into a downstream task's inputs. + #[tokio::test] + async fn refuses_to_complete_another_workers_task() { + let worker_id = uuid::Uuid::new_v4(); + let (store, task_number) = store_with_task(worker_id).await; + + let intruder = TaskCompleteTool::new(store.clone(), uuid::Uuid::new_v4()); + let error = intruder + .call(TaskCompleteArgs { + task_number, + summary: "not mine".into(), + outputs: serde_json::json!({"tag": "v9"}), + }) + .await + .expect_err("a worker must not complete a task it was not given"); + assert!(error.to_string().contains("not assigned")); + + let task = store + .get_by_number(task_number) + .await + .expect("fetch") + .expect("exists"); + assert!(task.outputs.is_none()); + } + + #[tokio::test] + async fn accepts_any_output_when_no_schema_is_declared() { + let worker_id = uuid::Uuid::new_v4(); + let (store, task_number) = store_with_task(worker_id).await; + + let tool = TaskCompleteTool::new(store.clone(), worker_id); + tool.call(TaskCompleteArgs { + task_number, + summary: "done".into(), + outputs: serde_json::json!({"anything": [1, 2, 3]}), + }) + .await + .expect("an undeclared contract constrains nothing"); + } +} From 392c149962be4d5ca4c4dc60b2f3edd7fb66a070 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:38:22 +0000 Subject: [PATCH 16/69] feat(interface): contract inspector in the task drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows what a task was given, where each value came from, and what it must produce. Read-only — authoring schemas belongs to a builder that does not exist yet, and the inspector is the half that is useful without it. Problems render first and unmissable. A graph that cannot supply a task's inputs is the most common way a hand-built pipeline is wrong and it is silent everywhere else: the task simply sits in the backlog looking patient. Each one names the key, the upstream task, and the pointer, because "validation failed" sends somebody reading prompts to guess. Each input shows its source next to its value — `tag #142 → /image/tag v1.4.2`. That pairing is what makes a pipeline debuggable: it says which upstream task to go look at when a value is wrong, which no amount of staring at the value itself will tell you. Failed keys turn red in place rather than only appearing in the problem list, so the reader does not have to match two lists to each other. The section renders nothing at all for a task with no contract, which is nearly every task today. An empty "Contract" heading everywhere would be noise that teaches people to skip the section. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/client.ts | 7 + interface/src/api/schema.d.ts | 321 ++++++++++++++++++ interface/src/api/types.ts | 5 + .../src/components/tasks/ContractSection.tsx | 259 ++++++++++++++ interface/src/routes/AgentTasks.tsx | 4 + interface/src/routes/GlobalTasks.tsx | 8 + interface/src/routes/UiLab.tsx | 94 ++++- 7 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 interface/src/components/tasks/ContractSection.tsx diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index e5de1724f..f284f11ca 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -668,6 +668,10 @@ export type TaskEdgeSummary = Types.TaskEdgeSummary; export type TaskDependenciesResponse = Types.TaskDependenciesResponse; export type TaskTransition = Types.TaskTransition; export type TaskTransitionsResponse = Types.TaskTransitionsResponse; +export type ContractProblem = Types.ContractProblem; +export type ContractSide = Types.ContractSide; +export type TaskInputBinding = Types.TaskInputBinding; +export type TaskContractResponse = Types.TaskContractResponse; export type TaskItem = Types.Task; export type CreateTaskRequest = Types.CreateTaskRequest; @@ -1728,6 +1732,9 @@ export const api = { /** Per-attempt execution log for a task, oldest first. */ listTaskRuns: (taskNumber: number) => fetchJson(`/tasks/${taskNumber}/runs`), + /** Resolves live, so it shows what the task would get if it ran now. */ + getTaskContract: (taskNumber: number) => + fetchJson(`/tasks/${taskNumber}/contract`), listTaskDependencies: (taskNumber: number) => fetchJson(`/tasks/${taskNumber}/dependencies`), /** The legal status moves, so the board never offers one the API rejects. */ diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 420bae03f..cb9ce498f 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -2224,6 +2224,24 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/bindings/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** `PUT /tasks/{number}/bindings/{key}` — point one input at its source. */ + put: operations["set_task_binding"]; + post?: never; + /** `DELETE /tasks/{number}/bindings/{key}` — unbind one input. */ + delete: operations["remove_task_binding"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{number}/block": { parameters: { query?: never; @@ -2241,6 +2259,30 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/contract": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * `GET /tasks/{number}/contract` — the declared contract, its bindings, and + * what those bindings currently resolve to. + * @description Resolution runs live rather than being read back from the last claim, so the + * page shows what the task *would* get if it ran now. A graph that has drifted + * since the last attempt is exactly the case worth seeing. + */ + get: operations["get_task_contract"]; + /** `PUT /tasks/{number}/contract` — declare what a task needs and produces. */ + put: operations["set_task_contract"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{number}/dependencies": { parameters: { query?: never; @@ -2891,6 +2933,64 @@ export interface components { /** Format: float */ emergency_threshold?: number | null; }; + /** + * @description A specific reason a contract could not be satisfied. + * + * Deliberately granular. "Validation failed" sends a human reading prompts and + * guessing; naming the key and the upstream task that should have supplied it + * points straight at the broken edge. + */ + ContractProblem: { + /** @enum {string} */ + kind: "task_missing"; + /** Format: int64 */ + task_number: number; + } | { + input_key: string; + /** @enum {string} */ + kind: "source_missing"; + /** Format: int64 */ + source_task_number: number; + } | { + input_key: string; + /** @enum {string} */ + kind: "source_has_no_outputs"; + /** Format: int64 */ + source_task_number: number; + } | { + input_key: string; + /** @enum {string} */ + kind: "pointer_missed"; + pointer: string; + /** Format: int64 */ + source_task_number: number; + } | { + input_key: string; + /** @enum {string} */ + kind: "empty_literal"; + } | { + /** @enum {string} */ + kind: "schema_violation"; + message: string; + /** @description JSON Pointer to the offending value, `""` for the document root. */ + path: string; + side: components["schemas"]["ContractSide"]; + } | { + /** @enum {string} */ + kind: "invalid_schema"; + message: string; + side: components["schemas"]["ContractSide"]; + } | { + input_key: string; + /** @enum {string} */ + kind: "storage"; + message: string; + }; + /** + * @description Which half of a contract a problem came from. + * @enum {string} + */ + ContractSide: "input" | "output"; /** @description Response payload for conversation defaults endpoint. */ ConversationDefaultsResponse: { /** @description All available models. */ @@ -4160,11 +4260,26 @@ export interface components { /** @enum {string} */ kind: "agent"; }; + SetBindingRequest: { + /** @description Literal JSON value, used when no source task is given. */ + literal_value?: unknown; + /** @description RFC 6901 JSON Pointer into that task's outputs. */ + source_pointer?: string | null; + /** + * Format: int64 + * @description Upstream task to read from. Omit for a literal. + */ + source_task_number?: number | null; + }; SetChannelArchiveRequest: { agent_id: string; archived: boolean; channel_id: string; }; + SetContractRequest: { + input_schema?: unknown; + output_schema?: unknown; + }; SkillContentResponse: { base_dir: string; content: string; @@ -4230,6 +4345,13 @@ export interface components { created_by: string; description?: string | null; id: string; + /** @description JSON Schema this task's resolved inputs must satisfy before it runs. */ + input_schema?: unknown; + /** + * @description Inputs as resolved at claim time. Persisted so the value the worker + * actually saw survives a crash and stays readable after upstream changes. + */ + inputs?: unknown; /** * @description Text of the most recent failure, kept on the task so the board can * show why it is parked without joining `task_runs`. @@ -4245,6 +4367,10 @@ export interface components { */ max_retries?: number | null; metadata: unknown; + /** @description JSON Schema this task's outputs must satisfy to complete. */ + output_schema?: unknown; + /** @description Validated outputs. What downstream tasks read from. */ + outputs?: unknown; owner_agent_id: string; priority: components["schemas"]["TaskPriority"]; /** @description Project this task acts on, if any. */ @@ -4273,6 +4399,21 @@ export interface components { message: string; success: boolean; }; + TaskContractResponse: { + bindings: components["schemas"]["TaskInputBinding"][]; + input_schema?: unknown; + /** @description Inputs as they were resolved at the last claim. */ + inputs?: unknown; + output_schema?: unknown; + outputs?: unknown; + /** @description Why resolution fails, if it does. Empty when the contract is satisfied. */ + problems: components["schemas"]["ContractProblem"][]; + /** + * @description What the bindings resolve to right now, which may differ from `inputs` + * if the graph changed since the last attempt. + */ + resolved_inputs?: unknown; + }; TaskDependenciesResponse: { /** * @description The subset of `parents` that has not finished yet — what the board @@ -4304,6 +4445,30 @@ export interface components { /** Format: int64 */ task_number: number; }; + /** + * @description Where one of a task's inputs comes from. + * + * Either a pointer into an upstream task's outputs, or a literal baked into + * the graph. `source_task_number` being `None` means literal. + */ + TaskInputBinding: { + /** Format: int64 */ + child_task_number: number; + /** @description Key in the child's input object. */ + input_key: string; + /** @description JSON literal, used when `source_task_number` is `None`. */ + literal_value?: unknown; + /** + * @description RFC 6901 JSON Pointer into that task's outputs. Empty selects the whole + * outputs object. + */ + source_pointer?: string | null; + /** + * Format: int64 + * @description Upstream task to read from. `None` for a literal. + */ + source_task_number?: number | null; + }; TaskListResponse: { /** * @description Edge counts for every task that has any. Tasks with no dependencies are @@ -10277,6 +10442,86 @@ export interface operations { }; }; }; + set_task_binding: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + /** @description Input key */ + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetBindingRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskContractResponse"]; + }; + }; + /** @description A binding must name either a source task or a literal */ + 422: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + remove_task_binding: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + /** @description Input key */ + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskContractResponse"]; + }; + }; + /** @description Binding not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; block_task: { parameters: { query?: never; @@ -10324,6 +10569,82 @@ export interface operations { }; }; }; + get_task_contract: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskContractResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + set_task_contract: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetContractRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskContractResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_task_dependencies: { parameters: { query?: never; diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index 13046680a..c9b38fc61 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -378,6 +378,11 @@ export type TaskEdgeSummary = components["schemas"]["TaskEdgeSummary"]; export type TaskDependenciesResponse = components["schemas"]["TaskDependenciesResponse"]; export type TaskTransition = components["schemas"]["TaskTransition"]; +export type ContractProblem = components["schemas"]["ContractProblem"]; +export type ContractSide = components["schemas"]["ContractSide"]; +export type TaskInputBinding = components["schemas"]["TaskInputBinding"]; +export type TaskContractResponse = + components["schemas"]["TaskContractResponse"]; export type TaskTransitionsResponse = components["schemas"]["TaskTransitionsResponse"]; diff --git a/interface/src/components/tasks/ContractSection.tsx b/interface/src/components/tasks/ContractSection.tsx new file mode 100644 index 000000000..b227d6f63 --- /dev/null +++ b/interface/src/components/tasks/ContractSection.tsx @@ -0,0 +1,259 @@ +import { useQuery } from "@tanstack/react-query"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCircleExclamation, + faQuoteLeft, + faRightLong, +} from "@fortawesome/free-solid-svg-icons"; +import { + api, + type ContractProblem, + type TaskContractResponse, + type TaskInputBinding, +} from "@/api/client"; + +export interface ContractSectionProps { + taskNumber: number; + onSelectTask?: (taskNumber: number) => void; +} + +export function ContractSection({ taskNumber, onSelectTask }: ContractSectionProps) { + const { data } = useQuery({ + queryKey: ["task-contract", taskNumber], + queryFn: () => api.getTaskContract(taskNumber), + }); + + if (!data) return null; + return ; +} + +/** Split from the fetching wrapper so it renders against fixtures. */ +export function ContractSectionView({ + data, + onSelectTask, +}: { + data: TaskContractResponse; + onSelectTask?: (taskNumber: number) => void; +}) { + const hasContract = + data.input_schema != null || + data.output_schema != null || + data.bindings.length > 0 || + data.outputs != null; + + // Most tasks declare nothing. An empty "Contract" heading on every one of + // them would be noise that teaches people to skip the section. + if (!hasContract) return null; + + // Which keys the graph currently cannot supply, so each row can say so + // rather than making the reader match a list of problems to a list of rows. + const failedKeys = new Set( + data.problems + .map((problem) => ("input_key" in problem ? problem.input_key : null)) + .filter((key): key is string => key !== null), + ); + + const resolved = (data.resolved_inputs ?? data.inputs ?? {}) as Record< + string, + unknown + >; + + return ( +
+

+ Contract +

+ + {/* Problems first and unmissable: a graph that cannot supply a task's + inputs is the single most common way a hand-built pipeline is + wrong, and it is silent everywhere else. */} + {data.problems.length > 0 && ( +
    + {data.problems.map((problem) => ( +
  • + + {describe(problem)} +
  • + ))} +
+ )} + + {data.bindings.length > 0 && ( +
+

Inputs

+
+ {data.bindings.map((binding) => ( + + ))} +
+
+ )} + + {data.outputs != null ? ( + + ) : ( + data.output_schema != null && ( +
+

+ Outputs +

+

+ Not produced yet. Must match the declared schema. +

+
+ ) + )} + + {data.output_schema != null && ( + + )} +
+ ); +} + +/** + * One input, and where its value comes from. + * + * Showing the source next to the value is what makes a pipeline debuggable — + * `#42 → /image/tag` says which upstream task to go look at when the value is + * wrong, which no amount of staring at the value itself will tell you. + */ +function BindingRow({ + binding, + value, + failed, + onSelectTask, +}: { + binding: TaskInputBinding; + value: unknown; + failed: boolean; + onSelectTask?: (taskNumber: number) => void; +}) { + const isLiteral = binding.source_task_number == null; + + return ( +
+ + {binding.input_key} + + + + {isLiteral ? ( + <> + + literal + + ) : ( + <> + {onSelectTask ? ( + + ) : ( + #{binding.source_task_number} + )} + + {binding.source_pointer || "/"} + + )} + + + + {failed ? "unresolved" : render(value)} + +
+ ); +} + +function JsonBlock({ + label, + value, + muted, +}: { + label: string; + value: unknown; + muted?: boolean; +}) { + return ( +
+

{label}

+
+				{JSON.stringify(value, null, 2)}
+			
+
+ ); +} + +function render(value: unknown): string { + if (value === undefined) return "—"; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +/** Stable list key. Problems have no id, but key+kind is unique per resolution. */ +function problemKey(problem: ContractProblem): string { + return "input_key" in problem + ? `${problem.kind}:${problem.input_key}` + : `${problem.kind}:${JSON.stringify(problem)}`; +} + +/** + * Prose for a problem. + * + * The server's `Display` text is already good, but it is not sent — only the + * structured variant is — so the wording lives here. Each one names the key and + * the upstream task, because "validation failed" sends someone reading prompts + * to guess. + */ +function describe(problem: ContractProblem): string { + switch (problem.kind) { + case "task_missing": + return `Task #${problem.task_number} no longer exists.`; + case "source_missing": + return `\`${problem.input_key}\` reads from #${problem.source_task_number}, which no longer exists.`; + case "source_has_no_outputs": + return `\`${problem.input_key}\` is waiting on #${problem.source_task_number}, which has not produced output yet.`; + case "pointer_missed": + return `\`${problem.input_key}\`: #${problem.source_task_number} produced nothing at \`${problem.pointer}\`.`; + case "empty_literal": + return `\`${problem.input_key}\` is declared a literal but carries no value.`; + case "schema_violation": + return `${problem.side === "input" ? "Input" : "Output"} at \`${ + problem.path || "/" + }\` does not match the schema: ${problem.message}`; + case "invalid_schema": + return `The declared ${problem.side} schema is not valid JSON Schema: ${problem.message}`; + case "storage": + return `\`${problem.input_key}\` could not be read: ${problem.message}`; + } +} diff --git a/interface/src/routes/AgentTasks.tsx b/interface/src/routes/AgentTasks.tsx index 352c9eb48..0386b4ce6 100644 --- a/interface/src/routes/AgentTasks.tsx +++ b/interface/src/routes/AgentTasks.tsx @@ -22,6 +22,7 @@ import { } from "@/components/TaskUtils"; import {BlockedTasksSection} from "@/components/tasks/BlockedTasksSection"; import {indexEdges} from "@/components/tasks/DependencyBadges"; +import {ContractSection} from "@/components/tasks/ContractSection"; import {DependencySection} from "@/components/tasks/DependencySection"; import {TaskRunHistory} from "@/components/tasks/TaskRunHistory"; @@ -275,6 +276,9 @@ export function AgentTasks({agentId}: {agentId: string}) { + diff --git a/interface/src/routes/GlobalTasks.tsx b/interface/src/routes/GlobalTasks.tsx index 0b6d77cdc..327e2e6a4 100644 --- a/interface/src/routes/GlobalTasks.tsx +++ b/interface/src/routes/GlobalTasks.tsx @@ -28,6 +28,7 @@ import { } from "@/components/TaskUtils"; import {BlockedTasksSection} from "@/components/tasks/BlockedTasksSection"; import {indexEdges} from "@/components/tasks/DependencyBadges"; +import {ContractSection} from "@/components/tasks/ContractSection"; import {DependencySection} from "@/components/tasks/DependencySection"; import {TaskRunHistory} from "@/components/tasks/TaskRunHistory"; import {RepoChip} from "@/components/tasks/RepoChip"; @@ -406,6 +407,13 @@ export function GlobalTasks() { if (target) setActiveTaskId(target.id); }} /> + { + const target = rawTasks.find((t) => t.task_number === number); + if (target) setActiveTaskId(target.id); + }} + /> (null); @@ -240,6 +314,24 @@ export function UiLab() {

+
+

+ ContractSection — satisfied +

+
+ {}} /> +
+
+ +
+

+ ContractSection — unresolved +

+
+ {}} /> +
+
+

TaskRunHistory From 4ffd2f25aa74797082e71f8add250d0130701076 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:06:01 +0000 Subject: [PATCH 17/69] feat(tasks): let workers file cards instead of spawning sub-workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers are leaves. A worker that needed to decompose had nowhere to put the pieces, so multi-step plans lived in whatever the model remembered of its own intent and died with the segment. This breaks that ceiling without recursive spawning: a worker does not create a sub-worker, it files cards the existing pickup loop schedules. That loop already claims, observes, times out, and reaps, so decomposition inherits all of it rather than adding a second execution path with its own bugs. Filed cards go straight to `ready`. Filing *is* the decomposition, and routing each card through human approval would make the mechanism pointless. What makes that safe is two bounds, not an approval gate: - fan-out, capped per task - depth, capped along the filing chain Both are needed. A cap of ten with unbounded depth still permits ten to the power of the depth, and a depth bound alone lets one task file thousands of siblings. Hermes caps neither for kanban cards — their `delegate_task` has a depth limit but decomposition does not — and their own docs flag it as a runaway risk. `created_by` carries `task:`, which buys provenance, the fan-out count, and claim verification without another column. `task_complete` gained `created_tasks`, checked against what was actually filed. A worker reporting children it never created leaves whoever reads the board believing work is scheduled when it is not — worse than the worker failing outright, because nothing looks wrong until somebody wonders why nothing happened. This is Hermes's `HallucinatedCardsError`, and it is the second place we verify a model's claim about itself rather than taking it. The filing task is resolved from the worker at call time rather than captured at construction, so it cannot go stale and a worker not executing a task simply cannot file — no card without provenance or a budget. Filed cards can carry an `output_schema` and input bindings, so a decomposing worker wires the pipeline it creates. That is what finally exercises the F4 contract machinery from the running system rather than only from tests. They can also be assigned to another agent while the filer stays owner, which is the cross-repo coordination case: "regenerate clients in web after the contract lands in api" becomes two cards and an edge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- src/api/server.rs | 1 + src/api/tasks.rs | 56 +++++ src/tasks.rs | 11 +- src/tasks/store.rs | 132 ++++++++++++ src/tools.rs | 11 +- src/tools/task_complete.rs | 120 +++++++++++ src/tools/task_create.rs | 409 ++++++++++++++++++++++++++++++++++++- 7 files changed, 731 insertions(+), 9 deletions(-) diff --git a/src/api/server.rs b/src/api/server.rs index f53de2736..582b2026a 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -150,6 +150,7 @@ pub fn api_router() -> OpenApiRouter> { )) .routes(routes!(tasks::remove_task_dependency)) .routes(routes!(tasks::list_task_transitions)) + .routes(routes!(tasks::get_task_provenance)) .routes(routes!(tasks::get_task_contract, tasks::set_task_contract)) .routes(routes!(tasks::set_task_binding, tasks::remove_task_binding)) .routes(routes!(tasks::block_task)) diff --git a/src/api/tasks.rs b/src/api/tasks.rs index 796874cae..5d6a7e581 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -183,6 +183,16 @@ pub(super) struct TaskContractResponse { problems: Vec, } +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct TaskProvenanceResponse { + /// The task that filed this one, when a worker did. + filed_by_task_number: Option, + /// Cards this task filed. + filed: Vec, + /// How many more this task may still file before hitting the cap. + remaining_fan_out: i64, +} + #[derive(Deserialize, utoipa::ToSchema)] pub(super) struct SetContractRequest { #[serde(default)] @@ -1177,3 +1187,49 @@ pub(super) async fn remove_task_binding( get_task_contract(State(state), Path(number)).await } + +/// `GET /tasks/{number}/provenance` — where this card came from and what it +/// spawned. +/// +/// A worker-filed card is otherwise indistinguishable from one a human wrote, +/// which makes a surprising board impossible to explain. +#[utoipa::path( + get, + path = "/tasks/{number}/provenance", + params(("number" = i64, Path, description = "Task number")), + responses( + (status = 200, body = TaskProvenanceResponse), + (status = 404, description = "Task not found"), + (status = 503, description = "Task store not initialized"), + ), + tag = "tasks", +)] +pub(super) async fn get_task_provenance( + State(state): State>, + Path(number): Path, +) -> Result, StatusCode> { + let store = get_task_store(&state)?; + + let task = store + .get_by_number(number) + .await + .map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to read task for provenance"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + let filer = crate::tasks::filer_id(number); + let filed = store.list_tasks_filed_by(&filer).await.map_err(|error| { + tracing::warn!(%error, task_number = number, "failed to list filed tasks"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let remaining_fan_out = (crate::tasks::MAX_TASKS_FILED_PER_TASK - filed.len() as i64).max(0); + + Ok(Json(TaskProvenanceResponse { + filed_by_task_number: crate::tasks::parse_filer_task_number(&task.created_by), + filed, + remaining_fan_out, + })) +} diff --git a/src/tasks.rs b/src/tasks.rs index 708a43e02..56d7e929a 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -5,9 +5,10 @@ pub mod store; pub use store::{ BLOCK_RECURRENCE_LIMIT, BlockKind, BlockOutcome, ContractProblem, ContractResolution, - ContractSide, CreateTaskInput, DEFAULT_FAILURE_LIMIT, DependencyError, FailureDisposition, - OutputSubmission, ReadySweep, Task, TaskBindingPatch, TaskEdgeSummary, TaskInputBinding, - TaskListFilter, TaskPriority, TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, - TaskStore, TaskSubtask, TaskUpdateResult, UpdateTaskInput, WorkerOutputSubmission, - WorkerTaskUpdateResult, can_transition, legal_transitions, + ContractSide, CreateTaskInput, DEFAULT_FAILURE_LIMIT, DependencyError, FILED_BY_TASK_PREFIX, + FailureDisposition, MAX_FILING_DEPTH, MAX_TASKS_FILED_PER_TASK, OutputSubmission, ReadySweep, + Task, TaskBindingPatch, TaskEdgeSummary, TaskInputBinding, TaskListFilter, TaskPriority, + TaskProjectBinding, TaskRun, TaskRunOutcome, TaskStatus, TaskStore, TaskSubtask, + TaskUpdateResult, UpdateTaskInput, WorkerOutputSubmission, WorkerTaskUpdateResult, + can_transition, filer_id, legal_transitions, parse_filer_task_number, }; diff --git a/src/tasks/store.rs b/src/tasks/store.rs index 3efcce0b5..e90b0bb56 100644 --- a/src/tasks/store.rs +++ b/src/tasks/store.rs @@ -97,6 +97,37 @@ impl std::fmt::Display for BlockKind { } } +/// How many cards one task may file. Bounds a single runaway decomposition. +pub const MAX_TASKS_FILED_PER_TASK: i64 = 10; + +/// How many filing hops are allowed from a human or agent to a filed card. +/// +/// Fan-out and depth need separate bounds: a cap of 10 with unbounded depth +/// still permits 10^n tasks. Three hops is enough for +/// "epic -> service -> change" and stops well short of a self-sustaining tree. +pub const MAX_FILING_DEPTH: i64 = 3; + +/// Hard stop for the `created_by` walk, independent of the policy limit above, +/// so a malformed chain cannot loop forever. +const MAX_FILING_DEPTH_WALK: i64 = 32; + +/// `created_by` prefix marking a card filed by a task rather than by a human, +/// a branch, or the cortex. The suffix is the filing task number, which is what +/// makes provenance and the fan-out cap possible without another column. +pub const FILED_BY_TASK_PREFIX: &str = "task:"; + +/// Render the `created_by` value for a card filed by a task. +pub fn filer_id(task_number: i64) -> String { + format!("{FILED_BY_TASK_PREFIX}{task_number}") +} + +/// Read the filing task number back out of a `created_by` value. +pub fn parse_filer_task_number(created_by: &str) -> Option { + created_by + .strip_prefix(FILED_BY_TASK_PREFIX) + .and_then(|rest| rest.parse().ok()) +} + /// How many times a task may be unblocked and re-blocked for the *same* reason /// before it escalates to a human instead of continuing to bounce. /// @@ -1426,6 +1457,107 @@ impl TaskStore { Ok(sweep) } + // -- Worker-filed cards ------------------------------------------------- + + /// The task a worker is currently executing, if any. + pub async fn task_number_for_worker(&self, worker_id: &str) -> Result> { + sqlx::query_scalar( + "SELECT task_number FROM tasks WHERE worker_id = ? ORDER BY task_number DESC LIMIT 1", + ) + .bind(worker_id) + .fetch_optional(&self.pool) + .await + .context("failed to look up the worker's task") + .map_err(Into::into) + } + + /// How many tasks a given filer has already created. + /// + /// Bounds fan-out. A worker decomposing its task into children is the + /// mechanism that breaks the delegation depth ceiling, and it is also the + /// mechanism by which a confused model files two hundred cards nobody asked + /// for. Hermes leaves this unbounded and their own docs flag it as a risk. + pub async fn count_tasks_filed_by(&self, created_by: &str) -> Result { + sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE created_by = ?") + .bind(created_by) + .fetch_one(&self.pool) + .await + .context("failed to count filed tasks") + .map_err(Into::into) + } + + /// How many filing hops separate this task from a human or an agent. + /// + /// A per-task fan-out cap alone still permits `cap^depth` tasks, so depth + /// needs its own bound. Walks the `created_by` chain, which records the + /// filing task for worker-filed cards. A cycle is impossible — a task can + /// only be filed by one that already exists — but the walk is bounded + /// anyway rather than trusting that. + pub async fn filing_depth(&self, task_number: i64) -> Result { + let mut depth = 0i64; + let mut current = task_number; + + while depth < MAX_FILING_DEPTH_WALK { + let created_by: Option = + sqlx::query_scalar("SELECT created_by FROM tasks WHERE task_number = ?") + .bind(current) + .fetch_optional(&self.pool) + .await + .context("failed to read task creator")?; + + let Some(parent) = created_by.as_deref().and_then(parse_filer_task_number) else { + return Ok(depth); + }; + + depth += 1; + current = parent; + } + + Ok(depth) + } + + /// Of the tasks a worker claims it filed, the ones it did not. + /// + /// Server-side verification of a model's claim about its own actions. A + /// worker reporting children it never created leaves whoever reads the + /// board believing work is scheduled when it is not — worse than the worker + /// failing outright, because the failure is invisible. + pub async fn unverified_filed_tasks( + &self, + created_by: &str, + claimed: &[i64], + ) -> Result> { + let mut unverified = Vec::new(); + + for task_number in claimed { + let actual: Option = + sqlx::query_scalar("SELECT created_by FROM tasks WHERE task_number = ?") + .bind(task_number) + .fetch_optional(&self.pool) + .await + .context("failed to verify filed task")?; + + if actual.as_deref() != Some(created_by) { + unverified.push(*task_number); + } + } + + Ok(unverified) + } + + /// Tasks filed by a given filer, for the provenance view. + pub async fn list_tasks_filed_by(&self, created_by: &str) -> Result> { + let rows = sqlx::query(&format!( + "{SELECT_COLUMNS} FROM tasks WHERE created_by = ? ORDER BY task_number ASC" + )) + .bind(created_by) + .fetch_all(&self.pool) + .await + .context("failed to list filed tasks")?; + + rows.into_iter().map(task_from_row).collect() + } + // -- Contracts ---------------------------------------------------------- /// Declare where one of a task's inputs comes from. diff --git a/src/tools.rs b/src/tools.rs index 8decb94de..59456b3f3 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -21,6 +21,7 @@ //! - `shell`, `file_read`/`file_write`/`file_edit`/`file_list` — stateless, registered at creation //! - `task_update` — scoped to the worker's assigned task //! - `task_complete` — structured, schema-validated result for that task +//! - `task_create` — files follow-up cards, bounded by fan-out and depth caps //! - `set_status` — per-worker instance, registered at creation //! //! **Cortex ToolServer** (one per agent): @@ -971,7 +972,15 @@ pub fn create_worker_tool_server( agent_id.clone(), worker_id, )) - .tool(TaskCompleteTool::new(task_store, worker_id)) + .tool(TaskCompleteTool::new(task_store.clone(), worker_id)) + // Workers file cards rather than spawning sub-workers. The pickup loop + // already schedules, observes, and recovers those, so decomposition + // reuses machinery instead of adding a second execution path. + .tool(TaskCreateTool::for_task_worker( + task_store, + agent_id.to_string(), + worker_id, + )) .tool({ let mut status_tool = SetStatusTool::new(agent_id.clone(), worker_id, channel_id, event_tx.clone()); diff --git a/src/tools/task_complete.rs b/src/tools/task_complete.rs index 2cf227b28..1c1600579 100644 --- a/src/tools/task_complete.rs +++ b/src/tools/task_complete.rs @@ -44,6 +44,9 @@ pub struct TaskCompleteArgs { pub summary: String, /// Machine-readable result, validated against the task's output schema. pub outputs: serde_json::Value, + /// Task numbers you filed while working on this. Verified server-side. + #[serde(default)] + pub created_tasks: Vec, } #[derive(Debug, Serialize)] @@ -82,6 +85,11 @@ impl Tool for TaskCompleteTool { "outputs": { "type": "object", "description": "The task's result, matching the output schema given in your instructions." + }, + "created_tasks": { + "type": "array", + "items": {"type": "integer"}, + "description": "Task numbers you filed while working on this. Verified against what was actually created — reporting a card you did not file rejects the completion." } }, "required": ["task_number", "summary", "outputs"] @@ -90,6 +98,34 @@ impl Tool for TaskCompleteTool { } async fn call(&self, args: Self::Args) -> Result { + // Check the claim about filed cards before accepting the completion. + // + // A worker reporting children it never created leaves whoever reads the + // board believing work is scheduled when it is not — worse than the + // worker failing outright, because the failure is invisible until + // someone wonders why nothing happened. Verified against `created_by`, + // which only the store writes. + if !args.created_tasks.is_empty() { + let filer = crate::tasks::filer_id(args.task_number); + let unverified = self + .task_store + .unverified_filed_tasks(&filer, &args.created_tasks) + .await + .map_err(|error| TaskCompleteError(format!("{error}")))?; + + if !unverified.is_empty() { + let list = unverified + .iter() + .map(|number| format!("#{number}")) + .collect::>() + .join(", "); + return Err(TaskCompleteError(format!( + "you reported filing {list}, but this task did not create them. \ + File them with task_create, or drop them from created_tasks." + ))); + } + } + let submission = self .task_store .submit_worker_outputs(&self.worker_id.to_string(), args.task_number, &args.outputs) @@ -203,6 +239,7 @@ mod tests { task_number, summary: "built it".into(), outputs: serde_json::json!({"tag": "v1.0.0"}), + created_tasks: Vec::new(), }) .await .expect("valid output should be accepted"); @@ -234,6 +271,7 @@ mod tests { task_number, summary: "built it".into(), outputs: serde_json::json!({"digest": "sha256:abc"}), + created_tasks: Vec::new(), }) .await .expect_err("output missing a required field must be rejected"); @@ -269,6 +307,7 @@ mod tests { task_number, summary: "not mine".into(), outputs: serde_json::json!({"tag": "v9"}), + created_tasks: Vec::new(), }) .await .expect_err("a worker must not complete a task it was not given"); @@ -292,8 +331,89 @@ mod tests { task_number, summary: "done".into(), outputs: serde_json::json!({"anything": [1, 2, 3]}), + created_tasks: Vec::new(), }) .await .expect("an undeclared contract constrains nothing"); } + /// Server-side verification of a model's claim about its own actions. + /// + /// A worker reporting children it never filed leaves whoever reads the + /// board believing work is scheduled when it is not — worse than failing + /// outright, because nothing looks wrong until somebody wonders why + /// nothing happened. + #[tokio::test] + async fn rejects_a_completion_claiming_cards_it_did_not_file() { + let worker_id = uuid::Uuid::new_v4(); + let (store, task_number) = store_with_task(worker_id).await; + + // A real card, but filed by somebody else. + let other = store + .create(CreateTaskInput { + owner_agent_id: "agent-1".into(), + assigned_agent_id: "agent-1".into(), + title: "not mine".into(), + status: TaskStatus::Backlog, + created_by: "human".into(), + ..Default::default() + }) + .await + .expect("create other task"); + + let tool = TaskCompleteTool::new(store.clone(), worker_id); + let error = tool + .call(TaskCompleteArgs { + task_number, + summary: "decomposed the work".into(), + outputs: serde_json::json!({}), + created_tasks: vec![other.task_number, 9999], + }) + .await + .expect_err("claiming cards it did not file must be rejected"); + + let message = error.to_string(); + assert!(message.contains(&format!("#{}", other.task_number))); + assert!( + message.contains("#9999"), + "a nonexistent card counts too: {message}" + ); + + let task = store + .get_by_number(task_number) + .await + .expect("fetch") + .expect("exists"); + assert!( + task.outputs.is_none(), + "a rejected completion must not record outputs either" + ); + } + + #[tokio::test] + async fn accepts_a_completion_reporting_cards_it_really_filed() { + let worker_id = uuid::Uuid::new_v4(); + let (store, task_number) = store_with_task(worker_id).await; + + let child = store + .create(CreateTaskInput { + owner_agent_id: "agent-1".into(), + assigned_agent_id: "agent-1".into(), + title: "filed child".into(), + status: TaskStatus::Ready, + created_by: crate::tasks::filer_id(task_number), + ..Default::default() + }) + .await + .expect("create child"); + + let tool = TaskCompleteTool::new(store.clone(), worker_id); + tool.call(TaskCompleteArgs { + task_number, + summary: "decomposed the work".into(), + outputs: serde_json::json!({"filed": 1}), + created_tasks: vec![child.task_number], + }) + .await + .expect("a truthful claim should be accepted"); + } } diff --git a/src/tools/task_create.rs b/src/tools/task_create.rs index 1a1bcfc5b..fed069400 100644 --- a/src/tools/task_create.rs +++ b/src/tools/task_create.rs @@ -13,6 +13,14 @@ pub struct TaskCreateTool { task_store: Arc, agent_id: String, created_by: String, + /// Set when this tool belongs to a worker executing a task. The worker + /// files cards *on behalf of* that task, which is what makes fan-out + /// bounded, provenance real, and completion claims checkable. + /// + /// The task number is resolved from the worker at call time rather than + /// captured at construction, so it cannot go stale and a worker that is + /// not bound to a task simply cannot file. + filing_worker_id: Option, working_memory: Option>, api_state: Option>, } @@ -36,6 +44,28 @@ impl TaskCreateTool { task_store, agent_id: agent_id.into(), created_by: created_by.into(), + filing_worker_id: None, + working_memory: None, + api_state: None, + } + } + + /// Scope this tool to a worker filing cards for the task it is executing. + /// + /// This is how a worker decomposes without spawning sub-workers: it files + /// cards the existing pickup loop schedules. Naturally bounded, observable, + /// and crash-safe, because the scheduler already handles all three. + pub fn for_task_worker( + task_store: Arc, + agent_id: impl Into, + worker_id: crate::WorkerId, + ) -> Self { + Self { + task_store, + agent_id: agent_id.into(), + // Overwritten per call once the worker's task is known. + created_by: "worker".to_string(), + filing_worker_id: Some(worker_id), working_memory: None, api_state: None, } @@ -56,6 +86,68 @@ impl TaskCreateTool { #[error("task_create failed: {0}")] pub struct TaskCreateError(String); +impl TaskCreateTool { + /// Which task this worker is executing. + /// + /// A worker not bound to a task has nothing to file on behalf of, and + /// letting it create cards anyway would produce work with no provenance + /// and no fan-out budget. + async fn resolve_filing_task( + &self, + worker_id: crate::WorkerId, + ) -> Result { + self.task_store + .task_number_for_worker(&worker_id.to_string()) + .await + .map_err(|error| TaskCreateError(format!("{error}")))? + .ok_or_else(|| { + TaskCreateError( + "you are not executing a task, so there is nothing to file cards for" + .to_string(), + ) + }) + } + + /// Refuse to file another card once this task has fanned out far enough, + /// or once the filing chain is deep enough. + /// + /// Both bounds are needed: a per-task cap with unbounded depth still + /// permits `cap^depth` tasks, and a depth bound alone permits one task to + /// file thousands of siblings. The error text names the limit so the worker + /// can adapt rather than retrying into the same wall. + async fn enforce_filing_limits(&self, filing_task_number: i64) -> Result<(), TaskCreateError> { + let filer = crate::tasks::filer_id(filing_task_number); + + let already = self + .task_store + .count_tasks_filed_by(&filer) + .await + .map_err(|error| TaskCreateError(format!("{error}")))?; + if already >= crate::tasks::MAX_TASKS_FILED_PER_TASK { + return Err(TaskCreateError(format!( + "task #{filing_task_number} has already filed {already} cards, the limit is {}. \ + Do the remaining work yourself, or file one card that decomposes further.", + crate::tasks::MAX_TASKS_FILED_PER_TASK + ))); + } + + let depth = self + .task_store + .filing_depth(filing_task_number) + .await + .map_err(|error| TaskCreateError(format!("{error}")))?; + if depth >= crate::tasks::MAX_FILING_DEPTH { + return Err(TaskCreateError(format!( + "task #{filing_task_number} is {depth} filing hops deep and the limit is {}. \ + Do this work directly rather than filing another card.", + crate::tasks::MAX_FILING_DEPTH + ))); + } + + Ok(()) + } +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct TaskCreateArgs { pub title: String, @@ -79,6 +171,30 @@ pub struct TaskCreateArgs { /// Task numbers that must finish before this one may run. #[serde(default)] pub depends_on: Vec, + /// Agent to assign the card to. Defaults to the filing agent. + #[serde(default)] + pub assigned_agent_id: Option, + /// JSON Schema the card must satisfy when it completes. + #[serde(default)] + pub output_schema: Option, + /// Where this card's inputs come from. + #[serde(default)] + pub input_bindings: Vec, +} + +/// One input of a filed card, wired to an upstream task or a literal. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TaskCreateBinding { + pub input_key: String, + /// Upstream task to read from. Omit for a literal. + #[serde(default)] + pub source_task_number: Option, + /// RFC 6901 JSON Pointer into that task's outputs, e.g. `/image/tag`. + #[serde(default)] + pub source_pointer: Option, + /// Literal JSON value, used when no source task is given. + #[serde(default)] + pub literal_value: Option, } fn default_priority() -> String { @@ -149,7 +265,29 @@ impl Tool for TaskCreateTool { async fn call(&self, args: Self::Args) -> Result { let priority = TaskPriority::parse(&args.priority) .ok_or_else(|| TaskCreateError(format!("invalid priority: {}", args.priority)))?; - let status = TaskStatus::PendingApproval; + + // A worker's cards go straight to the queue rather than sitting in + // pending_approval: filing them *is* the decomposition, and a human + // approving each one would make the mechanism useless. The fan-out and + // depth caps below are what keeps that safe, not an approval gate. + let filing_task_number = match self.filing_worker_id { + Some(worker_id) => Some(self.resolve_filing_task(worker_id).await?), + None => None, + }; + + let status = if filing_task_number.is_some() { + TaskStatus::Ready + } else { + TaskStatus::PendingApproval + }; + + let created_by = match filing_task_number { + Some(number) => { + self.enforce_filing_limits(number).await?; + crate::tasks::filer_id(number) + } + None => self.created_by.clone(), + }; let subtasks = args .subtasks @@ -160,11 +298,15 @@ impl Tool for TaskCreateTool { }) .collect::>(); + let assigned_agent_id = args + .assigned_agent_id + .unwrap_or_else(|| self.agent_id.clone()); + let task = self .task_store .create(CreateTaskInput { owner_agent_id: self.agent_id.clone(), - assigned_agent_id: self.agent_id.clone(), + assigned_agent_id, title: args.title, description: args.description, status, @@ -172,7 +314,7 @@ impl Tool for TaskCreateTool { subtasks, metadata: args.metadata.unwrap_or_else(|| serde_json::json!({})), source_memory_id: None, - created_by: self.created_by.clone(), + created_by, binding: crate::tasks::TaskProjectBinding { project_id: args.project_id, repo_id: args.repo_id, @@ -183,6 +325,34 @@ impl Tool for TaskCreateTool { .await .map_err(|error| TaskCreateError(format!("{error}")))?; + // Contract and bindings are applied after the row exists. A failure + // here leaves a task that will not resolve, which the claim path parks + // as a dependency block with the reason — visible rather than silent. + if let Some(output_schema) = &args.output_schema + && let Err(error) = self + .task_store + .set_contract(task.task_number, None, Some(output_schema)) + .await + { + tracing::warn!(%error, task_number = task.task_number, "failed to set contract on filed task"); + } + + for binding in args.input_bindings { + if let Err(error) = self + .task_store + .set_input_binding(&crate::tasks::TaskInputBinding { + child_task_number: task.task_number, + input_key: binding.input_key, + source_task_number: binding.source_task_number, + source_pointer: binding.source_pointer, + literal_value: binding.literal_value, + }) + .await + { + tracing::warn!(%error, task_number = task.task_number, "failed to bind input on filed task"); + } + } + // Emit SSE event + notification so the dashboard updates in real time. if let Some(api_state) = &self.api_state { api_state @@ -297,6 +467,9 @@ mod tests { repo_id: None, worktree_id: None, depends_on: Vec::new(), + assigned_agent_id: None, + output_schema: None, + input_bindings: Vec::new(), }) .await .expect("task create should succeed"); @@ -310,4 +483,234 @@ mod tests { "Task created #1: Ship observation MVP (status: pending_approval)" ); } + // -- Worker-filed cards ------------------------------------------------- + + async fn worker_filing_fixture() -> (Arc, crate::WorkerId, i64) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite should connect"); + crate::tasks::store::create_task_schema(&pool).await; + sqlx::query("INSERT INTO task_number_seq (id, next_number) VALUES (1, 1)") + .execute(&pool) + .await + .expect("seed sequence"); + + let store = Arc::new(TaskStore::new(pool)); + let parent = store + .create(CreateTaskInput { + owner_agent_id: "agent-1".into(), + assigned_agent_id: "agent-1".into(), + title: "decompose me".into(), + status: TaskStatus::InProgress, + created_by: "human".into(), + ..Default::default() + }) + .await + .expect("create parent"); + + let worker_id = uuid::Uuid::new_v4(); + store + .update( + parent.task_number, + crate::tasks::UpdateTaskInput { + worker_id: Some(worker_id.to_string()), + ..Default::default() + }, + ) + .await + .expect("bind worker") + .expect("exists"); + + (store, worker_id, parent.task_number) + } + + fn filing_args(title: &str) -> TaskCreateArgs { + TaskCreateArgs { + title: title.to_string(), + description: None, + priority: "medium".to_string(), + subtasks: Vec::new(), + metadata: None, + project_id: None, + repo_id: None, + worktree_id: None, + depends_on: Vec::new(), + assigned_agent_id: None, + output_schema: None, + input_bindings: Vec::new(), + } + } + + /// A filed card goes straight to the queue. Filing *is* the decomposition, + /// so routing each one through approval would make the mechanism useless. + #[tokio::test] + async fn a_worker_files_a_card_that_is_immediately_schedulable() { + let (store, worker_id, parent) = worker_filing_fixture().await; + let tool = TaskCreateTool::for_task_worker(store.clone(), "agent-1", worker_id); + + let output = tool + .call(filing_args("regenerate clients")) + .await + .expect("worker should be able to file a card"); + + assert_eq!(output.status, "ready"); + let filed = store + .get_by_number(output.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!( + filed.created_by, + crate::tasks::filer_id(parent), + "provenance must name the filing task, not just 'worker'" + ); + } + + /// The runaway bound. Hermes leaves kanban fan-out unlimited and their own + /// docs flag it; a confused model can otherwise file cards until something + /// else breaks. + #[tokio::test] + async fn fan_out_is_capped_per_task() { + let (store, worker_id, _) = worker_filing_fixture().await; + let tool = TaskCreateTool::for_task_worker(store.clone(), "agent-1", worker_id); + + for index in 0..crate::tasks::MAX_TASKS_FILED_PER_TASK { + tool.call(filing_args(&format!("child {index}"))) + .await + .expect("within the cap"); + } + + let error = tool + .call(filing_args("one too many")) + .await + .expect_err("the cap must hold"); + let message = error.to_string(); + assert!( + message.contains(&crate::tasks::MAX_TASKS_FILED_PER_TASK.to_string()), + "the refusal must name the limit so the worker can adapt: {message}" + ); + } + + /// Fan-out and depth need separate bounds — a cap of ten with unbounded + /// depth still permits ten to the power of the depth. + #[tokio::test] + async fn filing_depth_is_capped() { + let (store, _, root) = worker_filing_fixture().await; + + // Build a chain of filed tasks as deep as the limit allows. + let mut current = root; + for _ in 0..crate::tasks::MAX_FILING_DEPTH { + let child = store + .create(CreateTaskInput { + owner_agent_id: "agent-1".into(), + assigned_agent_id: "agent-1".into(), + title: "chained".into(), + status: TaskStatus::InProgress, + created_by: crate::tasks::filer_id(current), + ..Default::default() + }) + .await + .expect("create chained task"); + current = child.task_number; + } + + let deep_worker = uuid::Uuid::new_v4(); + store + .update( + current, + crate::tasks::UpdateTaskInput { + worker_id: Some(deep_worker.to_string()), + ..Default::default() + }, + ) + .await + .expect("bind") + .expect("exists"); + + let tool = TaskCreateTool::for_task_worker(store.clone(), "agent-1", deep_worker); + let error = tool + .call(filing_args("one hop too far")) + .await + .expect_err("the depth limit must hold"); + assert!( + error.to_string().contains("hops deep"), + "the refusal must explain why: {error}" + ); + } + + /// A worker with no task has nothing to file on behalf of. Letting it + /// create cards anyway would produce work with no provenance and no budget. + #[tokio::test] + async fn a_worker_not_executing_a_task_cannot_file() { + let (store, _, _) = worker_filing_fixture().await; + let tool = TaskCreateTool::for_task_worker(store, "agent-1", uuid::Uuid::new_v4()); + + let error = tool + .call(filing_args("orphan")) + .await + .expect_err("an unbound worker must not file"); + assert!(error.to_string().contains("not executing a task")); + } + + /// The point of F4 meeting F5: a decomposing worker wires the pipeline it + /// files, so the contract machinery is exercised by the running system + /// rather than only by tests. + #[tokio::test] + async fn a_filed_card_can_carry_a_contract_and_bindings() { + let (store, worker_id, parent) = worker_filing_fixture().await; + let tool = TaskCreateTool::for_task_worker(store.clone(), "agent-1", worker_id); + + let mut args = filing_args("deploy the tag"); + args.output_schema = Some(serde_json::json!({ + "type": "object", + "required": ["deployment_url"], + "properties": {"deployment_url": {"type": "string"}}, + })); + args.input_bindings = vec![TaskCreateBinding { + input_key: "tag".into(), + source_task_number: Some(parent), + source_pointer: Some("/image/tag".into()), + literal_value: None, + }]; + + let output = tool.call(args).await.expect("file with a contract"); + + let filed = store + .get_by_number(output.task_number) + .await + .expect("fetch") + .expect("exists"); + assert!(filed.output_schema.is_some()); + + let bindings = store + .list_input_bindings(output.task_number) + .await + .expect("list bindings"); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].source_task_number, Some(parent)); + assert_eq!(bindings[0].source_pointer.as_deref(), Some("/image/tag")); + } + + #[tokio::test] + async fn a_filed_card_can_be_assigned_to_another_agent() { + let (store, worker_id, _) = worker_filing_fixture().await; + let tool = TaskCreateTool::for_task_worker(store.clone(), "agent-1", worker_id); + + let mut args = filing_args("regenerate the web client"); + args.assigned_agent_id = Some("agent-web".into()); + let output = tool.call(args).await.expect("file cross-agent"); + + let filed = store + .get_by_number(output.task_number) + .await + .expect("fetch") + .expect("exists"); + assert_eq!(filed.assigned_agent_id, "agent-web"); + assert_eq!( + filed.owner_agent_id, "agent-1", + "the filing agent stays the owner so provenance survives reassignment" + ); + } } From 3758a1d60ab1cbdf1dcd4d88a8ed1233baf9bed7 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:14:02 +0000 Subject: [PATCH 18/69] feat(interface): show where a card came from and what it filed A worker-filed card is otherwise indistinguishable from one a human wrote, which makes a board that suddenly grew six items impossible to explain. Both directions are shown, because both questions get asked: "who asked for this" when a card appears unexpectedly, and "what did this ask for" when reading a task that decomposed. The upstream filer and each filed card link through to their own drawer. The remaining fan-out budget sits next to the count. A decomposition that stopped at the cap otherwise looks like a worker that simply lost interest, and the difference matters when deciding whether to file the rest by hand. The section renders nothing for a human-written card that spawned nothing, which is most of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/client.ts | 4 + interface/src/api/schema.d.ts | 72 +++++++++++ interface/src/api/types.ts | 2 + .../components/tasks/ProvenanceSection.tsx | 115 ++++++++++++++++++ interface/src/routes/AgentTasks.tsx | 4 + interface/src/routes/GlobalTasks.tsx | 8 ++ interface/src/routes/UiLab.tsx | 34 ++++++ 7 files changed, 239 insertions(+) create mode 100644 interface/src/components/tasks/ProvenanceSection.tsx diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index f284f11ca..5bc243824 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -672,6 +672,7 @@ export type ContractProblem = Types.ContractProblem; export type ContractSide = Types.ContractSide; export type TaskInputBinding = Types.TaskInputBinding; export type TaskContractResponse = Types.TaskContractResponse; +export type TaskProvenanceResponse = Types.TaskProvenanceResponse; export type TaskItem = Types.Task; export type CreateTaskRequest = Types.CreateTaskRequest; @@ -1735,6 +1736,9 @@ export const api = { /** Resolves live, so it shows what the task would get if it ran now. */ getTaskContract: (taskNumber: number) => fetchJson(`/tasks/${taskNumber}/contract`), + /** Where this card came from, and what it filed. */ + getTaskProvenance: (taskNumber: number) => + fetchJson(`/tasks/${taskNumber}/provenance`), listTaskDependencies: (taskNumber: number) => fetchJson(`/tasks/${taskNumber}/dependencies`), /** The legal status moves, so the board never offers one the API rejects. */ diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index cb9ce498f..519fabb18 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -2343,6 +2343,28 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/{number}/provenance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * `GET /tasks/{number}/provenance` — where this card came from and what it + * spawned. + * @description A worker-filed card is otherwise indistinguishable from one a human wrote, + * which makes a surprising board impossible to explain. + */ + get: operations["get_task_provenance"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{number}/retry": { parameters: { query?: never; @@ -4479,6 +4501,20 @@ export interface components { }; /** @enum {string} */ TaskPriority: "critical" | "high" | "medium" | "low"; + TaskProvenanceResponse: { + /** @description Cards this task filed. */ + filed: components["schemas"]["Task"][]; + /** + * Format: int64 + * @description The task that filed this one, when a worker did. + */ + filed_by_task_number?: number | null; + /** + * Format: int64 + * @description How many more this task may still file before hitting the cap. + */ + remaining_fan_out: number; + }; TaskResponse: { task: components["schemas"]["Task"]; }; @@ -10813,6 +10849,42 @@ export interface operations { }; }; }; + get_task_provenance: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Task number */ + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskProvenanceResponse"]; + }; + }; + /** @description Task not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Task store not initialized */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; retry_task: { parameters: { query?: never; diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index c9b38fc61..861d6e661 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -383,6 +383,8 @@ export type ContractSide = components["schemas"]["ContractSide"]; export type TaskInputBinding = components["schemas"]["TaskInputBinding"]; export type TaskContractResponse = components["schemas"]["TaskContractResponse"]; +export type TaskProvenanceResponse = + components["schemas"]["TaskProvenanceResponse"]; export type TaskTransitionsResponse = components["schemas"]["TaskTransitionsResponse"]; diff --git a/interface/src/components/tasks/ProvenanceSection.tsx b/interface/src/components/tasks/ProvenanceSection.tsx new file mode 100644 index 000000000..d4f30b809 --- /dev/null +++ b/interface/src/components/tasks/ProvenanceSection.tsx @@ -0,0 +1,115 @@ +import { useQuery } from "@tanstack/react-query"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCodeBranch, faSitemap } from "@fortawesome/free-solid-svg-icons"; +import { api, type TaskProvenanceResponse } from "@/api/client"; + +export interface ProvenanceSectionProps { + taskNumber: number; + onSelectTask?: (taskNumber: number) => void; +} + +export function ProvenanceSection({ + taskNumber, + onSelectTask, +}: ProvenanceSectionProps) { + const { data } = useQuery({ + queryKey: ["task-provenance", taskNumber], + queryFn: () => api.getTaskProvenance(taskNumber), + }); + + if (!data) return null; + return ; +} + +/** + * Where a card came from and what it spawned. + * + * A worker-filed card is otherwise indistinguishable from one a human wrote, + * which makes a board that suddenly grew six new items impossible to explain. + * Both directions matter: "who asked for this" and "what did this ask for". + */ +export function ProvenanceSectionView({ + data, + onSelectTask, +}: { + data: TaskProvenanceResponse; + onSelectTask?: (taskNumber: number) => void; +}) { + const { filed_by_task_number, filed, remaining_fan_out } = data; + + // A human-created card that spawned nothing has no provenance to show. + if (filed_by_task_number == null && filed.length === 0) return null; + + return ( +
+

+ Provenance +

+ + {filed_by_task_number != null && ( +

+ + Filed by{" "} + + while it ran +

+ )} + + {filed.length > 0 && ( +
+
+

+ + Filed {filed.length} +

+ {/* Showing the remaining budget makes a truncated + decomposition legible instead of looking like the + worker simply stopped caring. */} + + {remaining_fan_out === 0 + ? "fan-out limit reached" + : `${remaining_fan_out} more allowed`} + +
+
    + {filed.map((task) => ( +
  • + + + {task.title} + + + {task.status} + +
  • + ))} +
+
+ )} +
+ ); +} + +function TaskRef({ + number, + onSelect, +}: { + number: number; + onSelect?: (taskNumber: number) => void; +}) { + const className = "shrink-0 font-mono text-ink-dull"; + if (!onSelect) return #{number}; + return ( + + ); +} diff --git a/interface/src/routes/AgentTasks.tsx b/interface/src/routes/AgentTasks.tsx index 0386b4ce6..e34090244 100644 --- a/interface/src/routes/AgentTasks.tsx +++ b/interface/src/routes/AgentTasks.tsx @@ -23,6 +23,7 @@ import { import {BlockedTasksSection} from "@/components/tasks/BlockedTasksSection"; import {indexEdges} from "@/components/tasks/DependencyBadges"; import {ContractSection} from "@/components/tasks/ContractSection"; +import {ProvenanceSection} from "@/components/tasks/ProvenanceSection"; import {DependencySection} from "@/components/tasks/DependencySection"; import {TaskRunHistory} from "@/components/tasks/TaskRunHistory"; @@ -279,6 +280,9 @@ export function AgentTasks({agentId}: {agentId: string}) { + diff --git a/interface/src/routes/GlobalTasks.tsx b/interface/src/routes/GlobalTasks.tsx index 327e2e6a4..3cdd7f7b6 100644 --- a/interface/src/routes/GlobalTasks.tsx +++ b/interface/src/routes/GlobalTasks.tsx @@ -29,6 +29,7 @@ import { import {BlockedTasksSection} from "@/components/tasks/BlockedTasksSection"; import {indexEdges} from "@/components/tasks/DependencyBadges"; import {ContractSection} from "@/components/tasks/ContractSection"; +import {ProvenanceSection} from "@/components/tasks/ProvenanceSection"; import {DependencySection} from "@/components/tasks/DependencySection"; import {TaskRunHistory} from "@/components/tasks/TaskRunHistory"; import {RepoChip} from "@/components/tasks/RepoChip"; @@ -414,6 +415,13 @@ export function GlobalTasks() { if (target) setActiveTaskId(target.id); }} /> + { + const target = rawTasks.find((t) => t.task_number === number); + if (target) setActiveTaskId(target.id); + }} + /> (null); @@ -332,6 +357,15 @@ export function UiLab() {

+
+

+ ProvenanceSection +

+
+ {}} /> +
+
+

TaskRunHistory From 4e03c1eab69aaf4a30859efddae00b72ab838f34 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:47:12 +0000 Subject: [PATCH 19/69] fix(interface): keep the dev-only UI lab out of production builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lab route was guarded by `import.meta.env.DEV`, but only the *route entry* was — the `import {UiLab}` at the top of the router ran unconditionally. The module could not be tree-shaken either, because building its fixtures is a top-level side effect. So it executed on every production page load. That was survivable until the app was served over a tailnet address, where `crypto.randomUUID` is undefined: it is secure-context only, and a bare IP over plain HTTP is not one. The fixtures called it at module scope, so importing the router threw and the entire app failed to boot with a blank page. Both halves are fixed. The route and its import now live inside the DEV branch together, which is the only arrangement that actually drops the module — Vite folds the condition to `false` for production, taking the dynamic import with it. Verified: the built bundle no longer contains the lab. The fixtures use a counter instead. `lib/id.ts::generateId` already existed for this exact hazard, with a docstring naming Tailscale — it is the right answer for real client-side ids, but fixtures want ids that are stable across reloads more than unique, so a counter beats both. Note for whoever hits this next: `navigator.clipboard` is secure-context only as well, and several copy buttons call it unguarded. Those fail per-click rather than at boot, so they are left alone here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/server.rs | 0 interface/src/router.tsx | 35 +++++++++++++++++++++++----------- interface/src/routes/UiLab.tsx | 11 ++++++++++- 3 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 interface/src/api/server.rs diff --git a/interface/src/api/server.rs b/interface/src/api/server.rs new file mode 100644 index 000000000..e69de29bb diff --git a/interface/src/router.tsx b/interface/src/router.tsx index ecb0c521f..4f89c45a3 100644 --- a/interface/src/router.tsx +++ b/interface/src/router.tsx @@ -1,3 +1,4 @@ +import {lazy} from "react"; import { createRouter, createRootRoute, @@ -23,7 +24,6 @@ import {AgentWorkers} from "@/routes/AgentWorkers"; import {AgentProjects} from "@/routes/AgentProjects"; import {AgentTasks} from "@/routes/AgentTasks"; import {GlobalTasks} from "@/routes/GlobalTasks"; -import {UiLab} from "@/routes/UiLab"; import {Wiki} from "@/routes/Wiki"; import {AgentChat} from "@/routes/AgentChat"; import {Settings} from "@/routes/Settings"; @@ -118,15 +118,28 @@ const tasksRoute = createRoute({ }, }); -// Development-only visual harness for task components. Tree-shaken out of -// production builds via the import.meta.env.DEV guard on the route list below. -const uiLabRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/__uilab", - component: function UiLabPage() { - return ; - }, -}); +// Development-only visual harness for task components. +// +// Both the route *and* the import live inside the DEV branch, which is the +// only arrangement that actually keeps the module out of production. Guarding +// just the route entry leaves a static `import {UiLab}` at the top of this +// file running unconditionally, and it cannot be tree-shaken because building +// its fixtures is a top-level side effect — so it executed on every production +// page load and threw, taking the whole app down with it. +// +// Vite folds `import.meta.env.DEV` to `false` when building for production, so +// this collapses to `[]` and the dynamic import goes with it. +const devOnlyRoutes = import.meta.env.DEV + ? [ + createRoute({ + getParentRoute: () => rootRoute, + path: "/__uilab", + component: lazy(() => + import("@/routes/UiLab").then((m) => ({default: m.UiLab})), + ), + }), + ] + : []; const wikiRoute = createRoute({ getParentRoute: () => rootRoute, @@ -289,7 +302,7 @@ const routeTree = rootRoute.addChildren([ agentCronRoute, agentConfigRoute, channelRoute, - ...(import.meta.env.DEV ? [uiLabRoute] : []), + ...devOnlyRoutes, ]); export const router = createRouter({ diff --git a/interface/src/routes/UiLab.tsx b/interface/src/routes/UiLab.tsx index 5420eefb4..4e2691f8a 100644 --- a/interface/src/routes/UiLab.tsx +++ b/interface/src/routes/UiLab.tsx @@ -37,9 +37,18 @@ const AGENTS: Record = { "agent-web": "Web Agent", }; +// Counted rather than random. `crypto.randomUUID` is undefined outside a +// secure context, so calling it throws as soon as the app is reached over a +// LAN or tailnet address instead of localhost — `lib/id.ts::generateId` exists +// for exactly that reason and is the right choice for real client-side ids. +// Fixtures want stable ids across reloads more than they want unique ones, +// so a counter beats both. +let nextFixtureId = 0; + function fixtureTask(overrides: Partial): TaskItem { + nextFixtureId += 1; return { - id: crypto.randomUUID(), + id: `fixture-${nextFixtureId}`, task_number: 1, title: "Untitled", status: "blocked", From 4e4abb4455aabc70eb3d1ffd52eb424244e802d3 Mon Sep 17 00:00:00 2001 From: grodik <2020115+geudrik@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:28:22 +0000 Subject: [PATCH 20/69] fix(interface): stop the drawer crashing on blocked tasks, and let humans author contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, one shared root: `@spacedrive/ai` knows five task statuses and `blocked` is not one of them. `TaskList` handles the sixth by silently dropping the row, which the board already works around. `TaskStatusIcon` destructures `config[status]` with no fallback, so opening the drawer on a blocked task threw and took the page down. The boundary is now adapted in one place — `designSystemTask.ts` — so there is a single thing to delete when the design system learns the status. `blocked` maps to `pending_approval` for rendering: both mean "parked, waiting on a person". But an adapter that only rewrites the status leaves the drawer asserting a state the task does not have and offering an Approve button for an approval nobody is waiting on. So `BlockedBanner` states the real status, kind, and reason above the panel, and says outright why the control below disagrees. Lying quietly to the reader would have been the cheaper fix and the wrong one. Status changes now resolve the real task by id before branching, since the copy the drawer holds carries the adapted status — branching on that would approve a task that was never awaiting approval. Leaving `blocked` routes to unblock rather than a status write, because it has to clear the reason and re-check dependencies. The contract is now editable from the drawer, which is the half of authoring that belongs to a human. Defining the *shape* is a design decision; producing the *values* is work, and stays worker-only through `task_complete`. Declaring an output schema here is exactly what turns that submission from "record what the model said" into something checked and rejectable. JSON is parsed locally before being sent, because the server accepts a schema it cannot compile and only surfaces the problem later, when a task tries to run. Also makes the Vite dev proxy configurable via SPACEBOT_API and SPACEBOT_DEV_HOST. The proxy already existed but hardcoded one instance and localhost, so UI work meant a frontend build plus a ten-minute release build to re-embed it. Pointing it at a running daemon makes an edit a hot reload. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FDd7NYqwyqA7zzKPQBWCbK --- interface/src/api/client.ts | 22 ++ .../src/components/tasks/BlockedBanner.tsx | 71 +++++++ .../src/components/tasks/ContractSection.tsx | 189 +++++++++++++++++- .../src/components/tasks/designSystemTask.ts | 62 ++++++ interface/src/routes/AgentTasks.tsx | 17 +- interface/src/routes/GlobalTasks.tsx | 28 ++- interface/vite.config.ts | 9 +- 7 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 interface/src/components/tasks/BlockedBanner.tsx create mode 100644 interface/src/components/tasks/designSystemTask.ts diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 5bc243824..c343ddfcf 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1739,6 +1739,28 @@ export const api = { /** Where this card came from, and what it filed. */ getTaskProvenance: (taskNumber: number) => fetchJson(`/tasks/${taskNumber}/provenance`), + /** + * Declare what a task must produce (and may require). + * + * A human defines the *shape*; only a worker ever writes the *values*, via + * `task_complete`. Setting an output schema is what makes that submission + * checked rather than taken on trust. + */ + setTaskContract: async ( + taskNumber: number, + body: {input_schema?: unknown; output_schema?: unknown}, + ) => { + const response = await fetch( + `${getApiBase()}/tasks/${taskNumber}/contract`, + { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body), + }, + ); + if (!response.ok) throw new Error(`API error: ${response.status}`); + return (await response.json()) as TaskContractResponse; + }, listTaskDependencies: (taskNumber: number) => fetchJson(`/tasks/${taskNumber}/dependencies`), /** The legal status moves, so the board never offers one the API rejects. */ diff --git a/interface/src/components/tasks/BlockedBanner.tsx b/interface/src/components/tasks/BlockedBanner.tsx new file mode 100644 index 000000000..f2d09498c --- /dev/null +++ b/interface/src/components/tasks/BlockedBanner.tsx @@ -0,0 +1,71 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBan } from "@fortawesome/free-solid-svg-icons"; +import { Button } from "@spacedrive/primitives"; +import type { TaskItem } from "@/api/client"; +import { BlockKindChip } from "./BlockKindChip"; + +/** + * States the real status of a blocked task, above the shared detail panel. + * + * That panel comes from `@spacedrive/ai`, which has no `blocked` status and + * crashes when handed one, so the drawer passes it an adapted copy reading + * `pending_approval`. Without this banner the drawer would quietly assert a + * status the task does not have and offer an Approve button for an approval + * nobody is waiting on. The adapted row stays — it cannot be removed from a + * component we do not own — but it is no longer the only thing the reader sees. + */ +export interface BlockedBannerProps { + task: TaskItem; + onUnblock?: (task: TaskItem) => void; + onRetry?: (task: TaskItem) => void; + busy?: boolean; +} + +export function BlockedBanner({ + task, + onUnblock, + onRetry, + busy, +}: BlockedBannerProps) { + if (task.status !== "blocked") return null; + + // A missing credential is not fixed by running the same task again, so the + // verb follows the kind — the same split the board makes. + const sticky = + task.block_kind === "needs_input" || task.block_kind === "capability"; + + return ( +
+
+ + + Blocked + + +
+ + {task.block_reason && ( +

+ {task.block_reason} +

+ )} + +

+ Not picked up automatically. The status control below reads + “pending approval” because the shared panel cannot render + this state. +

+ + {sticky && onUnblock && ( + + )} + {!sticky && onRetry && ( + + )} +
+ ); +} diff --git a/interface/src/components/tasks/ContractSection.tsx b/interface/src/components/tasks/ContractSection.tsx index b227d6f63..2d85d87d5 100644 --- a/interface/src/components/tasks/ContractSection.tsx +++ b/interface/src/components/tasks/ContractSection.tsx @@ -1,4 +1,6 @@ -import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@spacedrive/primitives"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCircleExclamation, @@ -18,22 +20,45 @@ export interface ContractSectionProps { } export function ContractSection({ taskNumber, onSelectTask }: ContractSectionProps) { + const queryClient = useQueryClient(); const { data } = useQuery({ queryKey: ["task-contract", taskNumber], queryFn: () => api.getTaskContract(taskNumber), }); + const save = useMutation({ + mutationFn: (body: {input_schema?: unknown; output_schema?: unknown}) => + api.setTaskContract(taskNumber, body), + onSuccess: () => + void queryClient.invalidateQueries({queryKey: ["task-contract", taskNumber]}), + }); + if (!data) return null; - return ; + return ( + save.mutate(body)} + saving={save.isPending} + saveError={save.error instanceof Error ? save.error.message : null} + /> + ); } /** Split from the fetching wrapper so it renders against fixtures. */ export function ContractSectionView({ data, onSelectTask, + onSaveSchemas, + saving, + saveError, }: { data: TaskContractResponse; onSelectTask?: (taskNumber: number) => void; + /** Omitted in read-only contexts such as the fixture harness. */ + onSaveSchemas?: (body: {input_schema?: unknown; output_schema?: unknown}) => void; + saving?: boolean; + saveError?: string | null; }) { const hasContract = data.input_schema != null || @@ -41,9 +66,10 @@ export function ContractSectionView({ data.bindings.length > 0 || data.outputs != null; - // Most tasks declare nothing. An empty "Contract" heading on every one of - // them would be noise that teaches people to skip the section. - if (!hasContract) return null; + // Most tasks declare nothing, and an empty "Contract" heading on every one + // of them would be noise. But a task with no contract is exactly the one + // somebody needs to give a contract to, so the editor still gets a way in. + if (!hasContract && !onSaveSchemas) return null; // Which keys the graph currently cannot supply, so each row can say so // rather than making the reader match a list of problems to a list of rows. @@ -119,10 +145,163 @@ export function ContractSectionView({ {data.output_schema != null && ( )} + + {onSaveSchemas && ( + + )} + + ); +} + +/** + * Declare the shape a task must produce. + * + * Humans define the contract; only a worker writes values into it. Setting an + * output schema here is what turns `task_complete` from "record whatever the + * model said" into a checked submission that is rejected when it does not fit. + * + * The JSON is validated locally before being sent, because the server stores a + * schema it cannot compile and only surfaces the problem later, at the moment + * a task tries to run. + */ +function SchemaEditor({ + inputSchema, + outputSchema, + onSave, + saving, + saveError, +}: { + inputSchema: unknown; + outputSchema: unknown; + onSave: (body: {input_schema?: unknown; output_schema?: unknown}) => void; + saving?: boolean; + saveError?: string | null; +}) { + const [open, setOpen] = useState(false); + const [inputText, setInputText] = useState(() => format(inputSchema)); + const [outputText, setOutputText] = useState(() => format(outputSchema)); + const [localError, setLocalError] = useState(null); + + if (!open) { + return ( + + ); + } + + const submit = () => { + const input = parse(inputText); + const output = parse(outputText); + if (input.error || output.error) { + setLocalError( + input.error + ? `Input schema: ${input.error}` + : `Output schema: ${output.error}`, + ); + return; + } + setLocalError(null); + onSave({input_schema: input.value, output_schema: output.value}); + setOpen(false); + }; + + return ( +
+ + + + {(localError || saveError) && ( +

+ {localError ?? saveError} +

+ )} + +
+ + +
+
+ ); +} + +function SchemaField({ + label, + hint, + value, + onChange, +}: { + label: string; + hint: string; + value: string; + onChange: (next: string) => void; +}) { + return ( +
+ +

{hint}

+