diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d8dd2..6a393d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ the image workflows publish (plus `latest` from every push to `main`). ## [Unreleased] +### Fixed + +- **Agents missing freshly pushed commits.** A spawn with no explicit placement ran + the agent in the shared project-root checkout, and the root only fast-forwards + while parked on the default branch — so as soon as one agent left it on a feature + branch, every later no-placement spawn (the mobile app always, the web form with an + empty branch field) started from a stale tree even though the push had been + fetched. Operator spawns on a git root now default to a fresh worktree on + `agent/` cut from `origin/HEAD` — always the remote's latest push, and real + per-agent isolation (README's "one working directory or git worktree per agent"). + Explicit worktree/subdir placements are honored unchanged; schedule firings and the + mise-init bootstrap keep root placement (their conventions depend on the root + tree); non-git roots are untouched. 4 regression tests. + +### Added + +- **Tappable fleet stat cards** in the mobile app: Running / Waiting / Done now open a + full agent list pre-filtered to that bucket (same grouping as the counts), showing + every agent row the API knows — including agents that haven't dropped a checkmark + yet — with status badges, a live last-output line for running agents, and + tap-through to the agent detail screen (back returns to the list). + ### Added - **Activity screen in the mobile app** (Settings → Manage → Activity): the diff --git a/app/App.tsx b/app/App.tsx index 96f61d7..343a038 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -49,6 +49,7 @@ import { ClaudeLoginScreen } from "./src/screens/manage/ClaudeLoginScreen"; import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig"; import { ConnectScreen } from "./src/screens/ConnectScreen"; import { FleetScreen } from "./src/screens/FleetScreen"; +import { AgentListScreen } from "./src/screens/AgentListScreen"; import { AgentDetailScreen } from "./src/screens/AgentDetailScreen"; import { AnswerScreen } from "./src/screens/AnswerScreen"; import { SpawnScreen } from "./src/screens/SpawnScreen"; @@ -63,6 +64,7 @@ function Router() { const screens: Record React.JSX.Element> = { connect: ConnectScreen, fleet: FleetScreen, + agentList: AgentListScreen, detail: AgentDetailScreen, answer: AnswerScreen, spawn: SpawnScreen, diff --git a/app/README.md b/app/README.md index 2041382..cf5497c 100644 --- a/app/README.md +++ b/app/README.md @@ -23,7 +23,8 @@ npm run ios # opens the iOS simulator (requires Xcode) | Screen | File | What it does | | --- | --- | --- | -| Fleet (home) | `src/screens/FleetScreen.tsx` | Stat cards, "Waiting on you" list → Answer, "Recent checkmarks" → detail | +| Fleet (home) | `src/screens/FleetScreen.tsx` | Tappable stat cards → filtered agent list, "Waiting on you" → Answer, "Recent checkmarks" → detail | +| Agent list | `src/screens/AgentListScreen.tsx` | Every agent (checkmark or not) with All / Running / Waiting / Done filters → detail | | Agent detail | `src/screens/AgentDetailScreen.tsx` | Checkmark / Events / Log segmented control, live headless run event stream, meta table (incl. model backend + worker), Answer / Kill | | Answer | `src/screens/AnswerScreen.tsx` | Question, tappable quick replies, reply field + **Send & resume** | | Spawn | `src/screens/SpawnScreen.tsx` | Project select, model backend select, task field, Spawn | diff --git a/app/src/screens/AgentDetailScreen.tsx b/app/src/screens/AgentDetailScreen.tsx index 6fb7837..958fcd5 100644 --- a/app/src/screens/AgentDetailScreen.tsx +++ b/app/src/screens/AgentDetailScreen.tsx @@ -25,6 +25,7 @@ export function AgentDetailScreen() { openAnswer, detailTab, setDetailTab, + detailReturnTo, models, selectedAgent, selectedCheckmark, @@ -38,7 +39,11 @@ export function AgentDetailScreen() { - go("fleet")} title="Agent" /> + go(detailReturnTo)} + title="Agent" + /> This agent is no longer in the fleet. @@ -95,7 +100,7 @@ export function AgentDetailScreen() { > go("fleet")} + onLeadingPress={() => go(detailReturnTo)} agentId={agent.name} badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }} /> diff --git a/app/src/screens/AgentListScreen.tsx b/app/src/screens/AgentListScreen.tsx new file mode 100644 index 0000000..13b1e22 --- /dev/null +++ b/app/src/screens/AgentListScreen.tsx @@ -0,0 +1,159 @@ +import React, { useMemo } from "react"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { text } from "../theme/tokens"; +import { useTheme } from "../theme/useTheme"; +import { Badge } from "../components/Badge"; +import { Chip } from "../components/Chip"; +import { Icon } from "../components/Icon"; +import { PageHeader } from "../components/PageHeader"; +import { Card, Divider, Mono, SectionLabel } from "../components/primitives"; +import { useAppState, type AgentFilter } from "../state/AppState"; +import { statusLabel, statusTone, timeAgo } from "../api/format"; +import type { Agent } from "../api/client"; + +/** + * The full agent roster, reached by tapping a fleet stat card. Unlike the fleet's + * "Recent checkmarks" list this shows every agent row the API knows — an agent that + * hasn't dropped a checkmark yet is still visible here the moment it spawns. The + * filter buckets use exactly the same grouping as the stat-card counts, so the + * number tapped is the number listed. + */ + +const FILTERS: { key: AgentFilter; label: string }[] = [ + { key: "all", label: "All" }, + { key: "running", label: "Running" }, + { key: "waiting", label: "Waiting" }, + { key: "done", label: "Done" }, +]; + +function isRunning(a: Agent): boolean { + const s = a.status.toLowerCase(); + return s === "working" || s === "running"; +} + +function isDone(a: Agent): boolean { + const s = a.status.toLowerCase(); + return s === "done" || s === "failed"; +} + +export function AgentListScreen() { + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + const { go, openDetail, agents, waiting, agentFilter, setAgentFilter } = + useAppState(); + + // Waiting matches the fleet's "waiting on you" derivation (paused agents plus + // open checkmark questions), keyed the same way the store builds it. + const waitingKeys = useMemo( + () => new Set(waiting.map((w) => `${w.project}/${w.name}`)), + [waiting], + ); + + const rows = useMemo(() => { + switch (agentFilter) { + case "running": + return agents.filter(isRunning); + case "waiting": + return agents.filter((a) => waitingKeys.has(`${a.project_id}/${a.name}`)); + case "done": + return agents.filter(isDone); + default: + return agents; + } + }, [agents, agentFilter, waitingKeys]); + + return ( + + + + go("fleet")} title="Agents" /> + + {FILTERS.map((f) => ( + setAgentFilter(f.key)} + /> + ))} + + + + + + {`${rows.length} agent${rows.length === 1 ? "" : "s"}`} + + {rows.length === 0 ? ( + + {agentFilter === "all" + ? "No agents yet — spawn one from the Fleet screen." + : `No ${agentFilter} agents right now.`} + + ) : ( + + {rows.map((a, i) => ( + + {i > 0 && } + [ + styles.row, + pressed && { backgroundColor: colors.surfaceSunken }, + ]} + onPress={() => openDetail(a.project_id, a.name, "agentList")} + > + + + + {a.name} + + {statusLabel(a.status)} + + + {a.project_id} + {a.role ? ` · ${a.role}` : ""} + {` · started ${timeAgo(a.created_at)} ago`} + + {a.last_output?.trim() && isRunning(a) ? ( + + {a.last_output.trim().split("\n").pop()} + + ) : null} + + + + + ))} + + )} + + + ); +} + +const styles = StyleSheet.create({ + page: { flex: 1 }, + header: { paddingTop: 8, paddingHorizontal: 20 }, + filters: { flexDirection: "row", gap: 8, paddingBottom: 12, paddingRight: 20 }, + body: { paddingHorizontal: 20 }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 12, + paddingHorizontal: 16, + minHeight: 44, + }, + titleRow: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }, +}); diff --git a/app/src/screens/FleetScreen.tsx b/app/src/screens/FleetScreen.tsx index 074a9cb..365363f 100644 --- a/app/src/screens/FleetScreen.tsx +++ b/app/src/screens/FleetScreen.tsx @@ -22,6 +22,7 @@ import { import { TabBar } from "../components/TabBar"; import { useAppState, + type AgentFilter, type RecentItem, type WaitingItem, } from "../state/AppState"; @@ -29,15 +30,25 @@ import { export function FleetScreen() { const { colors } = useTheme(); const insets = useSafeAreaInsets(); - const { go, openAnswer, openDetail, waiting, recent, counts, loading, error, refresh } = - useAppState(); + const { + go, + openAnswer, + openDetail, + openAgentList, + waiting, + recent, + counts, + loading, + error, + refresh, + } = useAppState(); const empty = waiting.length === 0 && recent.length === 0; - const stats = [ - { label: "Running", value: counts.running, tint: colors.textHeading }, - { label: "Waiting", value: counts.waiting, tint: colors.warning }, - { label: "Done", value: counts.done, tint: colors.textHeading }, + const stats: { label: string; value: number; tint: string; filter: AgentFilter }[] = [ + { label: "Running", value: counts.running, tint: colors.textHeading, filter: "running" }, + { label: "Waiting", value: counts.waiting, tint: colors.warning, filter: "waiting" }, + { label: "Done", value: counts.done, tint: colors.textHeading, filter: "done" }, ]; return ( @@ -61,14 +72,27 @@ export function FleetScreen() { {stats.map((s) => ( - - - {s.label} - - - {s.value} - - + openAgentList(s.filter)} + > + {({ pressed }) => ( + + + {s.label} + + + {s.value} + + + )} + ))} @@ -219,7 +243,7 @@ const styles = StyleSheet.create({ marginBottom: 20, }, statsRow: { flexDirection: "row", gap: 12, marginBottom: 20 }, - statCard: { flex: 1, paddingVertical: 14, paddingHorizontal: 16 }, + statCard: { paddingVertical: 14, paddingHorizontal: 16 }, statValue: { fontFamily: fonts.monoSemiBold, fontSize: 24, diff --git a/app/src/state/AppState.tsx b/app/src/state/AppState.tsx index accf3d2..e334ebf 100644 --- a/app/src/state/AppState.tsx +++ b/app/src/state/AppState.tsx @@ -38,6 +38,7 @@ import { useServerConfig } from "./ServerConfig"; export type Screen = | "connect" | "fleet" + | "agentList" | "detail" | "answer" | "spawn" @@ -62,6 +63,8 @@ export type Screen = | "claudeLogin"; export type DetailTab = "state" | "events" | "log"; +/* Status buckets for the agent list, matching the fleet counts exactly. */ +export type AgentFilter = "all" | "running" | "waiting" | "done"; export type BadgeTone = "neutral" | "positive" | "warning" | "danger"; export type RecentTone = "positive" | "danger"; @@ -114,13 +117,21 @@ interface AppStateValue { go: (screen: Screen) => void; setDetailTab: (tab: DetailTab) => void; setLogFilter: (f: string) => void; - openDetail: (project: string, name: string) => void; + openDetail: (project: string, name: string, from?: Screen) => void; openAnswer: (project: string, name: string) => void; + /* Open the full agent list pre-filtered (the fleet stat cards tap through here). */ + openAgentList: (filter: AgentFilter) => void; + agentFilter: AgentFilter; + setAgentFilter: (f: AgentFilter) => void; + /* Where the detail screen's back button returns to (fleet or the agent list). */ + detailReturnTo: Screen; // Fleet data. loading: boolean; error: string | null; projects: Project[]; + /* Every agent across every project, flat — checkmark or not. */ + agents: Agent[]; /* Registered model backends (the spawn/schedule dropdown next to the subscription). */ models: ClaudeModel[]; /* Recurring agent spawns, across all projects. */ @@ -213,6 +224,8 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { const [screen, setScreen] = useState("fleet"); const [detailTab, setDetailTab] = useState("state"); const [logFilter, setLogFilter] = useState("all"); + const [agentFilter, setAgentFilter] = useState("all"); + const [detailReturnTo, setDetailReturnTo] = useState("fleet"); const [selected, setSelected] = useState(null); const resetData = useCallback(() => { @@ -405,6 +418,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { return { running, waiting: waiting.length, done }; }, [agentsByProject, waiting]); + const agents = useMemo(() => { + const flat = Object.values(agentsByProject).flat(); + return [...flat].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + }, [agentsByProject]); + const globalLog = useMemo(() => { const rows: GlobalLogItem[] = []; for (const [pid, list] of Object.entries(agentsByProject)) { @@ -511,10 +531,19 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { : []; // ---- Navigation helpers -------------------------------------------------- - const openDetail = useCallback((project: string, name: string) => { - setSelected({ project, name }); - setDetailTab("state"); - setScreen("detail"); + const openDetail = useCallback( + (project: string, name: string, from: Screen = "fleet") => { + setSelected({ project, name }); + setDetailTab("state"); + setDetailReturnTo(from); + setScreen("detail"); + }, + [], + ); + + const openAgentList = useCallback((filter: AgentFilter) => { + setAgentFilter(filter); + setScreen("agentList"); }, []); const openAnswer = useCallback((project: string, name: string) => { @@ -641,10 +670,15 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { setLogFilter, openDetail, openAnswer, + openAgentList, + agentFilter, + setAgentFilter, + detailReturnTo, loading, error, projects, + agents, models, schedules, memory, @@ -675,9 +709,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) { logFilter, openDetail, openAnswer, + openAgentList, + agentFilter, + detailReturnTo, loading, error, projects, + agents, models, schedules, memory, diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index a2e158d..b2bff50 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -77,6 +77,7 @@ def spawn( require_tests: bool = True, mise_init: bool = False, worker_id: str | None = None, + auto_worktree: bool = True, ) -> dict: """Create and launch an agent. Returns the agent row. @@ -88,6 +89,14 @@ def spawn( instead of the worker's Claude subscription — same binary, same hooks/skills/gates, different ``ANTHROPIC_*`` env. ``worker_id`` identifies the calling worker container (headless runs record it on the run row; the CLI defaults to a pid-scoped id). + + ``auto_worktree``: when no placement is given and the root is a git repo, default to + a fresh worktree on ``agent/`` instead of the shared root checkout. The root + only fast-forwards while parked on the default branch, so agents sharing it saw + stale trees the moment one of them left it on a feature branch — a worktree cut + from ``origin/HEAD`` always starts at the remote's latest push (README's "one + working directory or git worktree per agent"). Schedule firings pass False: their + continuity convention is a state file living in the root tree across runs. """ if not task: # ``claude -p`` has no idle-REPL mode — an empty prompt would exit immediately @@ -121,6 +130,20 @@ def spawn( except reposync.SyncError as exc: raise SpawnError(str(exc)) from exc + if ( + auto_worktree + and worktree_branch is None + and subdir is None + and not mise_init # the bootstrap commits .mise.toml to the default branch + and gitops.is_repo(root) + ): + # Isolation by default: without this, every no-placement spawn shares the + # root checkout, whose tree goes stale as soon as an agent parks it off the + # default branch. (Name reuse after a deleted agent can leave a stale + # agent/ branch behind; it is then checked out as-is, same as any + # explicitly named existing branch.) + worktree_branch = f"agent/{name}" + try: working_dir = worktree.resolve_working_dir( project["root_dir"], name, subdir=subdir, worktree_branch=worktree_branch diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py index a86b32a..210eb69 100644 --- a/src/handler/control/worker.py +++ b/src/handler/control/worker.py @@ -50,6 +50,11 @@ def _cmd_spawn(command: dict) -> dict: name = command.get("agent_name") or p.get("name") if not command.get("project_id") or not name: raise CommandError("spawn requires project_id and an agent name") + # Schedule firings keep the legacy root placement when their schedule sets no + # worktree/subdir: the scheduled-run convention parks continuity in a state file + # in the root tree, which a fresh per-run worktree would not see. Operator spawns + # default to an isolated worktree cut from origin/HEAD (spawn.auto_worktree). + scheduled = (command.get("requested_by") or "").startswith("schedule:") agent = spawn.spawn( command["project_id"], name, @@ -59,6 +64,7 @@ def _cmd_spawn(command: dict) -> dict: role=p.get("role"), model_id=p.get("model_id"), worker_id=command.get("claimed_by"), + auto_worktree=not scheduled, ) result = { "agent_id": agent["id"], diff --git a/tests/test_control_spawn.py b/tests/test_control_spawn.py index 629274a..9d69583 100644 --- a/tests/test_control_spawn.py +++ b/tests/test_control_spawn.py @@ -216,3 +216,88 @@ def test_resume_trusts_working_dir_in_claude_json(env, fake_launch): assert ok is True cfg = json.loads((env["tmp"] / ".claude.json").read_text()) assert cfg["projects"][str(root)]["hasTrustDialogAccepted"] is True + + +def _git(root, *args): + import subprocess + + subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", "-C", str(root), *args], + check=True, + capture_output=True, + ) + + +def _init_git_repo(root): + """A committed git repo with a test task — the shape of a real synced project.""" + _write_mise(root, with_test=True) + _git(root, "init", "-q", "-b", "main") + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", "init") + + +def test_spawn_defaults_to_isolated_worktree(env, fake_launch): + """A no-placement spawn on a git root must NOT share the root checkout: the root + only fast-forwards while parked on the default branch, so shared-root agents saw + stale trees (missed pushes) as soon as one agent left it on a feature branch.""" + root = env["tmp"] / "proj" + _init_git_repo(root) + _register_project(root) + + agent = spawn.spawn("proj", "api", task="build the thing") + + assert agent["working_dir"] == str(root / "api") + # A real worktree on the derived branch, not the root itself. + assert (root / "api" / ".git").exists() + import subprocess + + branch = subprocess.run( + ["git", "-C", str(root / "api"), "branch", "--show-current"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + assert branch == "agent/api" + + +def test_spawn_auto_worktree_off_keeps_root(env, fake_launch): + root = env["tmp"] / "proj" + _init_git_repo(root) + _register_project(root) + + agent = spawn.spawn("proj", "api", task="do it", auto_worktree=False) + + assert agent["working_dir"] == str(root) + + +def test_spawn_non_git_root_still_uses_root(env, fake_launch): + """Manual (non-git) projects keep the old placement — there is nothing to worktree.""" + root = env["tmp"] / "proj" + _write_mise(root, with_test=True) + _register_project(root) + + agent = spawn.spawn("proj", "api", task="do it") + + assert agent["working_dir"] == str(root) + + +def test_worker_spawn_scheduled_keeps_root_placement(env, monkeypatch): + """Schedule firings opt out of the worktree default: their continuity convention + is a state file living in the root tree across runs.""" + from handler.control import spawn as spawn_mod + from handler.control import worker + + calls = [] + + def fake_spawn(project_id, name, **kwargs): + calls.append(kwargs) + return {"id": 1, "name": name, "working_dir": "/x"} + + monkeypatch.setattr(spawn_mod, "spawn", fake_spawn) + + base = {"project_id": "proj", "agent_name": "a", "payload": {"task": "t"}} + worker._cmd_spawn({**base, "requested_by": "schedule:5"}) + worker._cmd_spawn({**base, "requested_by": "operator:web"}) + + assert calls[0]["auto_worktree"] is False + assert calls[1]["auto_worktree"] is True