Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ Operator (SPA) approves/denies via
| `src/jobs/types.rs` | `TriggerReason`, `ClaudeOutput`. `TriggerReason::event_id(project_id)` builds the `seen_events` dedupe key, prefixed with `project_id` (#159 H1) then discriminated per variant: `Issue` hashes title+description, `ReviewMR` keys on `head_sha` (#152 W6), `FixReview` keys on `review_id` (GitHub's review `html_url`, GitLab's MR `updated_at` — GitLab reviews carry no body, so every approve/unapprove used to hash to the same key, #159 M1), and `MRComment`/`IssueComment` key on the comment's own `url` rather than hashing its text, so an intentional identical repeat isn't deduped as a redelivery (#159 M2); each falls back to its pre-fix hash when the provider omitted the discriminator |
| `src/jobs/store.rs` | `TaskStore` core — the run loop (`confirm_task` + branch-conflict guard), `set_pid`/`set_session_id`, hub handle, and the **queue-admission** plumbing (`admit_lock`, `request_admit`, `run_admission_loop`). `confirm_task` claims the task with an atomic **compare-and-swap** (`claim_pending`: `agent_state` cold→pending, one winner) so two racing confirms can't both spawn an agent into one worktree (#142 F4); the spawned run future then **registers its own admission entry as its first action** (over a oneshot-delivered abort handle) and unregisters as its last, so a fast-failing run can't have the parent register a permanently-stale entry that inflates the admission gauge forever (#142 F5). `mark_seen`/`unmark_seen` claim and (on a failed dispatch) release the dedupe key so a redelivery isn't dropped (#142 F9). Lifecycle/CRUD/query helpers live in the sibling modules below (store.rs was split at the 400-line cap) |
| `src/jobs/edits.rs` | `TaskStore::update_task` (the operator PATCH, incl. queue/priority edits) + the `TaskEdits` payload — split out of `store.rs` at the cap |
| `src/jobs/results.rs` | `TaskStore` per-run metrics persistence into `task_sessions` (`replace_result`/`save_result`/`save_error_result`) — split out of `store.rs` at the cap |
| `src/jobs/results.rs` | `TaskStore` per-run metrics persistence into `task_sessions` (`replace_result`/`save_result`/`save_error_result`) — also sets the two cache-token columns (`cache_creation_input_tokens`/`cache_read_input_tokens`), reading `ClaudeOutput.usage` — split out of `store.rs` at the cap |
| `src/jobs/usage_backfill.rs` | `backfill_session_usage` — startup sweep (#163) that recovers historical `task_sessions` token counts (pre-fix rows stuck at `NULL`/0) from the raw `result` events already persisted in `events.payload`; modeled on `recover_orphans`/`ProjectDb::sweep_orphans` |
| `src/jobs/lifecycle.rs` | `derive_agent_state` (read-time overlay: `is_running` → `running`, `is_warm` → `warm`, else the durable `cold\|pending\|failed`), state-name constants, the `migrate_status` backfill mapping (testable), and the durable-state transition helpers on `TaskStore` — `recover_orphans` (also best-effort SIGKILLs each orphan's leftover agent **process group** by its recorded pid before nulling it, guarding pid reuse via a `/proc/<pid>/cmdline` sniff, so a previous hard-SIGKILL/OOM doesn't leave live agents in worktrees a recovered task may re-enter — #142 F8), `claim_pending` (the confirm CAS, #142 F4), `set_states`, `finish_task(agent_state, task_state, note)` (clears the recorded pid at a terminal finish so a later pid-reuse can't mistarget the reaper/group-kill — #142 F17), `mark_branch_released` (issue/PR-close → terminal `released`), `note_task_result`, `publish_state` |
| `src/jobs/control.rs` | `TaskStore` operator controls — `kill_task` (Pause → durable `cold` / `task_state` `working_on` + "paused by operator" note), `delete_task` (also **releases the task's `seen_events` claim** via `unmark_seen`, recomputed from its stored trigger, before reclaiming the branch worktree via `reclaim_worktree_for_task` — #159 M6, #103), `continue_task` (Resume), `push_message`. Pause/delete additionally **SIGKILL the whole agent process group** by the recorded pid (`kill_task_process_group`), not just the `kill_on_drop` leader, so backgrounded grandchildren die too — and delete's reclaim **skips (keeps) the worktree when a live sibling shares the branch** (`branch_is_live`), since a retry may be mid-run in it (#142 F7/F11). `continue_task` resets a resumed task to `cold` (not `pending`) via a **conditional UPDATE that refuses when `agent_state` is already `pending`** — an unconditional reset let a second concurrent `continue_task`/`push_message` stomp a spawn already claimed by `confirm_task`'s CAS (a still-unregistered #83 window) and double-spawn the agent (#159 H2) — so a bailed `confirm_task` leaves it `cold`, never a stuck `pending` that deadlocks its branch (#142 F10). `push_message`'s cold path now also accepts a never-started (`pending`, no `session_id`) task, since `confirm_task` doesn't need one (#159 M5). Both pause/delete and session-end auto-deny any still-`pending` `auth_requests` for the task (`deny_pending_auth`), so a parked approval that can no longer be answered clears from the UI (#142 F15) |
| `src/jobs/pending_message.rs` | `TaskStore::take_pending_message` (atomic read-and-clear-if-unchanged — replaces the old read-then-unconditional-NULL that could drop a comment appended mid-spawn, #159 H2) and `append_pending_message` (persist-then-merge, shared by `push_message`'s cold path and the turn loop's stray-message drain, #159 M3); split out of `control.rs` at the 400-line cap |
Expand Down Expand Up @@ -144,7 +145,7 @@ Tables (current set, see migration files for canonical schemas):
- `queues` — the **task queue** catalog (#39): `id`, `name`, `priority` (smallint, queue-vs-queue ordering, higher = sooner), `created_at`. An operator-orderable backlog bucket; the migration seeds one `Default` queue (stable UUID) so a fresh install can enqueue with no setup. The scheduling logic lives in `jobs/queue.rs`
- `tasks` — one row per work item, with **two orthogonal state axes**. `task_state` (persisted, operator-owned lifecycle: `pending|working_on|completed|paused|failed`) is auto-advanced by the runner but freely PATCH-overridable. `paused` marks a warm session whose last turn ended before finishing (a non-`success` `result` subtype like `error_max_turns`) — resumable, and distinct from `completed` so turn-end isn't mistaken for "done" (#106). One further **terminal** value, `released`, is **system-set only** (not PATCH-overridable, not in the operator `TASK_STATES` set): the runner's release path (`mark_branch_released`) moves a task there when its issue/PR closes and the worktree is reclaimed, keeping the row + `task_sessions`/events for history rather than deleting them (#103). `agent_state` is **derived at read time** by `derive_agent_state` (`cold|warm|pending|running|failed`) — its persisted backing column narrows to `cold|pending|failed`, and `warm`/`running` are overlaid from the hub's in-memory liveness flags, never stored. There is no `killed` state — the disposition reason (paused / budget / orphan) is recorded as `task_sessions` text instead. `pending_message` carries a queued follow-up for the resume path. **Normalized:** a task carries only `project_id` (NOT NULL, `ON DELETE CASCADE`) plus `branch` and the working `session_id`; remote URL, default branch, provider and owning service are resolved at run time through `project_id → projects → service` (not duplicated on the row), and the API re-derives them onto `TaskView` so the SPA still sees them. `model_id` (nullable FK → `models`) is the task's selected model (see `service_models` above). **Queue link (#39):** `queue_id` (nullable FK → `queues`, `ON DELETE SET NULL` — deleting a queue un-queues its tasks) + `priority` (smallint, default 0, in-queue sort key); indexed `(queue_id, priority)`. The scheduler only pulls tasks with `queue_id` set; both are operator-editable via PATCH **while pending**. "Filter out completed" is the `task_state = pending` predicate the picker uses
- `events` — durable hub-frame stream; one row per frame of any kind (agent event, `auth_request`, `status`) distinguished by the `kind` column, PK `(task_id, seq)`, each frame consuming a unique seq, append-only, batch-inserted (100 at a time) by the live hub, cascade-deleted with the task. **Turns are derived, not stored:** the SPA folds this flat stream into per-turn input/output (`useTurns.ts`) from the `type:"user"` (input, via `--replay-user-messages`) and `type:"result"` (output) markers already present — no `turn_index` column, no turns table, no second writer in the hot path
- `task_sessions` — per-run cost / turns / tokens / result text + the run's claude `session_id` and `created_at`. **1:N with tasks**: turns within one agent run (same `session_id`) accumulate into a single row; a new run (fresh `session_id` — a resume after kill, or a retry) starts a new row, so run history is preserved. `get_task` / the detail view surface the most recent row
- `task_sessions` — per-run cost / turns / tokens / result text + the run's claude `session_id` and `created_at`. **1:N with tasks**: turns within one agent run (same `session_id`) accumulate into a single row; a new run (fresh `session_id` — a resume after kill, or a retry) starts a new row, so run history is preserved. `get_task` / the detail view surface every row (newest first), with the newest also exposed as the single-row `result` summary for the existing UI. `input_tokens`/`output_tokens` plus the two nullable `cache_creation_input_tokens`/`cache_read_input_tokens` columns (#163) come from the `claude` CLI's `result` event `usage.*` object — nested, not top-level (a pre-#163 bug read them from the wrong shape and always got 0). **`NULL` on the two cache columns is the "not yet backfilled" marker**: every write path since the fix sets `Some(...)` (error paths `Some(0)`), and a startup sweep (`src/jobs/usage_backfill.rs::backfill_session_usage`, spawned from `main.rs`) recovers historical rows from the raw `result` events already persisted in `events.payload`, taking the last event per `session_id` (highest `seq` — the `result` event's counters are session-cumulative, not a per-turn delta) and zeroing rows with no matching event. The sweep is idempotent — every row it touches leaves the `NULL` selection, so a re-run (or a redeploy) touches nothing once caught up
- `projects` — discovered repos, per-project `allowed_operations` glob list and `env_file` (a `.env`-style minijinja template injected as env vars at agent spawn). Per-trigger-type model overrides live in the sibling `project_models` table (see below), surfaced as `ProjectDetailResponse.models`
- `project_branches` — branches the agent has touched, with `issue_iid` / `pr_iid` linkage and status
- `auth_requests` — operator-approval items raised by the in-process permission handler
Expand All @@ -163,9 +164,9 @@ Bearer-auth gates `/api/*` (and the SPA, when `API_BEARER_TOKEN` is set). `/webh
| `GET` | `/ws` | **Single app-wide** WebSocket live stream (multiplexes all tasks). In-band auth (first frame = token). Outbound `Envelope` frames (`task_id`/`event`\|`auth_request`\|`status`); inbound `{kind: chat\|redefine\|stop, task_id}` routed to the agent stdin |
| `GET` | `/api/tasks` | optional `?task_state=` (SQL `WHERE` on the persisted column) and `?agent_state=` (derived per task, filtered in-memory — acceptable at single-operator scale). Each task emits both `task_state` and the derived `agent_state`; no `live` boolean |
| `POST` | `/api/tasks` | operator-driven dispatch: `{ project_id, trigger: TriggerReason }` → pending task (use when the webhook missed/was filtered) |
| `GET` | `/api/tasks/stats` | time spent per `?group_by=project\|service\|branch\|trigger_type` within `?from=`/`?to=` (default last 30d). Optional `?service_id=`/`?project_id=`/`?branch=` narrow the set before aggregation (`service_id` resolved via the project, the other two are `tasks` columns). Running tasks counted as `now - started_at`. |
| `GET` | `/api/tasks/stats` | time spent **and token/cost usage** per `?group_by=project\|service\|branch\|trigger_type` within `?from=`/`?to=` (default last 30d). Optional `?service_id=`/`?project_id=`/`?branch=` narrow the set before aggregation (`service_id` resolved via the project, the other two are `tasks` columns). Running tasks counted as `now - started_at`. Each row and the response also carry `cost_usd`/`input_tokens`/`output_tokens`/`cache_creation_input_tokens`/`cache_read_input_tokens` (`total_*` at the top level), summed across every `task_sessions` row of the tasks in that bucket — attributed to the owning **task's** `created_at` window, not the session's own timestamp, matching time-spent |
| `POST` | `/api/tasks/bulk` | apply one action across many tasks: `{ action: run\|pause\|resume\|delete, ids: [..] }`. `run` confirms a pending task or resumes a paused one (picked per task). Reuses the per-task store methods and returns `{ succeeded: [id], failed: [{ id, error }] }` — a bad row doesn't sink the batch. Backs the SPA's multi-select toolbar |
| `GET`/`PATCH`/`DELETE` | `/api/tasks/{id}` | detail + latest session; PATCH sets `task_state` to any of `pending\|working_on\|completed\|paused\|failed` on **any** task (operator override of the auto-advance), and edits the run *inputs* — `branch`, the trigger's `title`/`description` (patched into `trigger_data`, which drives the prompt), plus the queue fields `queue_id` (double-option: omit = unchanged, `null` = dequeue, id = enqueue) and `priority` — only while `task_state == pending` (before the task is tied to a run); the branch may not equal the project's default branch, and `title`/`description` are accepted only for triggers that carry those fields. Enqueuing/reprioritizing fires `try_admit_next`. DELETE force-kills if running, **reclaims the branch worktree** (best-effort, mirroring the close/merge release — #103), then drops the row (and backfills a freed slot from the queue) |
| `GET`/`PATCH`/`DELETE` | `/api/tasks/{id}` | detail: `result` (the newest `task_sessions` row, kept for the existing UI) plus `sessions` (every row for the task, newest first — the full per-run cost/token breakdown, #163); PATCH sets `task_state` to any of `pending\|working_on\|completed\|paused\|failed` on **any** task (operator override of the auto-advance), and edits the run *inputs* — `branch`, the trigger's `title`/`description` (patched into `trigger_data`, which drives the prompt), plus the queue fields `queue_id` (double-option: omit = unchanged, `null` = dequeue, id = enqueue) and `priority` — only while `task_state == pending` (before the task is tied to a run); the branch may not equal the project's default branch, and `title`/`description` are accepted only for triggers that carry those fields. Enqueuing/reprioritizing fires `try_admit_next`. DELETE force-kills if running, **reclaims the branch worktree** (best-effort, mirroring the close/merge release — #103), then drops the row (and backfills a freed slot from the queue) |
| `POST` | `/api/tasks/{id}/confirm` | pending → queued (durable `agent_state` pending) |
| `POST` | `/api/tasks/{id}/retry` | clone the task as a new row |
| `POST` | `/api/tasks/{id}/kill` | SIGKILL; preserves session_id for Resume |
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/components/TaskResultPanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import TaskResultPanel from "./TaskResultPanel.vue";
import type { TaskResult } from "../types/api";

function makeResult(over: Partial<TaskResult> = {}): TaskResult {
return {
id: "r1",
task_id: "t1",
cost_usd: 0.1234,
input_tokens: 100,
output_tokens: 200,
cache_creation_input_tokens: 50,
cache_read_input_tokens: 75,
num_turns: 3,
is_error: false,
result_text: "done",
session_id: "abcd1234efgh5678",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}

describe("TaskResultPanel", () => {
it("renders nothing when result is null", () => {
const w = mount(TaskResultPanel, { props: { result: null, sessions: [] } });
expect(w.text()).toBe("");
});

it("renders the summary for a single session", () => {
const result = makeResult();
const w = mount(TaskResultPanel, { props: { result, sessions: [result] } });
expect(w.text()).toContain("0.1234");
expect(w.text()).toContain("3");
expect(w.text()).toContain("100");
expect(w.text()).toContain("200");
});

it("renders — for null cache token fields instead of 0", () => {
const result = makeResult({ cache_creation_input_tokens: null, cache_read_input_tokens: null });
const w = mount(TaskResultPanel, { props: { result, sessions: [result] } });
expect(w.text()).toContain("—");
expect(w.text()).not.toMatch(/Cache read.*\b0\b/);
});

it("renders the per-session table when there is more than one session", () => {
const s1 = makeResult({ id: "r1", session_id: "session-one-aaa", cost_usd: 0.1 });
const s2 = makeResult({ id: "r2", session_id: "session-two-bbb", cost_usd: 0.2 });
const w = mount(TaskResultPanel, { props: { result: s1, sessions: [s1, s2] } });
expect(w.find("table").exists()).toBe(true);
expect(w.text()).toContain("0.1000");
expect(w.text()).toContain("0.2000");
});

it("does not render the per-session table for a single session", () => {
const result = makeResult();
const w = mount(TaskResultPanel, { props: { result, sessions: [result] } });
expect(w.find("table").exists()).toBe(false);
});

it("does not render the per-session table for zero sessions", () => {
const result = makeResult();
const w = mount(TaskResultPanel, { props: { result, sessions: [] } });
expect(w.find("table").exists()).toBe(false);
});
});
Loading
Loading