feat: add worktree archiving with context menu and improved logging

This commit is contained in:
Mohamed Boudra
2026-03-03 17:35:48 +07:00
parent f418f3ab02
commit 888af29485
11 changed files with 529 additions and 24 deletions
+3 -1
View File
@@ -98,6 +98,7 @@ function AgentListRow({
const timeAgo = formatTimeAgo(agent.lastActivityAt); const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`; const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey; const isSelected = selectedAgentId === agentKey;
const archivedLabel = agent.archivedAt ? "Archived" : null;
const checkoutQuery = useCheckoutStatusCacheOnly({ const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId, serverId: agent.serverId,
cwd: agent.cwd, cwd: agent.cwd,
@@ -137,7 +138,8 @@ function AgentListRow({
<Text style={styles.secondaryRow} numberOfLines={1}> <Text style={styles.secondaryRow} numberOfLines={1}>
{shortenPath(projectPath)} {shortenPath(projectPath)}
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo} {branchLabel ? ` · ${branchLabel}` : ""}
{archivedLabel ? ` · ${archivedLabel}` : ""} · {timeAgo}
</Text> </Text>
</View> </View>
)} )}
@@ -1,4 +1,4 @@
import { View, Text, Pressable, Image, Platform } from 'react-native' import { View, Text, Pressable, Image, Platform, Alert, ActivityIndicator, StatusBar } from 'react-native'
import { useQueries } from '@tanstack/react-query' import { useQueries } from '@tanstack/react-query'
import { import {
useCallback, useCallback,
@@ -11,8 +11,8 @@ import {
} from 'react' } from 'react'
import { router, usePathname, useSegments } from 'expo-router' import { router, usePathname, useSegments } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler' import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
import { ChevronDown, ChevronRight } from 'lucide-react-native' import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query' import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
@@ -27,8 +27,17 @@ import {
type SidebarWorkspaceEntry, type SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-agents-list' } from '@/hooks/use-sidebar-agents-list'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
import { formatTimeAgo } from '@/utils/time' import { formatTimeAgo } from '@/utils/time'
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state' import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
import { confirmDialog } from '@/utils/confirm-dialog'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
useContextMenu,
} from '@/components/ui/context-menu'
type SidebarTreeRow = type SidebarTreeRow =
| { | {
@@ -76,7 +85,7 @@ interface WorkspaceRowProps {
workspace: SidebarWorkspaceEntry workspace: SidebarWorkspaceEntry
selected: boolean selected: boolean
onPress: () => void onPress: () => void
onLongPress: () => void drag: () => void
} }
function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string { function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string {
@@ -135,10 +144,25 @@ function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['
: theme.colors.border : theme.colors.border
} }
function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) { function WorkspaceStatusIndicator({
bucket,
loading = false,
}: {
bucket: SidebarWorkspaceEntry['statusBucket']
loading?: boolean
}) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const color = resolveStatusDotColor({ theme, bucket }) const color = resolveStatusDotColor({ theme, bucket })
return <View style={[styles.workspaceStatusDot, { backgroundColor: color }]} />
return (
<View style={styles.workspaceStatusDot}>
{loading ? (
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
) : (
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
)}
</View>
)
} }
function ProjectRow({ function ProjectRow({
@@ -201,10 +225,71 @@ function ProjectRow({
) )
} }
function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRowProps) { function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps) {
const didLongPressRef = useRef(false) const { theme } = useUnistyles()
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
const { setAnchorRect, setOpen } = useContextMenu()
const didLongPressRef = useRef(false)
const didLongPressCleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const longPressArmedRef = useRef(false)
const longPressCancelledRef = useRef(false)
const didStartDragRef = useRef(false)
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({
serverId: workspace.serverId,
cwd: workspace.cwd,
actionId: 'archive-worktree',
})
)
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
const isArchiving = archiveStatus === 'pending'
const openContextMenuAtTouchStart = useCallback(() => {
const point = touchStartRef.current
if (!point) {
return
}
const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0
setAnchorRect({
x: point.x,
y: point.y + statusBarHeight,
width: 0,
height: 0,
})
setOpen(true)
}, [setAnchorRect, setOpen])
const handleArchiveWorktree = useCallback(() => {
if (!workspace.isPaseoOwnedWorktree) {
return
}
void confirmDialog({
title: 'Archive worktree?',
message: `Archive this worktree?\n\n${workspace.cwd}`,
confirmLabel: 'Archive',
cancelLabel: 'Cancel',
destructive: true,
})
.then((confirmed) => {
if (!confirmed) {
return
}
return runArchiveWorktree({
serverId: workspace.serverId,
cwd: workspace.cwd,
worktreePath: workspace.cwd,
})
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to archive worktree'
Alert.alert('Error', message)
})
}, [runArchiveWorktree, workspace.cwd, workspace.isPaseoOwnedWorktree, workspace.serverId])
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
if (didLongPressRef.current) { if (didLongPressRef.current) {
didLongPressRef.current = false didLongPressRef.current = false
@@ -214,25 +299,115 @@ function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRo
}, [onPress]) }, [onPress])
const handleLongPress = useCallback(() => { const handleLongPress = useCallback(() => {
if (longPressCancelledRef.current) {
return
}
didLongPressRef.current = true didLongPressRef.current = true
onLongPress() longPressArmedRef.current = true
}, [onLongPress]) }, [])
return ( const moveMonitorGesture = useMemo(() => {
<Pressable if (Platform.OS === 'web') {
return null
}
const CANCEL_SLOP_PX = 10
const DRAG_SLOP_PX = 8
return Gesture.Pan()
.manualActivation(true)
.runOnJS(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
if (!touch) {
return
}
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
}
const start = touchStartRef.current
if (!start) {
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
return
}
const dx = touch.absoluteX - start.x
const dy = touch.absoluteY - start.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (!longPressArmedRef.current) {
if (distance > CANCEL_SLOP_PX) {
longPressCancelledRef.current = true
stateManager.fail()
}
return
}
if (didStartDragRef.current) {
return
}
if (distance > DRAG_SLOP_PX) {
didStartDragRef.current = true
drag()
stateManager.fail()
}
})
}, [drag])
const trigger = (
<ContextMenuTrigger
enabledOnMobile={false}
style={({ pressed, hovered = false }) => [ style={({ pressed, hovered = false }) => [
styles.workspaceRow, styles.workspaceRow,
selected && styles.workspaceRowSelected, selected && styles.workspaceRowSelected,
hovered && styles.workspaceRowHovered, hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed, pressed && styles.workspaceRowPressed,
]} ]}
onPressIn={(event) => {
if (didLongPressCleanupTimerRef.current) {
clearTimeout(didLongPressCleanupTimerRef.current)
didLongPressCleanupTimerRef.current = null
}
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }
}}
onPressOut={() => {
if (Platform.OS === 'web') {
return
}
if (!longPressArmedRef.current || didStartDragRef.current) {
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = null
return
}
openContextMenuAtTouchStart()
didLongPressCleanupTimerRef.current = setTimeout(() => {
didLongPressRef.current = false
didLongPressCleanupTimerRef.current = null
}, 0)
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = null
}}
onPress={handlePress} onPress={handlePress}
onLongPress={handleLongPress} onLongPress={handleLongPress}
delayLongPress={200} delayLongPress={200}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`} testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
> >
<View style={styles.workspaceRowLeft}> <View style={styles.workspaceRowLeft}>
<WorkspaceStatusDot bucket={workspace.statusBucket} /> <WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
<Text style={styles.workspaceBranchText} numberOfLines={1}> <Text style={styles.workspaceBranchText} numberOfLines={1}>
{resolveWorkspaceBranchLabel(workspace)} {resolveWorkspaceBranchLabel(workspace)}
</Text> </Text>
@@ -242,7 +417,54 @@ function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRo
{createdAtLabel} {createdAtLabel}
</Text> </Text>
) : null} ) : null}
</Pressable> </ContextMenuTrigger>
)
return (
<>
{moveMonitorGesture ? (
<GestureDetector gesture={moveMonitorGesture}>{trigger}</GestureDetector>
) : (
trigger
)}
<ContextMenuContent
align="start"
width={220}
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
>
<ContextMenuItem
leading={<Archive size={16} color={theme.colors.foregroundMuted} />}
destructive
disabled={!workspace.isPaseoOwnedWorktree}
status={workspace.isPaseoOwnedWorktree ? archiveStatus : 'idle'}
pendingLabel="Archiving…"
successLabel="Archived"
onSelect={handleArchiveWorktree}
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
>
Archive worktree
</ContextMenuItem>
</ContextMenuContent>
</>
)
}
function WorkspaceRowWithMenu({
workspace,
selected,
onPress,
drag,
}: {
workspace: SidebarWorkspaceEntry
selected: boolean
onPress: () => void
drag: () => void
}) {
return (
<ContextMenu>
<WorkspaceRow workspace={workspace} selected={selected} onPress={onPress} drag={drag} />
</ContextMenu>
) )
} }
@@ -452,7 +674,7 @@ export function SidebarAgentList({
activeWorkspaceSelection.workspaceId === item.workspace.cwd activeWorkspaceSelection.workspaceId === item.workspace.cwd
return ( return (
<WorkspaceRow <WorkspaceRowWithMenu
workspace={item.workspace} workspace={item.workspace}
selected={isSelected} selected={isSelected}
onPress={() => { onPress={() => {
@@ -462,7 +684,7 @@ export function SidebarAgentList({
onWorkspacePress?.() onWorkspacePress?.()
navigate(workspaceRoute as any) navigate(workspaceRoute as any)
}} }}
onLongPress={drag} drag={drag}
/> />
) )
}, },
@@ -671,6 +893,14 @@ const styles = StyleSheet.create((theme) => ({
height: 8, height: 8,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
flexShrink: 0, flexShrink: 0,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
workspaceStatusDotFill: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
}, },
workspaceBranchText: { workspaceBranchText: {
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -58,6 +58,10 @@ function useContextMenuContext(componentName: string): ContextMenuContextValue {
return ctx; return ctx;
} }
export function useContextMenu(): ContextMenuContextValue {
return useContextMenuContext("useContextMenu");
}
function useControllableOpenState({ function useControllableOpenState({
open, open,
defaultOpen, defaultOpen,
@@ -55,6 +55,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
} }
const serverLabel = serverLabelById.get(serverId) ?? serverId; const serverLabel = serverLabelById.get(serverId) ?? serverId;
for (const agent of agents.values()) { for (const agent of agents.values()) {
if (agent.archivedAt) {
continue;
}
const nextAgent: AggregatedAgent = { const nextAgent: AggregatedAgent = {
id: agent.id, id: agent.id,
serverId, serverId,
@@ -74,9 +74,6 @@ export function useAllAgentsList(options?: {
serverId, serverId,
serverLabel, serverLabel,
}); });
if (aggregated.archivedAt) {
continue;
}
if (aggregated.labels.ui !== "true") { if (aggregated.labels.ui !== "true") {
continue; continue;
} }
+1 -1
View File
@@ -1113,7 +1113,7 @@ export class HostRuntimeStore {
controller.markAgentDirectorySyncLoading(); controller.markAgentDirectorySyncLoading();
try { try {
const payload = await client.fetchAgents({ const payload = await client.fetchAgents({
filter: input.filter ?? { labels: { ui: "true" } }, filter: input.filter ?? { labels: { ui: "true" }, includeArchived: true },
...(input.subscribe ? { subscribe: input.subscribe } : {}), ...(input.subscribe ? { subscribe: input.subscribe } : {}),
...(input.page ? { page: input.page } : {}), ...(input.page ? { page: input.page } : {}),
}); });
@@ -86,6 +86,49 @@ function invalidateWorktreeList() {
predicate: (query) => predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList", Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
}); });
void queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
});
}
function removeWorktreeFromCachedLists(input: {
serverId: string;
worktreePath: string;
}): void {
const serverId = input.serverId.trim();
const worktreePath = input.worktreePath.trim();
if (!serverId || !worktreePath) {
return;
}
const removeFromList = (current: unknown) => {
if (!Array.isArray(current)) {
return current;
}
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
return filtered.length === current.length ? current : filtered;
};
queryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "paseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList
);
queryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList
);
} }
const successTimers = new Map<string, ReturnType<typeof setTimeout>>(); const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -267,6 +310,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
removeWorktreeFromCachedLists({ serverId, worktreePath });
invalidateWorktreeList(); invalidateWorktreeList();
}, },
}); });
@@ -801,6 +801,15 @@ export class AgentManager {
async closeAgent(agentId: string): Promise<void> { async closeAgent(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
this.logger.trace(
{
agentId,
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
pendingPermissions: agent.pendingPermissions.size,
},
"closeAgent: start"
);
this.agents.delete(agentId); this.agents.delete(agentId);
this.liveEventPumps.delete(agentId); this.liveEventPumps.delete(agentId);
this.liveEventBacklog.delete(agentId); this.liveEventBacklog.delete(agentId);
@@ -816,6 +825,7 @@ export class AgentManager {
}; };
await session.close(); await session.close();
this.emitState(closedAgent); this.emitState(closedAgent);
this.logger.trace({ agentId }, "closeAgent: completed");
} }
async setAgentMode(agentId: string, modeId: string): Promise<void> { async setAgentMode(agentId: string, modeId: string): Promise<void> {
@@ -1005,7 +1015,24 @@ export class AgentManager {
options?: AgentRunOptions options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> { ): AsyncGenerator<AgentStreamEvent> {
const existingAgent = this.requireAgent(agentId); const existingAgent = this.requireAgent(agentId);
this.logger.trace(
{
agentId,
lifecycle: existingAgent.lifecycle,
hasPendingRun: Boolean(existingAgent.pendingRun),
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options),
},
"streamAgent: requested"
);
if (existingAgent.pendingRun) { if (existingAgent.pendingRun) {
this.logger.trace(
{
agentId,
lifecycle: existingAgent.lifecycle,
},
"streamAgent: rejected because pendingRun already exists"
);
throw new Error(`Agent ${agentId} already has an active run`); throw new Error(`Agent ${agentId} already has an active run`);
} }
@@ -1015,12 +1042,31 @@ export class AgentManager {
let finalized = false; let finalized = false;
const finalize = (error?: string) => { const finalize = (error?: string) => {
this.logger.trace(
{
agentId,
error,
alreadyFinalized: finalized,
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"streamAgent.finalize: invoked"
);
if (finalized) { if (finalized) {
return; return;
} }
finalized = true; finalized = true;
if (agent.pendingRun !== streamForwarder) { if (agent.pendingRun !== streamForwarder) {
this.logger.trace(
{
agentId,
error,
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"streamAgent.finalize: skipped because pendingRun no longer points to streamForwarder"
);
if (error) { if (error) {
agent.lastError = error; agent.lastError = error;
} }
@@ -1043,6 +1089,15 @@ export class AgentManager {
mutableAgent.cwd mutableAgent.cwd
); );
} }
this.logger.trace(
{
agentId,
lifecycle: mutableAgent.lifecycle,
hasPendingRun: Boolean(mutableAgent.pendingRun),
terminalError,
},
"streamAgent.finalize: applying terminal state"
);
this.emitState(mutableAgent); this.emitState(mutableAgent);
this.flushLiveEventBacklog(mutableAgent); this.flushLiveEventBacklog(mutableAgent);
}; };
@@ -1054,6 +1109,22 @@ export class AgentManager {
try { try {
for await (const event of iterator) { for await (const event of iterator) {
self.handleStreamEvent(agent, event); self.handleStreamEvent(agent, event);
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
self.logger.trace(
{
agentId,
eventType: event.type,
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"streamAgent: forwarded terminal/turn event"
);
}
yield event; yield event;
} }
} catch (error) { } catch (error) {
@@ -1079,6 +1150,14 @@ export class AgentManager {
// deterministically order idle->running transitions. // deterministically order idle->running transitions.
this.touchUpdatedAt(agent); this.touchUpdatedAt(agent);
self.emitState(agent); self.emitState(agent);
this.logger.trace(
{
agentId,
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"streamAgent: started"
);
return streamForwarder; return streamForwarder;
} }
@@ -1743,6 +1822,14 @@ export class AgentManager {
} }
break; break;
case "turn_completed": case "turn_completed":
this.logger.trace(
{
agentId: agent.id,
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"handleStreamEvent: turn_completed"
);
agent.lastUsage = event.usage; agent.lastUsage = event.usage;
agent.lastError = undefined; agent.lastError = undefined;
if (!agent.pendingRun && agent.lifecycle !== "idle") { if (!agent.pendingRun && agent.lifecycle !== "idle") {
@@ -1774,6 +1861,14 @@ export class AgentManager {
this.emitState(agent); this.emitState(agent);
break; break;
case "turn_canceled": case "turn_canceled":
this.logger.trace(
{
agentId: agent.id,
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"handleStreamEvent: turn_canceled"
);
if (!agent.pendingRun) { if (!agent.pendingRun) {
(agent as ActiveManagedAgent).lifecycle = "idle"; (agent as ActiveManagedAgent).lifecycle = "idle";
} }
@@ -1792,6 +1887,14 @@ export class AgentManager {
this.emitState(agent); this.emitState(agent);
break; break;
case "turn_started": case "turn_started":
this.logger.trace(
{
agentId: agent.id,
lifecycle: agent.lifecycle,
hasPendingRun: Boolean(agent.pendingRun),
},
"handleStreamEvent: turn_started"
);
if (!agent.pendingRun) { if (!agent.pendingRun) {
(agent as ActiveManagedAgent).lifecycle = "running"; (agent as ActiveManagedAgent).lifecycle = "running";
this.emitState(agent); this.emitState(agent);
@@ -2143,6 +2246,14 @@ export class AgentManager {
// Keep consuming provider events even during an active foreground run, // Keep consuming provider events even during an active foreground run,
// then replay them immediately once that run settles. // then replay them immediately once that run settles.
if (latest.pendingRun) { if (latest.pendingRun) {
this.logger.trace(
{
agentId: latest.id,
eventType: event.type,
backlogSize: (this.liveEventBacklog.get(latest.id)?.length ?? 0) + 1,
},
"Live event pump: queued event because pendingRun is active"
);
this.enqueueLiveEvent(latest.id, event); this.enqueueLiveEvent(latest.id, event);
continue; continue;
} }
@@ -1906,6 +1906,16 @@ class ClaudeAgentSession implements AgentSession {
} }
async close(): Promise<void> { async close(): Promise<void> {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
turnState: this.turnState,
hasQuery: Boolean(this.query),
hasInput: Boolean(this.input),
hasActiveForegroundTurn: Boolean(this.activeForegroundTurn),
},
"Claude session close: start"
);
this.closed = true; this.closed = true;
this.rejectAllPendingPermissions(new Error("Claude session closed")); this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.cancelCurrentTurn?.(); this.cancelCurrentTurn?.();
@@ -1922,6 +1932,10 @@ class ClaudeAgentSession implements AgentSession {
await this.awaitWithTimeout(this.query?.return?.(), "close query return"); await this.awaitWithTimeout(this.query?.return?.(), "close query return");
this.query = null; this.query = null;
this.input = null; this.input = null;
this.logger.trace(
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
"Claude session close: completed"
);
} }
async listCommands(): Promise<AgentSlashCommand[]> { async listCommands(): Promise<AgentSlashCommand[]> {
@@ -2235,8 +2249,11 @@ class ClaudeAgentSession implements AgentSession {
label: string label: string
): Promise<void> { ): Promise<void> {
if (!promise) { if (!promise) {
this.logger.trace({ label }, "Claude query operation skipped (no promise)");
return; return;
} }
const startedAt = Date.now();
this.logger.trace({ label }, "Claude query operation wait start");
try { try {
await Promise.race([ await Promise.race([
promise, promise,
@@ -2244,6 +2261,10 @@ class ClaudeAgentSession implements AgentSession {
setTimeout(() => reject(new Error("timeout")), 3_000); setTimeout(() => reject(new Error("timeout")), 3_000);
}), }),
]); ]);
this.logger.trace(
{ label, durationMs: Date.now() - startedAt },
"Claude query operation settled"
);
} catch (error) { } catch (error) {
this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly"); this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly");
} }
@@ -2464,6 +2485,23 @@ class ClaudeAgentSession implements AgentSession {
} }
private emitRunEvent(run: RunRecord, event: AgentStreamEvent): void { private emitRunEvent(run: RunRecord, event: AgentStreamEvent): void {
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
this.logger.trace(
{
runId: run.id,
owner: run.owner,
runState: run.state,
eventType: event.type,
routedTo: run.owner === "foreground" && run.queue ? "foreground_queue" : "live_queue",
},
"Claude run event emitted"
);
}
if (run.owner === "foreground" && run.queue) { if (run.owner === "foreground" && run.queue) {
run.queue.push(event); run.queue.push(event);
if ( if (
@@ -2494,6 +2532,16 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurn = null; this.activeForegroundTurn = null;
this.preReplayMetadataSeen = false; this.preReplayMetadataSeen = false;
} }
this.logger.trace(
{
runId: run.id,
owner: run.owner,
eventType: event.type,
runState: run.state,
hasActiveForegroundTurn: Boolean(this.activeForegroundTurn),
},
"Claude run terminal event handled"
);
this.transitionTurnStateFromActiveRuns(`run ${run.id} terminal`); this.transitionTurnStateFromActiveRuns(`run ${run.id} terminal`);
} }
@@ -2789,6 +2837,13 @@ class ClaudeAgentSession implements AgentSession {
} }
if (next.done) { if (next.done) {
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
activeRunCount: this.runTracker.listActiveRuns().length,
},
"Claude query pump next() returned done"
);
this.input?.end(); this.input?.end();
await this.awaitWithTimeout(q.return?.(), "query pump return on done"); await this.awaitWithTimeout(q.return?.(), "query pump return on done");
if (this.query === q) { if (this.query === q) {
@@ -2834,6 +2889,18 @@ class ClaudeAgentSession implements AgentSession {
run: route.run, run: route.run,
message, message,
}); });
this.logger.trace(
{
claudeSessionId: this.claudeSessionId,
messageType: message.type,
routeReason: route.reason,
runId: route.run?.id ?? null,
runOwner: route.run?.owner ?? null,
suppressTerminalEvents,
metadataOnly,
},
"Claude query pump routed SDK message"
);
if (route.run) { if (route.run) {
this.transitionTurnStateFromActiveRuns(`routed via ${route.reason}`); this.transitionTurnStateFromActiveRuns(`routed via ${route.reason}`);
this.runTracker.bindIdentifiers(route.run, identifiers); this.runTracker.bindIdentifiers(route.run, identifiers);
@@ -2898,6 +2965,16 @@ class ClaudeAgentSession implements AgentSession {
// Pre-replay success results are stale in practice (leftover from an // Pre-replay success results are stale in practice (leftover from an
// earlier query segment) and must not end the current foreground run. // earlier query segment) and must not end the current foreground run.
if (resultSubtype === "success") { if (resultSubtype === "success") {
this.logger.trace(
{
runId: run.id,
runOwner: run.owner,
runState: run.state,
promptReplaySeen: run.promptReplaySeen,
resultSubtype,
},
"Suppressing pre-replay foreground success result terminal event"
);
return true; return true;
} }
@@ -2917,6 +2994,7 @@ class ClaudeAgentSession implements AgentSession {
message: SDKMessage, message: SDKMessage,
identifiers: EventIdentifiers identifiers: EventIdentifiers
): void { ): void {
const previousState = run.state;
if ( if (
message.type === "user" && message.type === "user" &&
identifiers.messageId && identifiers.messageId &&
@@ -2941,8 +3019,25 @@ class ClaudeAgentSession implements AgentSession {
if (message.type === "result") { if (message.type === "result") {
this.runTracker.transition(run, "finalizing"); this.runTracker.transition(run, "finalizing");
} else {
return; return;
} }
if (run.state !== previousState) {
this.logger.trace(
{
runId: run.id,
owner: run.owner,
messageType: message.type,
previousState,
nextState: run.state,
taskId: identifiers.taskId,
parentMessageId: identifiers.parentMessageId,
messageId: identifiers.messageId,
},
"Updated Claude run lifecycle from SDK message"
);
}
} }
private shouldSuppressLocalReplayActivity(message: SDKMessage): boolean { private shouldSuppressLocalReplayActivity(message: SDKMessage): boolean {
+16
View File
@@ -736,10 +736,15 @@ export class Session {
private async interruptAgentIfRunning(agentId: string): Promise<void> { private async interruptAgentIfRunning(agentId: string): Promise<void> {
const snapshot = this.agentManager.getAgent(agentId) const snapshot = this.agentManager.getAgent(agentId)
if (!snapshot) { if (!snapshot) {
this.sessionLogger.trace({ agentId }, 'interruptAgentIfRunning: agent not found')
throw new Error(`Agent ${agentId} not found`) throw new Error(`Agent ${agentId} not found`)
} }
if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) { if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) {
this.sessionLogger.trace(
{ agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) },
'interruptAgentIfRunning: skipping because agent is not running'
)
return return
} }
@@ -787,9 +792,18 @@ export class Session {
prompt: AgentPromptInput, prompt: AgentPromptInput,
runOptions?: AgentRunOptions runOptions?: AgentRunOptions
): { ok: true } | { ok: false; error: string } { ): { ok: true } | { ok: false; error: string } {
this.sessionLogger.trace(
{
agentId,
promptType: typeof prompt === 'string' ? 'string' : 'structured',
hasRunOptions: Boolean(runOptions),
},
'startAgentStream: requested'
)
let iterator: AsyncGenerator<AgentStreamEvent> let iterator: AsyncGenerator<AgentStreamEvent>
try { try {
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions) iterator = this.agentManager.streamAgent(agentId, prompt, runOptions)
this.sessionLogger.trace({ agentId }, 'startAgentStream: streamAgent returned iterator')
} catch (error) { } catch (error) {
this.handleAgentRunError(agentId, error, 'Failed to start agent run') this.handleAgentRunError(agentId, error, 'Failed to start agent run')
const message = const message =
@@ -802,7 +816,9 @@ export class Session {
for await (const _ of iterator) { for await (const _ of iterator) {
// Events are forwarded via the session's AgentManager subscription. // Events are forwarded via the session's AgentManager subscription.
} }
this.sessionLogger.trace({ agentId }, 'startAgentStream: iterator drained')
} catch (error) { } catch (error) {
this.sessionLogger.trace({ agentId, err: error }, 'startAgentStream: iterator threw')
this.handleAgentRunError(agentId, error, 'Agent stream failed') this.handleAgentRunError(agentId, error, 'Agent stream failed')
} }
})() })()
+6 -3
View File
@@ -10,8 +10,11 @@ DAEMON_PORT=$(get-port 6767 6768 6769 6770 6771 6772 6773)
METRO_PORT=$(get-port 8081 8082 8083 8084 8085 8086 8087) METRO_PORT=$(get-port 8081 8082 8083 8084 8085 8086 8087)
# Use a temporary PASEO_HOME to avoid conflicts between dev instances # Use a temporary PASEO_HOME to avoid conflicts between dev instances
# export PASEO_HOME=$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX") if [ -z "${PASEO_HOME}" ]; then
# trap "rm -rf '$PASEO_HOME'" EXIT export PASEO_HOME
PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")"
trap "rm -rf '$PASEO_HOME'" EXIT
fi
# Build CORS origins for this Expo instance # Build CORS origins for this Expo instance
CORS_ORIGINS="http://localhost:${METRO_PORT},http://127.0.0.1:${METRO_PORT}" CORS_ORIGINS="http://localhost:${METRO_PORT},http://127.0.0.1:${METRO_PORT}"
@@ -24,7 +27,7 @@ echo " Paseo Dev"
echo "══════════════════════════════════════════════════════" echo "══════════════════════════════════════════════════════"
echo " Daemon: http://localhost:${DAEMON_PORT}" echo " Daemon: http://localhost:${DAEMON_PORT}"
echo " Metro: http://localhost:${METRO_PORT}" echo " Metro: http://localhost:${METRO_PORT}"
# echo " Home: ${PASEO_HOME}" echo " Home: ${PASEO_HOME}"
echo "══════════════════════════════════════════════════════" echo "══════════════════════════════════════════════════════"
# Export for child processes (overrides .env values) # Export for child processes (overrides .env values)