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
15 changes: 15 additions & 0 deletions docs/agent-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Managed runs

Open **Runs** in the sidebar (`#/runs`) to inspect managed Claude and Codex execution attempts. Search across session ID, directory, project, model and branch; filter by provider, state or exact project ID. Results use pages of 50. A run detail URL loads independently of the current page. Source links open the existing chat with its Decoded / Native controls; missing indexed chats retain their run history and show an unavailable-source explanation.

A run represents **one managed process attempt**, spanning multiple prompts. It is not a task or an individual conversation turn. Historical indexed sessions do not become runs until managed execution is requested. Native rollout adoption changes the session ID while keeping the attempt ID. Reattaching to a verified surviving tmux process also keeps the attempt ID. A replaced or confirmed-absent process closes the old attempt with an unknown outcome (`blocked`, with `ended_at`) and creates a fresh attempt. Terminal records cannot be updated by later observations.

States are `queued`, `starting`, `running`, `awaiting_input`, `blocked`, `completed`, `failed`, and `canceled`. Launch begins at `starting`; an attached transport remains `blocked` pending screen evidence. Successful decoded submission or native Enter records `running` with explicit input provenance. Native Enter does not prove what command was entered. Provider screen observations may indicate `awaiting_input`, `running`, or `blocked`, but are advisory: Claude's classifier defaults to idle, and native prompts are not reliable completion signals. Last-observed timestamps reflect real attach, input or screen observations, not a fabricated heartbeat. Old observations can be stale even if a transport remains attached.

Explicit termination records `canceled` after the termination operation returns. Confirmed launch failure records `failed`. A capture/attach failure with a verified surviving pane leaves an active, recoverable `blocked` attempt. Disconnect or server restart marks an attempt `blocked` with an unobserved/detached explanation. Shutdown detaches tmux attempts; direct PTY shutdown closes an unknown-outcome attempt because its process is terminated. No prompt, idle screen, process exit (including exit 0), or disconnect records successful completion. There is deliberately no “finish” operation: this slice does not infer or automate task outcomes. `completed` and `queued` are contract foundations for later explicit outcome/queue features.

The `agent_runs` SQLite table is independent of the rebuildable session index and has no cascading foreign key. Source indexing fills missing metadata while preserving recorded execution model/context. `task_id` and `parent_run_id` are explicitly nullable foundations; no task management, queue launch, or approval automation is implemented.

`GET /api/runs` returns typed `AgentRun[]`; optional `provider`, `state`, `project`, and `q` filters apply before `limit` (1–500, default 200) and `offset`. `GET /api/runs/{run_id}` returns one record or 404. Both endpoints only inspect durable records and indexed metadata; they never start providers or claim execution locks.

