diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 96bb121c7e7..c1870d35a7c 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2257,6 +2257,36 @@ describe('AgentHookServer listener replay', () => { } }) + it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) + }) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) + server.retirePaneAuthority(PANE) + + // Why: `claude --resume` in the reused shell pane emits only SessionStart while idle; + // without the un-retire the resumed session stays rowless until a prompt (STA-3386). + await postHook({ hook_event_name: 'SessionStart', source: 'resume' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true }) + ]) + } finally { + server.stop() + } + }) + it('accepts a live remote prompt but not replay after launch authority retires', () => { const server = new AgentHookServer() server.ingestRemote( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 9d2f8d01e59..f8c1d754fbf 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -394,7 +394,6 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta connectionId: entry.connectionId, receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, - ...(entry.hookEventName ? { hookEventName: entry.hookEventName } : {}), ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), @@ -997,7 +996,12 @@ export class AgentHookServer { return 'accept' } // Why: command completion retires launch authority but leaves its shell pane reusable. - if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) { + // A live SessionStart proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + if ( + (event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') && + event.isReplay !== true + ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) return 'restart' diff --git a/src/main/index.ts b/src/main/index.ts index 8a04bbdf2b0..61c7d799f94 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1426,7 +1426,6 @@ function openMainWindow(): BrowserWindow { providerSessionOnly, promptInteractionKey, restoredUnconfirmed, - hookEventName, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -1461,7 +1460,6 @@ function openMainWindow(): BrowserWindow { connectionId, receivedAt, stateStartedAt, - ...(hookEventName ? { hookEventName } : {}), ...(providerSession ? { providerSession } : {}), ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index b496f9146cd..70b6db29490 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -60,7 +60,13 @@ export function countActivityUnread( } } } - if (isUnreadAgentState(entry.state) && ackAt < entry.stateStartedAt) { + // Why: a session-boundary done is an idle connect (STA-3386), not an event to read; + // history never contains one (the status slice skips the push), so only the live entry needs the gate. + if ( + isUnreadAgentState(entry.state) && + entry.sessionBoundary !== true && + ackAt < entry.stateStartedAt + ) { count += 1 } } diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 2c81cec0be2..6449e570fe1 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -15,7 +15,9 @@ export function lastEnteredDoneAt( return null } const entry = agent.entry - if (entry.state === 'done') { + // Why: a session-boundary done means the session connected idle (STA-3386) — nothing + // finished; fall through to history (which never records session boundaries). + if (entry.state === 'done' && entry.sessionBoundary !== true) { return entry.stateStartedAt } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index b948cf43da6..b3059ca6390 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -810,6 +810,13 @@ export function createAgentCompletionCoordinator( dispatchAttention(payload) return } + if (payload.state === 'done' && payload.sessionBoundary === true) { + // Why: a session-boundary 'done' is an idle session connecting (resume/launch/clear, + // STA-3386), not a completed turn — record the activity/evidence above but never + // complete. Pending timers stay untouched: a quiet-window done from a real prior + // turn must still fire. + return + } if (isCompletionHookState(payload.state)) { // Why: the turn is ending, so a debounced attention from an earlier pause must not fire after completion. clearPendingCodexAttention() diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 68b216bb427..4803731fb70 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -421,20 +421,20 @@ describe('agent hook completion notifications', () => { ) }) - it('does not fire a completion notification for a SessionStart idle row', async () => { + it('does not fire a completion notification for a session-boundary done row', async () => { const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') - // Why: Claude SessionStart lands as 'done' so a resumed session gets its sidebar - // row while idle (STA-3386) — connecting to a session is not completing a turn. + // Why: Claude SessionStart lands as a sessionBoundary 'done' so a resumed session gets + // its sidebar row while idle (STA-3386) — connecting to a session is not completing a turn. observeAgentHookCompletionForNotification({ paneKey, worktreeId: 'wt-1', - hookEventName: 'SessionStart', payload: { state: 'done', prompt: '', agentType: 'claude', + sessionBoundary: true, stateStartedAt: 1_700_000_000_000 } }) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index fdd757e0deb..125c0994514 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -272,19 +272,12 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion export function observeAgentHookCompletionForNotification({ paneKey, worktreeId, - payload, - hookEventName + payload }: { paneKey: string worktreeId: string payload: AgentCompletionStatusSnapshot - hookEventName?: string }): void { - if (hookEventName === 'SessionStart' && payload.state === 'done') { - // Why: a SessionStart that lands 'done' is an idle session connecting (resume/launch, - // STA-3386) — not a completed turn, so it must not raise agent-task-complete. - return - } pruneClosedPaneCoordinators() if (!paneCanReceiveHookCompletion(paneKey)) { return diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 160496c3be8..d49778b3a05 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -539,6 +539,35 @@ describe('useAutomationDispatchEvents setup launch', () => { }) }) + it('ignores a session-boundary done so a connecting agent cannot complete the run (STA-3386)', async () => { + let launchArgs: { + onAgentStatus?: (payload: { state: string; sessionBoundary?: boolean }) => void + } = {} + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + // Why: Claude fires SessionStart (a sessionBoundary done) at launch, before the argv + // prompt submits — treating it as run completion would close the tab on an empty run. + launchArgs.onAgentStatus?.({ state: 'done', sessionBoundary: true }) + await Promise.resolve() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + }) + it('consumes duplicate done and zero-exit completion through one finalizer', async () => { let launchArgs: { onAgentStatus?: (payload: { state: string }) => void diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index d543d639ddf..09009d40011 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -416,6 +416,10 @@ export function useAutomationDispatchEvents(): void { } if ( entry.state === 'done' && + // Why: a session-boundary done is the agent CONNECTING (Claude SessionStart + // fires at launch, before the argv prompt submits) — completing here would + // close the tab and record an empty run result. + entry.sessionBoundary !== true && (!options?.requireWorkingAfterStart || sawWorkingAfterStart) ) { latestAssistantMessage = @@ -528,7 +532,8 @@ export function useAutomationDispatchEvents(): void { onAgentStatus: (payload) => { latestAssistantMessage = payload.lastAssistantMessage?.trim() || latestAssistantMessage - if (payload.state !== 'done') { + // Why: session-boundary done = launch connect, not run completion (see observeAgentStatus). + if (payload.state !== 'done' || payload.sessionBoundary === true) { return } handleAgentDone() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 9932dc34863..bed3e4e86c8 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3286,8 +3286,7 @@ export function useIpcEvents(): void { observeAgentHookCompletionForNotification({ paneKey, worktreeId: statusWorktreeId, - payload: notificationPayload, - ...(data.hookEventName ? { hookEventName: data.hookEventName } : {}) + payload: notificationPayload }) } return 'applied' diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index fcf39188136..972d569b230 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1739,8 +1739,14 @@ export const createAgentStatusSlice: StateCreator { 'production' ) - // Why: 'working' would show a phantom spinner on an idle TUI (Devin precedent); + // Why: 'working' would show a phantom spinner on an idle TUI; a session-boundary // 'done' renders the row idle, which is the truth at SessionStart. - expect(event?.payload).toMatchObject({ state: 'done', prompt: '', agentType: 'claude' }) + expect(event?.payload).toMatchObject({ + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) expect(event?.payload.interrupted).toBeUndefined() expect(event?.hookEventName).toBe('SessionStart') // Why: SessionStart carries resume identity, so in-app resume works before any prompt. @@ -1427,6 +1432,13 @@ describe('shared agent-hook-listener', () => { { paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart', source: 'compact' } }, 'production' ) + // Why: unknown/missing sources fail closed — only startup/resume/clear are idle boundaries. + const unknownSource = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart' } }, + 'production' + ) const child = normalizeHookPayload( state, 'claude', @@ -1441,8 +1453,10 @@ describe('shared agent-hook-listener', () => { // Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle) and a // child-attributed SessionStart must not flip the lead's live turn to an idle row. expect(compacted).toBeNull() + expect(unknownSource).toBeNull() expect(child).toBeNull() expect(stopped?.payload).toMatchObject({ state: 'done', prompt: 'say hi' }) + expect(stopped?.payload.sessionBoundary).toBeUndefined() }) it('normalizes Devin documented lifecycle events', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 6a76225f6dc..9e133aa683a 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2742,23 +2742,32 @@ function normalizeClaudeEvent( return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) } if (eventName === 'SessionStart') { - // Why: SessionStart fires while the TUI is idle (startup/resume/clear), so land the row - // 'done' — 'working' would show a phantom spinner before any prompt (Devin precedent). - // This is the only signal a resumed session emits before its first prompt (STA-3386). - if (eventAgentId !== undefined || hookPayload['source'] === 'compact') { - // Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle), and a - // child-attributed SessionStart must not flip the lead's live turn to an idle row. + // Why: SessionStart is the only signal a resumed session emits before its first prompt + // (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom + // spinner on an idle TUI (why Devin/Pi/Grok drop the event), and the sessionBoundary + // flag keeps completion-reactive consumers (notifications, automation runs) out of it. + const sessionStartSource = hookPayload['source'] + if ( + eventAgentId !== undefined || + (sessionStartSource !== 'startup' && + sessionStartSource !== 'resume' && + sessionStartSource !== 'clear') + ) { + // Why: allowlist idle boundaries and fail closed — a compact restart (or any unknown + // source) fires mid-turn, and a child-attributed SessionStart must not flip the lead's + // live turn to an idle row. return null } // Why: a new process owns the pane; stale children/tasks/crons must not gate the - // fresh session's idle row back up to 'working' (mirrors the Codex SessionStart reset). + // fresh session's idle row back up to 'working' (same reset Codex does on SessionStart). state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) state.claudeActiveSessionCronPaneKeys.delete(paneKey) state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: 'done', - updateToolSnapshot: true + updateToolSnapshot: true, + sessionBoundary: true }) } const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) @@ -2912,7 +2921,12 @@ function buildClaudeStatusPayload( promptText: string, paneKey: string, hookPayload: Record, - options: { stateName: AgentStatusState; updateToolSnapshot: boolean; interrupted?: boolean } + options: { + stateName: AgentStatusState + updateToolSnapshot: boolean + interrupted?: boolean + sessionBoundary?: boolean + } ): ParsedAgentStatusPayload | null { // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. const snapshot = options.updateToolSnapshot @@ -2935,6 +2949,7 @@ function buildClaudeStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, interrupted: options.interrupted, + sessionBoundary: options.sessionBoundary, subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) }) } diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 6059950b708..1dea3dc70fe 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -421,6 +421,20 @@ Fix dispatch fallback preview for normalized status prompts` } }) + it('preserves sessionBoundary=true only on done (stale-signal suppression like interrupted)', () => { + expect( + parseAgentStatusPayload('{"state":"done","sessionBoundary":true}')!.sessionBoundary + ).toBe(true) + for (const state of ['working', 'blocked', 'waiting'] as const) { + const result = parseAgentStatusPayload(`{"state":"${state}","sessionBoundary":true}`) + expect(result!.sessionBoundary).toBeUndefined() + } + // Why: parser uses `=== true`, so truthy sentinels don't count. + expect( + parseAgentStatusPayload('{"state":"done","sessionBoundary":"true"}')!.sessionBoundary + ).toBeUndefined() + }) + it('requires strict boolean true for interrupted (rejects truthy non-boolean)', () => { // Why: parser uses `=== true`, so truthy string/number sentinels don't count. expect( diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 7436109df56..123a1bf5eb7 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -126,6 +126,8 @@ export type AgentStatusEntry = { /** True when this `done` was reached via interrupt, not normal completion * (agent-reported or Orca's guarded fallback). Undefined otherwise. */ interrupted?: boolean + /** True when this `done` is a session boundary, not a completed turn. See AgentStatusPayload. */ + sessionBoundary?: boolean /** Orchestration dispatch context for panes spawned by another agent. * Why: parent/child hierarchy is pane-level state, not worktree lineage — workers often share the coordinator's worktree. */ orchestration?: AgentStatusOrchestrationContext @@ -170,6 +172,11 @@ export type AgentStatusPayload = { interactivePrompt?: string lastAssistantMessage?: string interrupted?: boolean + /** True when this `done` marks a session boundary (connect/resume/clear landing idle, + * e.g. Claude SessionStart — STA-3386), not a completed turn. Consumers that react to + * completions (notifications, automation runs, unread badges, finished timestamps) + * must ignore it. Only meaningful on `done`. */ + sessionBoundary?: boolean /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] } @@ -202,6 +209,7 @@ export function pickParsedAgentStatusPayload( ? { lastAssistantMessage: row.lastAssistantMessage } : {}), ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), + ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.subagents !== undefined ? { subagents: row.subagents } : {}) } } @@ -219,10 +227,6 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { /** Identifies the SSH connection the event arrived on, or null for local. * Only the remote-ingest path (`ingestRemote`) can stamp it; the HTTP path always sets null. See docs/design/agent-status-over-ssh.md §5. */ connectionId: string | null - /** Hook event that produced this status, when it came from a native hook. - * Why: lets receivers tell a session boundary from a turn result — a SessionStart - * 'done' is an idle connect (STA-3386), not a completion to notify about. */ - hookEventName?: string /** Timestamp (ms) when the hook server received this latest status event. */ receivedAt: number /** Timestamp (ms) when the current state first appeared for this pane. */ @@ -405,6 +409,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | ), // Why: only meaningful on `done`; coerce to undefined elsewhere so it can't leak stale truth across transitions. interrupted: obj.interrupted === true && state === 'done' ? true : undefined, + sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, subagents: normalizeSubagentsField(obj.subagents) } }