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
20 changes: 20 additions & 0 deletions src/__tests__/unit/chat/chat-session-lease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,26 @@ describe('chat session leases', () => {
})).toBeUndefined();
});

it('recognizes restarted daemon leases as dead local process leases', () => {
const leased = ChatSessionLeases.acquire(createSession(), {
ownerKind: 'daemon',
ownerId: 'daemon-4686-1779894401584',
clientLabel: 'control plane',
}, {
now: Date.parse('2026-04-21T01:00:00.000Z'),
});

expect(ChatSessionLeases.isOwnedByDeadLocalProcess(leased.lease, { isProcessAlive: () => false })).toBe(true);
expect(ChatSessionLeases.conflict(leased, {
ownerKind: 'daemon',
ownerId: 'daemon-9999-1779894500000',
clientLabel: 'control plane',
}, {
now: Date.parse('2026-04-21T01:01:00.000Z'),
isProcessAlive: () => false,
})).toBeUndefined();
});

it('still reports fresh local process leases when the owner is alive', () => {
const leased = ChatSessionLeases.acquire(createSession(), {
ownerKind: 'tui',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ describe('ControlPlaneSessionStore', () => {
isStreaming: false,
isPending: false,
});
expect(store.getSnapshot().liveStatus).toBeUndefined();
expect(store.getSnapshot().liveStatus).toBe('Receiving assistant response...');
store.dispose();
} finally {
vi.useRealTimers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ describe('ClientSharedSessionActivityService', () => {
summary: 'Done.',
timestamp: new Date().toISOString(),
} as ControlPlaneSessionActivity, {
onRunFinished: () => effects.push('finished'),
onRunFinished: (_activity, liveStatus) => effects.push(`finished:${liveStatus}`),
onWorkspaceChanged: () => effects.push('workspace changed'),
});

expect(effects).toEqual(['finished', 'workspace changed']);
expect(effects).toEqual(['finished:Run finished: done', 'workspace changed']);
});

it('uses derived tool labels when the API provides them', () => {
Expand Down
6 changes: 3 additions & 3 deletions src/cli-v2/state/control-plane-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,11 +417,11 @@ export class ControlPlaneSessionStore {
latestUpdate: SessionActivityService.resolveLatestUpdate(runActivity),
});
},
onRunFinished: (runActivity) => {
onRunFinished: (runActivity, liveStatus) => {
this.assistantStreamBuffer.flush();
this.setSnapshot({
running: false,
liveStatus: undefined,
...(liveStatus !== undefined ? { liveStatus } : {}),
latestUpdate: SessionActivityService.resolveLatestUpdate(runActivity),
});
void this.refreshSession(event.sessionId, { silent: true });
Expand Down Expand Up @@ -452,7 +452,7 @@ export class ControlPlaneSessionStore {
update.text,
update.done,
),
liveStatus: update.done ? undefined : 'Receiving assistant response...',
...(!update.done ? { liveStatus: 'Receiving assistant response...' } : {}),
}));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type SessionActivityEffectHandlers = {
export type ClientSharedSessionActivityEffects = {
onAssistantStream?: (activity: ActivityOf<'assistant.stream'>, liveStatus: string | undefined) => void;
onRunStarted?: (activity: ActivityOf<'loop.started'>, liveStatus: string | undefined) => void;
onRunFinished?: (activity: ActivityOf<'loop.finished'>) => void;
onRunFinished?: (activity: ActivityOf<'loop.finished'>, liveStatus: string | undefined) => void;
onLiveStatus?: (activity: ClientSharedSessionActivity, liveStatus: string | undefined) => void;
onPendingApprovalChanged?: (activity: PendingApprovalActivity) => void;
onWorkspaceChanged?: (activity: WorkspaceChangedActivity) => void;
Expand All @@ -41,7 +41,7 @@ export class ClientSharedSessionActivityService {
effects.onRunStarted?.(activity, ClientSharedSessionActivityService.formatLiveStatus(activity));
},
'loop.finished': (activity, effects) => {
effects.onRunFinished?.(activity);
effects.onRunFinished?.(activity, ClientSharedSessionActivityService.formatLiveStatus(activity));
effects.onWorkspaceChanged?.(activity);
},
'tool.calling': (activity, effects) => {
Expand Down Expand Up @@ -72,6 +72,7 @@ export class ClientSharedSessionActivityService {

private static readonly liveStatusHandlers: SessionActivityStatusHandlers = {
'loop.started': () => 'Run started...',
'loop.finished': (activity) => `Run finished: ${activity.outcome}`,
'tool.calling': (activity) => `Working... running ${ClientSharedSessionActivityService.formatToolLabel(activity)}${ClientSharedSessionActivityService.formatStep(activity.step)}`,
'tool.completed': (activity) => `${activity.tool} finished in ${Math.round(activity.durationMs)}ms`,
'tool.approval_requested': (activity) => `Approval requested for ${ClientSharedSessionActivityService.formatToolLabel(activity)}`,
Expand Down
2 changes: 1 addition & 1 deletion src/core/chat/engine/sessions/leases/leases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { ChatSession, ChatSessionLease } from '@/core/chat/types.js';
import type { ChatSessionLeaseConflictOptions, ChatSessionLeaseOwner } from './types.js';

const DEFAULT_SESSION_LEASE_STALE_AFTER_MS = 15 * 60 * 1000;
const LOCAL_PROCESS_LEASE_OWNER_PATTERN = /^(?:tui|ask|submit|daemon)-(\d+)$/;
const LOCAL_PROCESS_LEASE_OWNER_PATTERN = /^(?:tui|ask|submit|daemon)-(\d+)(?:-\d+)?$/;

export class ChatSessionLeases {
static isFresh(
Expand Down
14 changes: 10 additions & 4 deletions src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,19 +159,25 @@ function applySessionActivity(activity: ControlPlaneSessionActivity, context: Se
streamActivity.done,
)
));
context.setLiveStatus(liveStatus);
if (liveStatus !== undefined) {
context.setLiveStatus(liveStatus);
}
},
onRunStarted: (_runActivity, liveStatus) => {
context.setRunning(true);
context.setLiveStatus(liveStatus);
},
onRunFinished: () => {
onRunFinished: (_runActivity, liveStatus) => {
context.setRunning(false);
context.setLiveStatus(undefined);
if (liveStatus !== undefined) {
context.setLiveStatus(liveStatus);
}
void context.refresh(context.sessionId, { silent: true });
},
onLiveStatus: (_statusActivity, liveStatus) => {
context.setLiveStatus(liveStatus);
if (liveStatus !== undefined) {
context.setLiveStatus(liveStatus);
}
},
onPendingApprovalChanged: () => {
context.refreshPendingApproval(context.sessionId);
Expand Down
10 changes: 7 additions & 3 deletions src/web-v2/hooks/sessions/useControlPlaneSessionRunControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function useControlPlaneSessionRunControl({
const [cancelError, setCancelError] = useState<string | undefined>();
const serverConfirmedRunningRef = useRef(false);
const runStateBaselineUpdatedAtRef = useRef(0);
const runStateDataUpdatedAtRef = useRef(0);
const sessionRunStateQuery = trpcReact.controlPlane.sessionRunState.useQuery(
sessionId && workspaceId ? { id: sessionId, workspaceId } : skipToken,
{
Expand All @@ -41,6 +42,10 @@ export function useControlPlaneSessionRunControl({
);
const cancelMutation = trpcReact.controlPlane.sessionCancel.useMutation();

useEffect(() => {
runStateDataUpdatedAtRef.current = sessionRunStateQuery.dataUpdatedAt;
}, [sessionRunStateQuery.dataUpdatedAt]);

useEffect(() => {
setRunningState(false);
setCancelling(false);
Expand All @@ -62,7 +67,6 @@ export function useControlPlaneSessionRunControl({
serverConfirmedRunningRef.current = false;
setRunningState(false);
setCancelling(false);
setLiveStatus(undefined);
if (sessionId && workspaceId) {
void Promise.all([
utils.controlPlane.session.invalidate({ id: sessionId, workspaceId }),
Expand All @@ -86,7 +90,7 @@ export function useControlPlaneSessionRunControl({
setRunningState((current) => {
const resolved = typeof nextRunning === 'function' ? nextRunning(current) : nextRunning;
if (resolved) {
runStateBaselineUpdatedAtRef.current = sessionRunStateQuery.dataUpdatedAt;
runStateBaselineUpdatedAtRef.current = runStateDataUpdatedAtRef.current;
} else {
serverConfirmedRunningRef.current = false;
}
Expand All @@ -95,7 +99,7 @@ export function useControlPlaneSessionRunControl({
});
setCancelling(false);
setCancelError(undefined);
}, [sessionRunStateQuery.dataUpdatedAt]);
}, []);

const cancelRun = useCallback(async () => {
if (!sessionId || !workspaceId || cancelMutation.isPending) {
Expand Down
Loading