The execution manager writes lifecycle records before publishing `runs_changed` SSE notifications. Screen observations enter a manager-owned buffer directly from provider policy, rather than through a lossy SSE subscriber; the latest advisory observation is persisted every two seconds. The UI refreshes on confirmed-write notifications/reconnect and every 15 seconds to recover missed notifications. This is current-state history, not a complete event audit log. A partial unique index and immediate SQLite transaction prevent duplicate active attempts for a provider/session across independent store instances. Execution lifecycle operations and observation writes are serialized by the shared manager.
3 changes: 2 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const settingsModalImport = () => import("./components/Settings/SettingsModal");
// render a reload prompt in-place instead of crashing the whole ErrorBoundary.
const Sidebar = lazyWithRetry(() => import("./components/Sidebar/Sidebar"));
const ChatView = lazyWithRetry(chatViewImport);
const Runs = lazyWithRetry(() => import("./components/Runs"));
const Dashboard = lazyWithRetry(() => import("./components/Dashboard/Dashboard"));
const SettingsModal = lazyWithRetry(settingsModalImport);
const SearchOverlay = lazyWithRetry(searchOverlayImport);
Expand Down Expand Up @@ -332,7 +333,7 @@ export default function App() {
<Sidebar />
<div style={{ flex: 1, display: "flex", minWidth: 200, overflow: "hidden" }}>
<ErrorBoundary>
{chatIdFromUrl ? <ChatView /> : <Dashboard />}
{route.startsWith("/runs") ? <Runs /> : chatIdFromUrl ? <ChatView /> : <Dashboard />}
</ErrorBoundary>
</div>
{chatIdFromUrl && (
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ function patch<T>(path: string, body: unknown): Promise<T> {
}

export const api = {
getRuns: (params = "") => get<import("./types").AgentRun[]>(`/api/runs${params}`),
getRun: (id: string) => get<import("./types").AgentRun>(`/api/runs/${encodeURIComponent(id)}`),
getProjects: () => get<Project[]>("/api/projects"),
getProjectSessions: (projectId: string) =>
get<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions`),
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,25 @@ export interface DashboardData {
total_messages: number;
tips: DashboardTip[];
}

/** A managed execution attempt; independent of indexed session lifetime. */
export interface AgentRun {
id: string;
provider: string;
native_session_id: string;
model: string | null;
project_id: string | null;
cwd: string | null;
git_branch: string | null;
is_worktree: boolean;
task_id: string | null;
parent_run_id: string | null;
state: "queued" | "starting" | "running" | "awaiting_input" | "blocked" | "completed" | "failed" | "canceled";
reason: string;
provenance: string;
created_at: string;
updated_at: string;
last_observed_at: string | null;
ended_at: string | null;
source_available: boolean;
}
26 changes: 26 additions & 0 deletions frontend/src/components/Runs.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.runs-page { padding: 28px; overflow: auto; flex: 1; min-width: 0; color: var(--text-primary); font-family: var(--font-ui); }
.runs-page header, .runs-filters, .runs-list, .runs-pagination, .run-detail { max-width: 1100px; margin-inline: auto; }
.runs-page header p, .run-muted, .runs-page small, .run-detail dt { color: var(--text-secondary); }
.runs-page h1 { font-size: 26px; margin-top: 0; }
.runs-page h2 { font-size: 18px; margin: 0; }
.runs-page p { line-height: 1.5; }
.runs-page a { color: var(--text-accent); text-underline-offset: 3px; }
.runs-filters { display: flex; align-items: end; gap: 12px; flex-wrap: wrap; margin-block: 24px; }
.runs-filters label { display: grid; gap: 6px; flex: 1 1 140px; font-size: 12px; }
.runs-page input, .runs-page select, .runs-page button { font: inherit; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--border-default); border-radius: var(--radius-sm); padding: 8px 10px; min-height: 36px; min-width: 0; }
.runs-page input, .runs-page select { width: 100%; box-sizing: border-box; }
.runs-page button { cursor: pointer; }
.runs-page button:hover:not(:disabled) { background: var(--bg-sidebar-hover); }
.runs-page button:disabled { opacity: .45; cursor: default; }
.runs-page :focus-visible { outline: 2px solid var(--border-accent); outline-offset: 2px; }
.runs-list { display: grid; gap: 12px; }
.run-card { border: 1px solid var(--border-default); border-radius: var(--radius-md); padding: 18px; overflow-wrap: anywhere; background: var(--bg-base); }
.run-card-title { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.run-state { font-size: 12px; border: 1px solid var(--border-default); padding: 3px 8px; border-radius: 20px; }
.run-state-blocked, .run-state-failed { color: var(--tool-error-text); }
.run-detail { box-sizing: border-box; margin-bottom: 20px; }
.run-detail dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px 24px; }
.run-detail dt { font-size: 12px; margin-bottom: 4px; }
.run-detail dd { margin: 0; font-size: 13px; }
.runs-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; padding-block: 20px; }
@media (max-width: 600px) { .runs-page { padding: 16px; } .run-card { padding: 14px; } .run-detail dl { grid-template-columns: 1fr; } }
97 changes: 97 additions & 0 deletions frontend/src/components/Runs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { AgentRun } from "../api/types";
import { navigateTo, useRoute } from "../router";
import { useDebounce } from "../utils/useDebounce";
import "./Runs.css";

const states = ["queued", "starting", "running", "awaiting_input", "blocked", "completed", "failed", "canceled"];
const time = (value: string | null) => value ? new Date(value).toLocaleString() : "Not yet observed";
const label = (value: string) => value.replace(/_/g, " ");
const observation = (run: AgentRun) => !run.last_observed_at ? "—" : `${time(run.last_observed_at)}${!run.ended_at && Date.now() - Date.parse(run.last_observed_at) > 30000 ? " (stale)" : ""}`;

export default function Runs() {
const route = useRoute();
const runId = route.startsWith("/runs/") ? route.slice(6) : null;
const [detail, setDetail] = useState<AgentRun | null>(null);
const [detailMissing, setDetailMissing] = useState(false);
const [page, setPage] = useState(0);
const [runs, setRuns] = useState<AgentRun[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const query = useDebounce(search, 200);
const [provider, setProvider] = useState("");
const [state, setState] = useState("");
const [project, setProject] = useState("");
const projectQuery = useDebounce(project, 200);
const generation = useRef(0);
const refresh = useCallback(async () => {
const request = ++generation.current;
const params = new URLSearchParams({ provider, state, project: projectQuery, q: query, limit: "50", offset: String(page * 50) });
try {
const rows = await api.getRuns(`?${params}`);
let selected: AgentRun | null = null;
let missing = false;
if (runId) {
try { selected = await api.getRun(runId); }
catch (e) { if (e instanceof Error && e.message.includes("404")) missing = true; else throw e; }
}
if (request !== generation.current) return;
setRuns(rows); setDetail(selected); setDetailMissing(missing); setError("");
} catch (e) {
if (request === generation.current) setError(e instanceof Error ? e.message : "Could not load runs");
} finally { if (request === generation.current) setLoading(false); }
}, [provider, state, projectQuery, query, page, runId]);
useEffect(() => {
setLoading(true);
void refresh();
const events = new EventSource("/api/events");
events.onmessage = (event) => {
try { if (["runs_changed", "refresh"].includes(JSON.parse(event.data).type)) void refresh(); } catch { /* ignore malformed event */ }
};
events.onopen = () => void refresh();
const timer = window.setInterval(() => void refresh(), 15000);
return () => { ++generation.current; events.close(); window.clearInterval(timer); };
}, [refresh]);
const filtered = !!(provider || state || project || search || page);
return <main className="runs-page">
<header><h1>Runs</h1><p>Execution history across Claude and Codex.</p></header>
<div className="runs-filters">
<label>Search<input value={search} onChange={(e) => { setPage(0); setSearch(e.target.value); }} placeholder="Branch, model, session…" /></label>
<label>Provider<select value={provider} onChange={(e) => { setPage(0); setProvider(e.target.value); }}><option value="">All providers</option><option>claude</option><option>codex</option></select></label>
<label>State<select value={state} onChange={(e) => { setPage(0); setState(e.target.value); }}><option value="">All states</option>{states.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select></label>
<label>Project<input value={project} onChange={(e) => { setPage(0); setProject(e.target.value); }} placeholder="Project ID (exact)" /></label>
<button onClick={() => void refresh()}>Refresh</button>
</div>
{error && <p role="alert">{error} <button onClick={() => void refresh()}>Retry</button></p>}
{loading ? <p role="status">Loading runs…</p> : <>
{runId && (detail ? <RunDetail run={detail} /> : detailMissing ? <p role="status">Run not found. <a href="#/runs">View all runs</a></p> : null)}
{!error && !runs.length && <p role="status">{filtered ? "No runs match these filters." : "No managed runs yet. Open a session and attach its managed terminal to create a run. Historical chats are not automatically live runs."}</p>}
<div className="runs-list">{runs.map((run) => <article key={run.id} className="run-card">
<div className="run-card-title"><a href={`#/runs/${run.id}`}><strong>{run.provider} · {run.model || "Model unknown"}</strong></a><span className={`run-state run-state-${run.state}`}>{label(run.state)}</span></div>
<p>{run.project_id || run.cwd || "Project unknown"} · {run.git_branch || "Branch unknown"}{run.is_worktree ? " · Worktree" : ""}</p>
<p>{run.reason}</p>
<small>Started {time(run.created_at)} · Last observed {observation(run)}</small>
<p><SourceLink run={run} /></p>
</article>)}</div>
<nav className="runs-pagination" aria-label="Run pages"><button disabled={!page || loading} onClick={() => setPage(page - 1)}>Previous</button><span>Page {page + 1}</span><button disabled={runs.length < 50 || loading} onClick={() => setPage(page + 1)}>Next</button></nav>
</>}
</main>;
}

