mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-06 08:56:25 +00:00
Merge pull request #33 from 0xWheatyz/claude/mobile-app-feature-parity-run9dz
Fix stale spawns: default to worktrees from origin/HEAD + tappable fleet stat cards
This commit is contained in:
@@ -6,6 +6,28 @@ the image workflows publish (plus `latest` from every push to `main`).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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/<name>` 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
|
### Added
|
||||||
|
|
||||||
- **Activity screen in the mobile app** (Settings → Manage → Activity): the
|
- **Activity screen in the mobile app** (Settings → Manage → Activity): the
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { ClaudeLoginScreen } from "./src/screens/manage/ClaudeLoginScreen";
|
|||||||
import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig";
|
import { ServerConfigProvider, useServerConfig } from "./src/state/ServerConfig";
|
||||||
import { ConnectScreen } from "./src/screens/ConnectScreen";
|
import { ConnectScreen } from "./src/screens/ConnectScreen";
|
||||||
import { FleetScreen } from "./src/screens/FleetScreen";
|
import { FleetScreen } from "./src/screens/FleetScreen";
|
||||||
|
import { AgentListScreen } from "./src/screens/AgentListScreen";
|
||||||
import { AgentDetailScreen } from "./src/screens/AgentDetailScreen";
|
import { AgentDetailScreen } from "./src/screens/AgentDetailScreen";
|
||||||
import { AnswerScreen } from "./src/screens/AnswerScreen";
|
import { AnswerScreen } from "./src/screens/AnswerScreen";
|
||||||
import { SpawnScreen } from "./src/screens/SpawnScreen";
|
import { SpawnScreen } from "./src/screens/SpawnScreen";
|
||||||
@@ -63,6 +64,7 @@ function Router() {
|
|||||||
const screens: Record<ScreenName, () => React.JSX.Element> = {
|
const screens: Record<ScreenName, () => React.JSX.Element> = {
|
||||||
connect: ConnectScreen,
|
connect: ConnectScreen,
|
||||||
fleet: FleetScreen,
|
fleet: FleetScreen,
|
||||||
|
agentList: AgentListScreen,
|
||||||
detail: AgentDetailScreen,
|
detail: AgentDetailScreen,
|
||||||
answer: AnswerScreen,
|
answer: AnswerScreen,
|
||||||
spawn: SpawnScreen,
|
spawn: SpawnScreen,
|
||||||
|
|||||||
+2
-1
@@ -23,7 +23,8 @@ npm run ios # opens the iOS simulator (requires Xcode)
|
|||||||
|
|
||||||
| Screen | File | What it does |
|
| 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 |
|
| 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** |
|
| 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 |
|
| Spawn | `src/screens/SpawnScreen.tsx` | Project select, model backend select, task field, Spawn |
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function AgentDetailScreen() {
|
|||||||
openAnswer,
|
openAnswer,
|
||||||
detailTab,
|
detailTab,
|
||||||
setDetailTab,
|
setDetailTab,
|
||||||
|
detailReturnTo,
|
||||||
models,
|
models,
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
selectedCheckmark,
|
selectedCheckmark,
|
||||||
@@ -38,7 +39,11 @@ export function AgentDetailScreen() {
|
|||||||
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||||
<View style={{ height: insets.top }} />
|
<View style={{ height: insets.top }} />
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<PageHeader leading="back" onLeadingPress={() => go("fleet")} title="Agent" />
|
<PageHeader
|
||||||
|
leading="back"
|
||||||
|
onLeadingPress={() => go(detailReturnTo)}
|
||||||
|
title="Agent"
|
||||||
|
/>
|
||||||
<Text style={[text.body, { color: colors.textMuted }]}>
|
<Text style={[text.body, { color: colors.textMuted }]}>
|
||||||
This agent is no longer in the fleet.
|
This agent is no longer in the fleet.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -95,7 +100,7 @@ export function AgentDetailScreen() {
|
|||||||
>
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
leading="back"
|
leading="back"
|
||||||
onLeadingPress={() => go("fleet")}
|
onLeadingPress={() => go(detailReturnTo)}
|
||||||
agentId={agent.name}
|
agentId={agent.name}
|
||||||
badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }}
|
badge={{ tone: statusTone(agent.status), label: statusLabel(agent.status) }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<View style={[styles.page, { backgroundColor: colors.surfacePage }]}>
|
||||||
|
<View style={{ height: insets.top }} />
|
||||||
|
<View style={styles.header}>
|
||||||
|
<PageHeader leading="back" onLeadingPress={() => go("fleet")} title="Agents" />
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.filters}
|
||||||
|
>
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<Chip
|
||||||
|
key={f.key}
|
||||||
|
label={f.label}
|
||||||
|
selected={agentFilter === f.key}
|
||||||
|
onPress={() => setAgentFilter(f.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={[styles.body, { paddingBottom: insets.bottom + 24 }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<SectionLabel style={{ marginBottom: 8 }}>
|
||||||
|
{`${rows.length} agent${rows.length === 1 ? "" : "s"}`}
|
||||||
|
</SectionLabel>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Text style={[text.bodySm, { color: colors.textMuted }]}>
|
||||||
|
{agentFilter === "all"
|
||||||
|
? "No agents yet — spawn one from the Fleet screen."
|
||||||
|
: `No ${agentFilter} agents right now.`}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
{rows.map((a, i) => (
|
||||||
|
<View key={`${a.project_id}/${a.name}`}>
|
||||||
|
{i > 0 && <Divider />}
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.row,
|
||||||
|
pressed && { backgroundColor: colors.surfaceSunken },
|
||||||
|
]}
|
||||||
|
onPress={() => openDetail(a.project_id, a.name, "agentList")}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Mono style={{ fontSize: 13, color: colors.textHeading }}>
|
||||||
|
{a.name}
|
||||||
|
</Mono>
|
||||||
|
<Badge tone={statusTone(a.status)}>{statusLabel(a.status)}</Badge>
|
||||||
|
</View>
|
||||||
|
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||||
|
{a.project_id}
|
||||||
|
{a.role ? ` · ${a.role}` : ""}
|
||||||
|
{` · started ${timeAgo(a.created_at)} ago`}
|
||||||
|
</Text>
|
||||||
|
{a.last_output?.trim() && isRunning(a) ? (
|
||||||
|
<Mono
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: 11.5, color: colors.textMuted }}
|
||||||
|
>
|
||||||
|
{a.last_output.trim().split("\n").pop()}
|
||||||
|
</Mono>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Icon name="chevronRight" size={16} color={colors.ink4} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { TabBar } from "../components/TabBar";
|
import { TabBar } from "../components/TabBar";
|
||||||
import {
|
import {
|
||||||
useAppState,
|
useAppState,
|
||||||
|
type AgentFilter,
|
||||||
type RecentItem,
|
type RecentItem,
|
||||||
type WaitingItem,
|
type WaitingItem,
|
||||||
} from "../state/AppState";
|
} from "../state/AppState";
|
||||||
@@ -29,15 +30,25 @@ import {
|
|||||||
export function FleetScreen() {
|
export function FleetScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { go, openAnswer, openDetail, waiting, recent, counts, loading, error, refresh } =
|
const {
|
||||||
useAppState();
|
go,
|
||||||
|
openAnswer,
|
||||||
|
openDetail,
|
||||||
|
openAgentList,
|
||||||
|
waiting,
|
||||||
|
recent,
|
||||||
|
counts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
} = useAppState();
|
||||||
|
|
||||||
const empty = waiting.length === 0 && recent.length === 0;
|
const empty = waiting.length === 0 && recent.length === 0;
|
||||||
|
|
||||||
const stats = [
|
const stats: { label: string; value: number; tint: string; filter: AgentFilter }[] = [
|
||||||
{ label: "Running", value: counts.running, tint: colors.textHeading },
|
{ label: "Running", value: counts.running, tint: colors.textHeading, filter: "running" },
|
||||||
{ label: "Waiting", value: counts.waiting, tint: colors.warning },
|
{ label: "Waiting", value: counts.waiting, tint: colors.warning, filter: "waiting" },
|
||||||
{ label: "Done", value: counts.done, tint: colors.textHeading },
|
{ label: "Done", value: counts.done, tint: colors.textHeading, filter: "done" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,14 +72,27 @@ export function FleetScreen() {
|
|||||||
|
|
||||||
<View style={styles.statsRow}>
|
<View style={styles.statsRow}>
|
||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<Card key={s.label} style={styles.statCard}>
|
<Pressable
|
||||||
<Text style={[text.caption, { color: colors.textMuted }]}>
|
key={s.label}
|
||||||
{s.label}
|
style={{ flex: 1 }}
|
||||||
</Text>
|
onPress={() => openAgentList(s.filter)}
|
||||||
<Text style={[styles.statValue, { color: s.tint }]}>
|
>
|
||||||
{s.value}
|
{({ pressed }) => (
|
||||||
</Text>
|
<Card
|
||||||
</Card>
|
style={[
|
||||||
|
styles.statCard,
|
||||||
|
pressed && { backgroundColor: colors.surfaceSunken },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[text.caption, { color: colors.textMuted }]}>
|
||||||
|
{s.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.statValue, { color: s.tint }]}>
|
||||||
|
{s.value}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -219,7 +243,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
},
|
},
|
||||||
statsRow: { flexDirection: "row", gap: 12, marginBottom: 20 },
|
statsRow: { flexDirection: "row", gap: 12, marginBottom: 20 },
|
||||||
statCard: { flex: 1, paddingVertical: 14, paddingHorizontal: 16 },
|
statCard: { paddingVertical: 14, paddingHorizontal: 16 },
|
||||||
statValue: {
|
statValue: {
|
||||||
fontFamily: fonts.monoSemiBold,
|
fontFamily: fonts.monoSemiBold,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { useServerConfig } from "./ServerConfig";
|
|||||||
export type Screen =
|
export type Screen =
|
||||||
| "connect"
|
| "connect"
|
||||||
| "fleet"
|
| "fleet"
|
||||||
|
| "agentList"
|
||||||
| "detail"
|
| "detail"
|
||||||
| "answer"
|
| "answer"
|
||||||
| "spawn"
|
| "spawn"
|
||||||
@@ -62,6 +63,8 @@ export type Screen =
|
|||||||
| "claudeLogin";
|
| "claudeLogin";
|
||||||
|
|
||||||
export type DetailTab = "state" | "events" | "log";
|
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 BadgeTone = "neutral" | "positive" | "warning" | "danger";
|
||||||
export type RecentTone = "positive" | "danger";
|
export type RecentTone = "positive" | "danger";
|
||||||
|
|
||||||
@@ -114,13 +117,21 @@ interface AppStateValue {
|
|||||||
go: (screen: Screen) => void;
|
go: (screen: Screen) => void;
|
||||||
setDetailTab: (tab: DetailTab) => void;
|
setDetailTab: (tab: DetailTab) => void;
|
||||||
setLogFilter: (f: string) => 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;
|
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.
|
// Fleet data.
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
|
/* Every agent across every project, flat — checkmark or not. */
|
||||||
|
agents: Agent[];
|
||||||
/* Registered model backends (the spawn/schedule dropdown next to the subscription). */
|
/* Registered model backends (the spawn/schedule dropdown next to the subscription). */
|
||||||
models: ClaudeModel[];
|
models: ClaudeModel[];
|
||||||
/* Recurring agent spawns, across all projects. */
|
/* Recurring agent spawns, across all projects. */
|
||||||
@@ -213,6 +224,8 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [screen, setScreen] = useState<Screen>("fleet");
|
const [screen, setScreen] = useState<Screen>("fleet");
|
||||||
const [detailTab, setDetailTab] = useState<DetailTab>("state");
|
const [detailTab, setDetailTab] = useState<DetailTab>("state");
|
||||||
const [logFilter, setLogFilter] = useState<string>("all");
|
const [logFilter, setLogFilter] = useState<string>("all");
|
||||||
|
const [agentFilter, setAgentFilter] = useState<AgentFilter>("all");
|
||||||
|
const [detailReturnTo, setDetailReturnTo] = useState<Screen>("fleet");
|
||||||
const [selected, setSelected] = useState<Selected | null>(null);
|
const [selected, setSelected] = useState<Selected | null>(null);
|
||||||
|
|
||||||
const resetData = useCallback(() => {
|
const resetData = useCallback(() => {
|
||||||
@@ -405,6 +418,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return { running, waiting: waiting.length, done };
|
return { running, waiting: waiting.length, done };
|
||||||
}, [agentsByProject, waiting]);
|
}, [agentsByProject, waiting]);
|
||||||
|
|
||||||
|
const agents = useMemo<Agent[]>(() => {
|
||||||
|
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<GlobalLogItem[]>(() => {
|
const globalLog = useMemo<GlobalLogItem[]>(() => {
|
||||||
const rows: GlobalLogItem[] = [];
|
const rows: GlobalLogItem[] = [];
|
||||||
for (const [pid, list] of Object.entries(agentsByProject)) {
|
for (const [pid, list] of Object.entries(agentsByProject)) {
|
||||||
@@ -511,10 +531,19 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// ---- Navigation helpers --------------------------------------------------
|
// ---- Navigation helpers --------------------------------------------------
|
||||||
const openDetail = useCallback((project: string, name: string) => {
|
const openDetail = useCallback(
|
||||||
setSelected({ project, name });
|
(project: string, name: string, from: Screen = "fleet") => {
|
||||||
setDetailTab("state");
|
setSelected({ project, name });
|
||||||
setScreen("detail");
|
setDetailTab("state");
|
||||||
|
setDetailReturnTo(from);
|
||||||
|
setScreen("detail");
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openAgentList = useCallback((filter: AgentFilter) => {
|
||||||
|
setAgentFilter(filter);
|
||||||
|
setScreen("agentList");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAnswer = useCallback((project: string, name: string) => {
|
const openAnswer = useCallback((project: string, name: string) => {
|
||||||
@@ -641,10 +670,15 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setLogFilter,
|
setLogFilter,
|
||||||
openDetail,
|
openDetail,
|
||||||
openAnswer,
|
openAnswer,
|
||||||
|
openAgentList,
|
||||||
|
agentFilter,
|
||||||
|
setAgentFilter,
|
||||||
|
detailReturnTo,
|
||||||
|
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
|
agents,
|
||||||
models,
|
models,
|
||||||
schedules,
|
schedules,
|
||||||
memory,
|
memory,
|
||||||
@@ -675,9 +709,13 @@ export function AppStateProvider({ children }: { children: React.ReactNode }) {
|
|||||||
logFilter,
|
logFilter,
|
||||||
openDetail,
|
openDetail,
|
||||||
openAnswer,
|
openAnswer,
|
||||||
|
openAgentList,
|
||||||
|
agentFilter,
|
||||||
|
detailReturnTo,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
|
agents,
|
||||||
models,
|
models,
|
||||||
schedules,
|
schedules,
|
||||||
memory,
|
memory,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ def spawn(
|
|||||||
require_tests: bool = True,
|
require_tests: bool = True,
|
||||||
mise_init: bool = False,
|
mise_init: bool = False,
|
||||||
worker_id: str | None = None,
|
worker_id: str | None = None,
|
||||||
|
auto_worktree: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create and launch an agent. Returns the agent row.
|
"""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,
|
instead of the worker's Claude subscription — same binary, same hooks/skills/gates,
|
||||||
different ``ANTHROPIC_*`` env. ``worker_id`` identifies the calling worker container
|
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).
|
(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/<name>`` 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:
|
if not task:
|
||||||
# ``claude -p`` has no idle-REPL mode — an empty prompt would exit immediately
|
# ``claude -p`` has no idle-REPL mode — an empty prompt would exit immediately
|
||||||
@@ -121,6 +130,20 @@ def spawn(
|
|||||||
except reposync.SyncError as exc:
|
except reposync.SyncError as exc:
|
||||||
raise SpawnError(str(exc)) from 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/<name> branch behind; it is then checked out as-is, same as any
|
||||||
|
# explicitly named existing branch.)
|
||||||
|
worktree_branch = f"agent/{name}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
working_dir = worktree.resolve_working_dir(
|
working_dir = worktree.resolve_working_dir(
|
||||||
project["root_dir"], name, subdir=subdir, worktree_branch=worktree_branch
|
project["root_dir"], name, subdir=subdir, worktree_branch=worktree_branch
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ def _cmd_spawn(command: dict) -> dict:
|
|||||||
name = command.get("agent_name") or p.get("name")
|
name = command.get("agent_name") or p.get("name")
|
||||||
if not command.get("project_id") or not name:
|
if not command.get("project_id") or not name:
|
||||||
raise CommandError("spawn requires project_id and an agent 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(
|
agent = spawn.spawn(
|
||||||
command["project_id"],
|
command["project_id"],
|
||||||
name,
|
name,
|
||||||
@@ -59,6 +64,7 @@ def _cmd_spawn(command: dict) -> dict:
|
|||||||
role=p.get("role"),
|
role=p.get("role"),
|
||||||
model_id=p.get("model_id"),
|
model_id=p.get("model_id"),
|
||||||
worker_id=command.get("claimed_by"),
|
worker_id=command.get("claimed_by"),
|
||||||
|
auto_worktree=not scheduled,
|
||||||
)
|
)
|
||||||
result = {
|
result = {
|
||||||
"agent_id": agent["id"],
|
"agent_id": agent["id"],
|
||||||
|
|||||||
@@ -216,3 +216,88 @@ def test_resume_trusts_working_dir_in_claude_json(env, fake_launch):
|
|||||||
assert ok is True
|
assert ok is True
|
||||||
cfg = json.loads((env["tmp"] / ".claude.json").read_text())
|
cfg = json.loads((env["tmp"] / ".claude.json").read_text())
|
||||||
assert cfg["projects"][str(root)]["hasTrustDialogAccepted"] is True
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user