function SourceLink({ run }: { run: AgentRun }) {
return run.source_available ? <a href={`#/chat/${run.native_session_id}`}>Open decoded chat / native terminal</a> : <span className="run-muted">Source chat unavailable; run history retained</span>;
}

function RunDetail({ run }: { run: AgentRun }) {
const values = { "Run ID": run.id, "Native session": run.native_session_id, State: label(run.state), Reason: run.reason,
"State evidence": run.provenance, Provider: run.provider, Model: run.model || "Unknown", Project: run.project_id || "Unknown", Directory: run.cwd || "Unknown", Branch: run.git_branch || "Unknown",
Worktree: run.is_worktree ? "Yes" : "No", Created: time(run.created_at), Updated: time(run.updated_at), "Last observed": observation(run), Ended: run.ended_at ? time(run.ended_at) : "No ending recorded", Task: run.task_id || "Not linked", "Parent run": run.parent_run_id || "Not linked" };
return <section aria-label="Run details" className="run-card run-detail">
<div className="run-card-title"><h2>Run details</h2><button onClick={() => navigateTo("/runs")}>Close details</button></div>
<dl>{Object.entries(values).map(([key, value]) => <div key={key}><dt>{key}</dt><dd>{value}</dd></div>)}</dl>
<p><SourceLink run={run} /></p>
{!run.ended_at && <p className="run-muted">An idle prompt or process exit does not establish task success. Live state is best-effort. Check the last observation time; restart or transport loss may leave this attempt detached or unobserved.</p>}
</section>;
}
5 changes: 5 additions & 0 deletions frontend/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ function IconSearch() {
);
}

function IconRuns() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="m9 7 7 5-7 5z"/></svg>;
}

function IconChats() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
Expand Down Expand Up @@ -931,6 +935,7 @@ export default function Sidebar() {

{/* Nav items */}
<div style={{ padding: "6px 0 4px", flexShrink: 0 }}>
<NavItem collapsed={textCollapsed} icon={<IconRuns />} label="Runs" onClick={() => navigateTo("/runs")} />
<NavItem collapsed={textCollapsed} icon={<IconSearch />} label="Search" shortcut="⌘K" onClick={openSearch} />

{/* Starred section — only shown when there are starred sessions */}
Expand Down
Loading
Loading