From 0f292ca9b60173e17451bf3b2ecff290deda1233 Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Wed, 30 Sep 2026 22:54:40 +0530 Subject: [PATCH 01/10] fix: correct managed OMP readiness and lifecycle status --- docs/reference/agent-status-store.md | 28 ++ ...ent-status-extension-omp-lifecycle.test.ts | 1 - ...agent-status-extension-omp-prefill.test.ts | 1 - .../pi/agent-status-extension-source.test.ts | 29 -- src/main/pi/agent-status-extension-source.ts | 8 +- .../pi/agent-status-extension-test-harness.ts | 18 +- src/main/pi/agent-status-handler-source.ts | 52 ++-- ...agent-status-omp-state-transitions.test.ts | 256 ++++++++++++++++++ src/main/pi/agent-status-ui-prompt-source.ts | 2 +- .../pi/omp-session-status-owner-source.ts | 7 +- src/main/pi/omp-session-status-owner.test.ts | 11 +- src/main/pi/omp-status-lifecycle-source.ts | 113 ++++++++ src/main/runtime/terminal-wait-detection.ts | 5 +- .../terminal-wait-name-only-idle.test.ts | 11 + .../providers/pi-family-events.ts | 74 ++++- 15 files changed, 548 insertions(+), 68 deletions(-) create mode 100644 src/main/pi/agent-status-omp-state-transitions.test.ts create mode 100644 src/main/pi/omp-status-lifecycle-source.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index b7ce4fa16dde..c81b4472e1e5 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -61,6 +61,34 @@ Three consequences: decay, acknowledgements, dismissals, unread. Those stay reader-side but become one shared implementation (PR 3). +## Managed OMP hook status + +The managed extension sends OMP events to `/hook/omp` on the execution host. +`normalizePiCompatibleEvent` maps them into the existing hook status store. +Native, WSL, SSH, and folder workspaces use the same mapping. +No second reporter or reader-side rule is required. + +- Startup and session switches send current activity. Readiness requires idle, + no pending messages, and no active jobs. Missing activity does not prove readiness. +- Idle readiness uses `done` with `sessionBoundary: true`. It is not turn completion. +- Retry and compaction start as `working`. Their end can restore readiness only + when current activity is clear and no continuation flag is true. +- Real UI dialogs use `waiting`; tool approvals use `blocked`. Closing a dialog + restores current activity. Nested dialogs, reloads, and stale closes are tracked. +- `agent_end` with `willContinue: true` does not complete a turn. Owned jobs, + children, and pending messages keep the combined row `working` after the main + agent completes. The final all-clear keeps the main-agent outcome and its clock. +- The last assistant stop reason maps `stop` to `success`, `error` to `failure`, + and `aborted` to `cancellation`. Other reasons have no outcome. Normal provider + completion does not prove that the user's task succeeded. +- Shutdown clears local timers and dialog tracking without a completion event. + Missing hooks or transport loss never prove process exit. Process verdicts + remain `live`, `unverifiable`, and `exited`. + +Activity and outcome fields are optional payload fields. Existing remote clients +do not need a new wire opcode. A source change does not update an installed app +or its managed extension until that source is built and deployed. + ## The store already exists The hook server's state is that store today for every PTY-based agent. The diff --git a/src/main/pi/agent-status-extension-omp-lifecycle.test.ts b/src/main/pi/agent-status-extension-omp-lifecycle.test.ts index 5d89d31aec12..f72a83741943 100644 --- a/src/main/pi/agent-status-extension-omp-lifecycle.test.ts +++ b/src/main/pi/agent-status-extension-omp-lifecycle.test.ts @@ -97,7 +97,6 @@ describe('OMP agent_end contract', () => { await vi.waitFor(() => expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end']) ) - expect(context.isIdle).not.toHaveBeenCalled() } } ) diff --git a/src/main/pi/agent-status-extension-omp-prefill.test.ts b/src/main/pi/agent-status-extension-omp-prefill.test.ts index 5e8f9118944e..ffd53dc0bafb 100644 --- a/src/main/pi/agent-status-extension-omp-prefill.test.ts +++ b/src/main/pi/agent-status-extension-omp-prefill.test.ts @@ -15,7 +15,6 @@ describe('OMP prefill through the explicitly loaded status extension', () => { h.reload() await h.callHook('session_start', {}, { ui: { setEditorText } }) expect(setEditorText).toHaveBeenCalledTimes(1) - expect(h.fetchMock).not.toHaveBeenCalled() }) it('preserves the draft until an editor is available', async () => { diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 5ecee4fb0f25..f231375f7968 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -163,35 +163,6 @@ describe('getPiAgentStatusExtensionSource', () => { } }) - it('keeps OMP runtime status payloads unchanged by Pi session metadata', async () => { - const harness = createHarness({ kind: 'omp' }) - - await harness.callHook( - 'session_start', - {}, - { - sessionManager: { - getSessionId: () => 'omp-session-1', - getSessionFile: () => '/tmp/omp-session-1.jsonl' - } - } - ) - await harness.callHook('agent_start') - - expect(harness.fetchMock).toHaveBeenCalledTimes(1) - expect(harness.fetchMock.mock.calls[0]?.[1]?.body).toBe( - JSON.stringify({ - paneKey: 'pane-1', - launchToken: 'launch-1', - tabId: 'tab-1', - worktreeId: 'tree-1', - env: 'env-1', - version: '1.2.3', - payload: { hook_event_name: 'agent_start' } - }) - ) - }) - it('tracks persistent OMP sessions and clears ephemeral session ids', async () => { const harness = createHarness({ kind: 'omp' }) let sessionId = 'omp-session-8' diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 58a18b78fa56..57c17f31f671 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -208,15 +208,15 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' const metadata = getPostSessionMetadata(ompRuntime)', '// Model changes must not erase an unacknowledged completion in the latest-only slot.', " const previousCompletion = latestPost?.hookEventName === 'agent_end' && !latestPost.delivered && latestPost.metadata.session_id === metadata.session_id", + " const preserveCompletion = ompRuntime && previousCompletion && ['model_select', 'ui_prompt_start', 'ui_prompt_end'].includes(hookEventName)", ' pendingPost = {', ' revision: ++postRevision,', ' attempts: 0,', ' delivered: false,', - " hookEventName: ompRuntime && hookEventName === 'model_select' && previousCompletion ? 'agent_end' : hookEventName,", - // Why: every coalesced snapshot must retain an open modal, not just its start event. + " hookEventName: preserveCompletion ? 'agent_end' : hookEventName,", kind === 'pi' - ? ' extra: { ...extra, ...(!ompRuntime && piUiPromptDepth > 0 ? { ui_prompt_active: true } : {}) },' - : ' extra,', + ? ' extra: { ...(preserveCompletion ? latestPost.extra : {}), ...extra, ...((ompRuntime ? ompUiPromptDepth : piUiPromptDepth) > 0 ? { ui_prompt_active: true } : preserveCompletion ? { ui_prompt_active: false } : {}) },' + : ' extra: { ...(preserveCompletion ? latestPost.extra : {}), ...extra, ...(ompUiPromptDepth > 0 ? { ui_prompt_active: true } : preserveCompletion ? { ui_prompt_active: false } : {}) },', ' metadata,', ' ompRuntime,', ' }', diff --git a/src/main/pi/agent-status-extension-test-harness.ts b/src/main/pi/agent-status-extension-test-harness.ts index 0c07caf8256e..3b96f6c73fe7 100644 --- a/src/main/pi/agent-status-extension-test-harness.ts +++ b/src/main/pi/agent-status-extension-test-harness.ts @@ -8,8 +8,16 @@ import { getPiAgentStatusExtensionSource } from './agent-status-extension-source export type HookContext = { hasUI?: boolean - ui?: { setEditorText?: (text: string) => void; notify?: (message: string, level: string) => void } + ui?: { + setEditorText?: (text: string) => void + notify?: (message: string, level: string) => void + confirm?: (title: string, message: string) => Promise + input?: (title: string, placeholder?: string) => Promise + } isIdle?: () => boolean + hasPendingMessages?: () => boolean + getAsyncJobSnapshot?: () => { running: unknown[]; recent?: unknown[] } | null + agent?: { kind: 'main' | 'sub' } model?: { provider?: unknown; id?: unknown } | null modelRegistry?: { getAvailable: () => { provider: string; id: string }[] } sessionManager?: { @@ -205,7 +213,13 @@ export function createAgentStatusExtensionHarness(args: { setModel: setModelMock, events: piEvents, on(name: string, handler: HookHandler) { - target[name] = handler + const previous = target[name] + target[name] = previous + ? async (event, ctx) => { + await previous(event, ctx) + await handler(event, ctx) + } + : handler } }) } diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index a88490cf6aef..7ecac802c556 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -4,6 +4,7 @@ import { getAgentStatusInputRedactionSourceLines } from './agent-status-input-re import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getOmpSessionOwnerHandlerSourceLines } from './omp-session-status-owner-source' import { getPiAgentStatusUiPromptHandlerSourceLines } from './agent-status-ui-prompt-source' +import * as ompStatusLifecycle from './omp-status-lifecycle-source' import { getPiSubagentRosterEventSourceLines, getPiSubagentRosterSetupSourceLines @@ -17,6 +18,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ? [ " onStatus('session_start', (event, ctx) => {", ' updateSessionMetadata(ctx)', + ' if (isOmpRuntime()) { invalidateOmpUiDialogs(); post("session_start", readOmpActivity(ctx)); return }', ...(kind === 'pi' ? [' piUiPromptDepth = 0'] : []), ' // Why: /reload re-registers the active session, but it is not a', ' // turn boundary and must not clear the visible status or unread state.', @@ -25,7 +27,13 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })', '' ] - : [] + : [ + " onStatus('session_start', (_event, ctx) => {", + ' invalidateOmpUiDialogs()', + ' updateRuntimeOmpSessionMetadata(ctx)', + " post('session_start', readOmpActivity(ctx))", + ' })' + ] // Why: OMP can switch sessions in-process, so each latest-only post needs fresh identity. const ctxParam = ', ctx' @@ -63,6 +71,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] " post('tool_approval_resolved', {", ' tool_name: event.toolName,', ' approved: event.approved,', + ' ...readOmpActivity(ctx),', ' })', ' })', '' @@ -123,6 +132,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] " return (err as { code?: string } | null)?.code !== 'ESRCH'", ' }', '}', + ...ompStatusLifecycle.getOmpStatusLifecycleSourceLines(), '', "// Why: child agents inherit the lead's pane env; only its process may", '// register status hooks. PID identity keeps in-process reloads reporting.', @@ -136,26 +146,14 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ...getPiSubagentRosterSetupSourceLines(), ...(kind !== 'pi' ? [ - " pi.on('session_shutdown', () => { lifecycleState.active.clear(); lifecycleState.exited?.clear(); lifecycleState.waiting = false; resetPostQueue(); clearPendingAgentEndCheck() })" - ] - : []), - ...(kind !== 'prime-agent' - ? [ - " pi.on('session_switch', (_event, ctx) => {", - ' if (!isOmpRuntime()) return', - ' lifecycleState.active.clear()', - ' lifecycleState.exited?.clear()', - ' lifecycleState.waiting = false', - ' resetPostQueue()', - ' clearPendingAgentEndCheck()', - ' updateRuntimeOmpSessionMetadata(ctx)', - ' })' + " pi.on('session_shutdown', (_event, ctx) => { if (!ownsSessionStatus(ctx)) return; lifecycleState.active.clear(); lifecycleState.exited?.clear(); lifecycleState.waiting = false; resetPostQueue(); clearPendingAgentEndCheck(); invalidateOmpUiDialogs(); ompCompletionExtra = {}; ompCompletionContext = null })" ] : []), ...getOmpSessionOwnerHandlerSourceLines(), ...getOmpModelCommandSourceLines(), ...sessionStartHandler, ...(kind === 'omp' ? getPiPrefillHandlerSourceLines('omp', true) : []), + ...ompStatusLifecycle.getOmpStatusLifecycleHandlerSourceLines(), ` onStatus('before_agent_start', (event${ctxParam}) => {`, ...captureSessionMetadata, " post('before_agent_start', { prompt: event.prompt ?? '' })", @@ -166,6 +164,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' clearPendingAgentEndCheck()', ' lifecycleState.waiting = false', ' runGeneration += 1', + ' if (isOmpRuntime()) { invalidateOmpUiDialogs(); ompCompletionExtra = {}; ompCompletionContext = null }', // Why: a turn cannot begin under a dialog holding input focus, so this is the one // boundary that can recover a modal whose close never arrived. ...(kind === 'pi' ? [' piUiPromptDepth = 0', ' piTurnInFlight = true'] : []), @@ -212,8 +211,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] '', ' // Why: modern Pi stays non-idle across retry/compaction/follow-up work,', ' // while legacy Pi becomes idle after its final agent_end handlers.', - ' // OMP instead marks non-terminal agent_end events with willContinue, so it', - ' // returns before the recheck timer is ever armed.', + ' // OMP continuation events never establish completion; owned jobs delay all-clear.', ' const AGENT_END_IDLE_RECHECK_MS = 25', ' const AGENT_END_IDLE_RECHECK_MAX_MS = 250', ' let agentSettledSupported = false', @@ -226,6 +224,8 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' let runGeneration = 0', ' let endedRunGeneration = 0', ' let completionPostedGeneration = -1', + ' let ompCompletionExtra: Record = {}', + ' let ompCompletionContext = null', ' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', ' let pendingAgentEndCheck: ReturnType | null = null', ' let pendingAgentEndContext: { isIdle: () => boolean } | null = null', @@ -241,15 +241,26 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' lifecycleState.exited?.clear()', ' if (lifecycleState.active.size > 0) {', ' lifecycleState.waiting = true', + ' if (isOmpRuntime()) post("agent_end", { ...ompCompletionExtra, has_active_jobs: true })', ' return', ' }', + ' if (isOmpRuntime() && ompCompletionContext) {', + ' const activity = readOmpActivity(ompCompletionContext)', + ' if (activity.has_active_jobs === true || activity.has_pending_messages === true) {', + ' post("agent_end", { ...ompCompletionExtra, ...activity })', + ' pendingAgentEndContext = ompCompletionContext', + ' if (pendingAgentEndCheck === null) pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, AGENT_END_IDLE_RECHECK_MS)', + ' pendingAgentEndCheck?.unref?.()', + ' return', + ' }', + ' }', ' lifecycleState.waiting = false', ' if (completionPostedGeneration === endedRunGeneration) return', ' completionPostedGeneration = endedRunGeneration', // Why: distinct from the completion guard, which holds the generation of the posted run // and so starts clean on a pane that has not run a turn yet — that pane is idle, not busy. ...(kind === 'pi' ? [' piTurnInFlight = false'] : []), - " post('agent_end')", + " post('agent_end', isOmpRuntime() ? ompCompletionExtra : {})", ' }', '', ' function checkPendingAgentEnd(): void {', @@ -260,7 +271,8 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' return', ' }', ' try {', - ' if (ctx.isIdle()) {', + ' const activity = isOmpRuntime() ? readOmpActivity(ctx) : null', + ' if (ctx.isIdle() && activity?.has_active_jobs !== true && activity?.has_pending_messages !== true) {', ' pendingAgentEndContext = null', ' postAgentEndOnce()', ' return', @@ -289,6 +301,8 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' }', ' endedRunGeneration = runGeneration', ' if (isOmpRuntime()) {', + ' ompCompletionContext = ctx', + ' ompCompletionExtra = { turn_outcome: readOmpTurnOutcome(event?.messages) }', ' postAgentEndOnce()', ' return', ' }', diff --git a/src/main/pi/agent-status-omp-state-transitions.test.ts b/src/main/pi/agent-status-omp-state-transitions.test.ts new file mode 100644 index 000000000000..dc6bea79c118 --- /dev/null +++ b/src/main/pi/agent-status-omp-state-transitions.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it, vi } from 'vitest' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { + createHookListenerState, + seedLegacyAgentStatusForTests +} from '../../shared/agent-hook-listener/listener-state' +import { PANE_KEY } from '../../shared/agent-hook-listener-test-harness' +import type { AgentStatusPayload } from '../../shared/agent-status-types' +import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' + +function createStatusScenario(kind: 'pi' | 'omp' = 'omp') { + const state = createHookListenerState() + const rows: AgentStatusPayload[] = [] + const harness = createAgentStatusExtensionHarness({ + kind, + title: 'omp', + env: { + ORCA_PANE_KEY: PANE_KEY, + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_VERSION: '1' + }, + fetchImpl: async (_url, init) => { + const row = normalizeHookPayload(state, 'omp', JSON.parse(String(init?.body)), 'production') + if (row) { + seedLegacyAgentStatusForTests(state, row) + rows.push(row.payload) + } + return { ok: true } + } + }) + return { harness, rows } +} + +const idle = { + isIdle: () => true, + hasPendingMessages: () => false, + getAsyncJobSnapshot: () => ({ running: [] }) +} + +describe('managed OMP status transitions', () => { + it.each(['pi', 'omp'] as const)( + 'reports readiness and clears turn details on %s session switch', + async (kind) => { + const { harness, rows } = createStatusScenario(kind) + await harness.callHook('session_start', {}, idle) + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ state: 'done', sessionBoundary: true }) + ) + await harness.callHook('before_agent_start', { prompt: 'old request' }, idle) + await harness.callHook( + 'tool_execution_start', + { toolName: 'bash', args: { command: 'pwd' } }, + idle + ) + await vi.waitFor(() => expect(rows.at(-1)?.toolName).toBe('bash')) + await harness.callHook('session_switch', {}, idle) + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ state: 'done', sessionBoundary: true }) + ) + expect(rows.at(-1)?.prompt || undefined).toBeUndefined() + expect(rows.at(-1)?.toolName).toBeUndefined() + expect(rows.at(-1)?.mainAgent).toBeUndefined() + } + ) + + it('does not infer readiness from an incomplete startup snapshot', async () => { + const { harness, rows } = createStatusScenario() + await harness.callHook('session_start', {}, { isIdle: () => true }) + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('working')) + }) + + it('keeps readiness unknown when the async snapshot is null', async () => { + const { harness, rows } = createStatusScenario() + await harness.callHook('session_start', {}, { ...idle, getAsyncJobSnapshot: () => null }) + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('working')) + }) + + it.each(['auto_retry', 'auto_compaction'])( + 'restores readiness after %s without a completed turn', + async (phase) => { + const { harness, rows } = createStatusScenario() + await harness.callHook(`${phase}_start`, {}, idle) + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('working')) + await harness.callHook(`${phase}_end`, {}, idle) + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ state: 'done', sessionBoundary: true }) + ) + expect(rows.at(-1)?.mainAgent).toBeUndefined() + } + ) + + it('keeps combined work active until pending messages clear', async () => { + const { harness, rows } = createStatusScenario() + let pending = true + const context = { ...idle, hasPendingMessages: () => pending } + await harness.callHook('agent_start', {}, context) + await harness.callHook( + 'agent_end', + { messages: [{ role: 'assistant', stopReason: 'stop' }] }, + context + ) + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ state: 'working', mainAgent: { outcome: 'success' } }) + ) + pending = false + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ state: 'done', mainAgent: { outcome: 'success' } }) + ) + }) + + it.each(['auto_retry', 'auto_compaction'])( + 'keeps %s active until terminal agent_end', + async (phase) => { + const { harness, rows } = createStatusScenario() + await harness.callHook(`${phase}_start`, {}, idle) + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('working')) + await harness.callHook(`${phase}_end`, { willRetry: true }, idle) + await harness.callHook('agent_end', { willContinue: true }, idle) + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('working')) + expect(rows.some((row) => row.state === 'done')).toBe(false) + await harness.callHook( + 'agent_end', + { messages: [{ role: 'assistant', stopReason: 'stop' }] }, + idle + ) + await vi.waitFor(() => expect(rows.at(-1)?.mainAgent?.outcome).toBe('success')) + } + ) + + it.each([ + ['stop', 'success'], + ['error', 'failure'], + ['aborted', 'cancellation'], + ['length', undefined], + ['toolUse', undefined] + ] as const)( + 'maps observed %s without treating every completion as success', + async (stopReason, outcome) => { + const { harness, rows } = createStatusScenario() + await harness.callHook('agent_start', {}, idle) + await harness.callHook('agent_end', { messages: [{ role: 'assistant', stopReason }] }, idle) + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('done')) + expect(rows.at(-1)?.mainAgent?.outcome).toBe(outcome) + expect(rows.at(-1)?.interrupted).toBe(outcome === 'cancellation' ? true : undefined) + } + ) + + it('keeps combined work active while jobs run and preserves the main-agent outcome clock', async () => { + const { harness, rows } = createStatusScenario() + let running = true + const context = { ...idle, getAsyncJobSnapshot: () => ({ running: running ? [{}] : [] }) } + await harness.callHook('agent_start', {}, context) + await harness.callHook( + 'agent_end', + { messages: [{ role: 'assistant', stopReason: 'error' }] }, + context + ) + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'failure' } + }) + ) + const clock = rows.at(-1)?.mainAgent?.stateStartedAt + running = false + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('done')) + expect(rows.at(-1)?.mainAgent).toMatchObject({ outcome: 'failure', stateStartedAt: clock }) + }) + + it('restores readiness after nested dialogs and ignores a late close from an old session', async () => { + const { harness, rows } = createStatusScenario() + const close: (() => void)[] = [] + const ui = { + confirm: () => { + const { promise, resolve } = Promise.withResolvers() + close.push(() => resolve(true)) + return promise + } + } + const context = { ...idle, hasUI: true, ui } + await harness.callHook('session_start', {}, context) + const first = ui.confirm() + const second = ui.confirm() + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('waiting')) + close[0]() + await first + expect(rows.at(-1)?.state).toBe('waiting') + close[1]() + await second + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ state: 'done', sessionBoundary: true }) + ) + const oldDialog = ui.confirm() + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('waiting')) + await harness.callHook('session_switch', {}, { ...context, isIdle: () => false }) + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('working')) + const count = rows.length + close[2]() + await oldDialog + expect(rows.length).toBe(count) + }) + + it('closes waiting state when a dialog rejects', async () => { + const { harness, rows } = createStatusScenario() + const ui = { + confirm: async () => { + throw new Error('dialog failed') + } + } + await harness.callHook('session_start', {}, { ...idle, hasUI: true, ui }) + await expect(ui.confirm()).rejects.toThrow('dialog failed') + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ state: 'done', sessionBoundary: true }) + ) + }) + + it('tracks real handler UI proxy calls across an extension reload', async () => { + const { harness, rows } = createStatusScenario() + const { promise, resolve } = Promise.withResolvers() + const sharedUi = { confirm: () => promise } + const ui = new Proxy(sharedUi, { + get(target, key, receiver) { + if (key === 'confirm') { + return () => target.confirm() + } + return Reflect.get(target, key, receiver) + } + }) + const context = { ...idle, hasUI: true, ui } + await harness.callHook('session_start', {}, context) + const dialog = ui.confirm() + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('waiting')) + harness.reload() + await harness.callHook('model_select', {}, context) + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('waiting')) + resolve(true) + expect(await dialog).toBe(true) + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ state: 'done', sessionBoundary: true }) + ) + }) + + it.each(['pi', 'omp'] as const)('ignores stale dialog closes after %s shutdown', async (kind) => { + const { harness, rows } = createStatusScenario(kind) + const { promise, resolve } = Promise.withResolvers() + const ui = { confirm: () => promise } + const context = { ...idle, hasUI: true, ui } + await harness.callHook('session_start', {}, context) + const dialog = ui.confirm() + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('waiting')) + await harness.callHook('session_shutdown', {}, context) + resolve(true) + await dialog + expect(rows.at(-1)?.state).toBe('waiting') + }) +}) diff --git a/src/main/pi/agent-status-ui-prompt-source.ts b/src/main/pi/agent-status-ui-prompt-source.ts index d961864cd412..b64f4203f083 100644 --- a/src/main/pi/agent-status-ui-prompt-source.ts +++ b/src/main/pi/agent-status-ui-prompt-source.ts @@ -35,7 +35,7 @@ export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): s " onStatus('session_shutdown', () => {", ' resetPostQueue()', ' clearPendingAgentEndCheck()', - ' if (isOmpRuntime()) return', + ' if (isOmpRuntime()) { invalidateOmpUiDialogs(); ompCompletionExtra = {}; ompCompletionContext = null; lifecycleState.active.clear(); lifecycleState.exited?.clear(); lifecycleState.waiting = false; return }', ' // Why: pi tears an open dialog down through resetExtensionUI without resolving its', ' // promise, so a replaced session never emits the matching ui_prompt_end and the wait', ' // would stick forever. Reset without posting: shutdown is not a turn boundary, and', diff --git a/src/main/pi/omp-session-status-owner-source.ts b/src/main/pi/omp-session-status-owner-source.ts index 9e29126f0147..ff219c498be7 100644 --- a/src/main/pi/omp-session-status-owner-source.ts +++ b/src/main/pi/omp-session-status-owner-source.ts @@ -26,8 +26,7 @@ export function getOmpSessionOwnerHandlerSourceLines(): string[] { '', ' function ownsSessionStatus(ctx): boolean {', ' if (!isOmpRuntime()) return true', - ' // Newer OMP builds may expose this computed runtime provenance directly.', - ' if (ctx?.agentKind === "sub") return false', + ' if (ctx?.agent?.kind === "sub" || ctx?.agentKind === "sub") return false', ' const current = sessionProvenance(ctx)', ' if (!current) return true', " // A task transcript is never the pane's resumable root, even when its", @@ -43,7 +42,7 @@ export function getOmpSessionOwnerHandlerSourceLines(): string[] { ' const pane = JSON.stringify([process.env.ORCA_PANE_KEY, process.env.ORCA_AGENT_LAUNCH_TOKEN])', ' const owner = owners.get(pane)', ' if (owner) {', - ' if (owner.manager === current.manager) return true', + ' if (owner.manager === current.manager) { owners.set(pane, current); return true }', ' if (current.parent === owner.file || current.parent === owner.id) return false', ' if (isNestedTaskTranscript(owner.file, current.file)) return false', ' return false', @@ -55,11 +54,11 @@ export function getOmpSessionOwnerHandlerSourceLines(): string[] { ' function onStatus(name, handler): void {', ' pi.on(name, (event, ctx) => {', ' if (!ownsSessionStatus(ctx)) return', + ' installOmpUiTracking(ctx)', ' return handler(event, ctx)', ' })', ' }', '', - " onStatus('session_start', () => {})", '' ] } diff --git a/src/main/pi/omp-session-status-owner.test.ts b/src/main/pi/omp-session-status-owner.test.ts index 7d1c5dc42b47..d04623053a0f 100644 --- a/src/main/pi/omp-session-status-owner.test.ts +++ b/src/main/pi/omp-session-status-owner.test.ts @@ -74,7 +74,10 @@ describe('OMP session status ownership', () => { await settle() } expect( - harness.fetchMock.mock.calls.map((call) => JSON.parse(call[1].body).payload.session_id) + harness.fetchMock.mock.calls + .map((call) => JSON.parse(call[1].body).payload) + .filter((payload) => payload.hook_event_name === 'agent_start') + .map((payload) => payload.session_id) ).toEqual(['initial', 'new', 'resumed']) }) it('uses a distinct ownership key when pane and launch change before callbacks', async () => { @@ -91,7 +94,9 @@ describe('OMP session status ownership', () => { harness.reload() await harness.callHook('agent_start', {}, separate) await settle() - expect(JSON.parse(harness.fetchMock.mock.calls[0][1].body).payload.session_id).toBe('separate') + expect(JSON.parse(harness.fetchMock.mock.lastCall?.[1].body).payload.session_id).toBe( + 'separate' + ) }) it('uses OMP parent metadata and nested task paths when the root already owns the pane', async () => { @@ -131,6 +136,8 @@ describe('OMP session status ownership', () => { } } await harness.callHook('session_start', {}, root) + await settle() + harness.fetchMock.mockClear() harness.reload() const child = { sessionManager: { diff --git a/src/main/pi/omp-status-lifecycle-source.ts b/src/main/pi/omp-status-lifecycle-source.ts new file mode 100644 index 000000000000..b4b2bed872b6 --- /dev/null +++ b/src/main/pi/omp-status-lifecycle-source.ts @@ -0,0 +1,113 @@ +// OMP activity snapshots and dialog calls feed the managed status transport. +export function getOmpStatusLifecycleSourceLines(): string[] { + return ` +let ompUiPromptDepth = 0 +let ompDialogTracking: { epoch: number; depth: number; context: unknown; post: typeof post; activity: typeof readOmpActivity; syncDepth: (depth: number) => void } | null = null +function readOmpActivity(ctx): Record { + const snapshot: Record = {} + try { + if (typeof ctx?.isIdle === 'function') snapshot.is_idle = ctx.isIdle() === true + if (typeof ctx?.hasPendingMessages === 'function') snapshot.has_pending_messages = ctx.hasPendingMessages() === true + if (typeof ctx?.getAsyncJobSnapshot === 'function') { + const jobs = ctx.getAsyncJobSnapshot() + if (Array.isArray(jobs?.running)) snapshot.has_active_jobs = jobs.running.length > 0 + } + } catch { + return { is_idle: false, has_pending_messages: true, has_active_jobs: true } + } + return snapshot +} + +function readOmpTurnOutcome(messages): string | undefined { + if (!Array.isArray(messages)) return undefined + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] + if (message?.role !== 'assistant') continue + if (message.stopReason === 'error') return 'failure' + if (message.stopReason === 'aborted') return 'cancellation' + if (message.stopReason === 'stop') return 'success' + return undefined + } + return undefined +} + +function invalidateOmpUiDialogs(): void { + ompUiPromptDepth = 0 + if (!ompDialogTracking) return + ompDialogTracking.epoch++ + ompDialogTracking.depth = 0 +} + +function installOmpUiTracking(ctx): void { + if (!isOmpRuntime() || ctx?.hasUI !== true || !ctx.ui) return + try { + const ui = ctx.ui + const key = Symbol.for('orca.omp.status-ui-dialogs') + let tracking = Reflect.get(ui, key) + if (!tracking) { + tracking = { epoch: 0, depth: 0, context: ctx, post, activity: readOmpActivity, syncDepth: (depth) => { ompUiPromptDepth = depth } } + if (!Reflect.defineProperty(ui, key, { value: tracking, configurable: true })) return + for (const name of ['select', 'confirm', 'input', 'askDialog', 'custom', 'editor']) { + // OMP handler proxies expose the shared UI's own descriptors, not their delegated methods. + const descriptor = Object.getOwnPropertyDescriptor(ui, name) + if (!descriptor || typeof descriptor.value !== 'function' || descriptor.writable === false) continue + const original = descriptor.value + const wrapped = async function (...args) { + const epoch = tracking.epoch + tracking.depth++ + tracking.syncDepth(tracking.depth) + if (tracking.depth === 1) tracking.post('ui_prompt_start') + try { + return await original.apply(this, args) + } finally { + // A dialog from a replaced session or run cannot change the current session's row. + if (epoch === tracking.epoch) { + tracking.depth-- + tracking.syncDepth(tracking.depth) + if (tracking.depth === 0) tracking.post('ui_prompt_end', tracking.activity(tracking.context)) + } + } + } + Reflect.defineProperty(ui, name, { ...descriptor, value: wrapped }) + } + } + tracking.context = ctx + tracking.post = post + tracking.activity = readOmpActivity + tracking.syncDepth = (depth) => { ompUiPromptDepth = depth } + ompDialogTracking = tracking + ompUiPromptDepth = tracking.depth + } catch { + // Status instrumentation must not prevent an agent UI call. + return + } +} +` + .trim() + .split('\n') +} + +export function getOmpStatusLifecycleHandlerSourceLines(): string[] { + return [ + " onStatus('session_switch', (_event, ctx) => {", + ' if (!isOmpRuntime()) return', + ' lifecycleState.active.clear()', + ' lifecycleState.exited?.clear()', + ' lifecycleState.waiting = false', + ' resetPostQueue()', + ' clearPendingAgentEndCheck()', + ' ompCompletionExtra = {}', + ' ompCompletionContext = null', + ' invalidateOmpUiDialogs()', + ' updateRuntimeOmpSessionMetadata(ctx)', + " post('session_switch', readOmpActivity(ctx))", + ' })', + " for (const name of ['auto_retry_start', 'auto_retry_end', 'auto_compaction_start', 'auto_compaction_end', 'retry_fallback_applied', 'retry_fallback_succeeded']) {", + ' onStatus(name, (event, ctx) => {', + ' if (!isOmpRuntime()) return', + ' updateRuntimeOmpSessionMetadata(ctx)', + ' post(name, { ...event, ...readOmpActivity(ctx) })', + ' })', + ' }' + ] +} diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 1c05c697fb10..00791e9cdd61 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -1,5 +1,6 @@ import { isQoderComposerReady } from './qoder-terminal-readiness' import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo' +import { getPiStateTitleStatus } from '../../shared/pi-state-title-marker' import { detectAgentStatusFromTitle, isOpenCodeNativeTitle, @@ -20,7 +21,6 @@ import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' const GEMINI_IDLE_PREFIX = '\u25c7' -const PI_IDLE_PREFIX = '\u03c0 - ' function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { const status = detectAgentStatusFromTitle(title) @@ -35,7 +35,8 @@ function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { title.startsWith(CLAUDE_IDLE_PREFIX) || title.startsWith('* ') || title.includes(GEMINI_IDLE_PREFIX) || - title.startsWith(PI_IDLE_PREFIX) + getPiStateTitleStatus(title) === 'idle' || + title.startsWith('\u03c0 - ') ) { return 'idle' } diff --git a/src/main/runtime/terminal-wait-name-only-idle.test.ts b/src/main/runtime/terminal-wait-name-only-idle.test.ts index cf4648bf3539..7c2a48256083 100644 --- a/src/main/runtime/terminal-wait-name-only-idle.test.ts +++ b/src/main/runtime/terminal-wait-name-only-idle.test.ts @@ -133,6 +133,17 @@ describe('tui-idle evidence ranking', () => { ).resolves.toMatchObject({ satisfied: true }) }) + it.each(['π > leviathan', 'Pi > workspace', 'OMP > workspace', 'zsh | OMP > workspace'])( + 'settles the native idle marker %s without waiting for output silence', + async (title) => { + const pty = makeTuiIdlePty({ lastAgentStatus: 'idle', lastOscTitle: title }) + const { wait } = createWait({ pty, agent: 'omp' }) + const settled = watch(wait.wait(HANDLE, { condition: 'tui-idle', timeoutMs: 1000 })) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toHaveBeenCalledWith({ ok: expect.objectContaining({ satisfied: true }) }) + } + ) + // Why this case exists: tier 1 used to read only the renderer-synced pane title, so a // daemon-hosted pane with no renderer dropped its explicit `Codex ready` to the // quiescence lane and waited the whole window for a result it already had. diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts index c87044191635..0c7e7fd3edd5 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -8,9 +8,7 @@ import { resolvePrompt, resolveToolState } from '../prompt-fields' import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' import { readString } from '../tool-input-preview' -/** Maps a Pi-family hook event (Pi, OMP, Prime) onto a pane status: lifecycle - * events become `working` / `done`, an ask tool becomes `blocked`, and OMP's - * `model` stamp rides along. Returns null for events that carry no status. */ +// Pi-family hooks update the canonical pane status without inferring process exit. export function normalizePiCompatibleEvent( state: HookListenerState, agentType: 'pi' | 'omp' | 'prime-agent', @@ -28,6 +26,27 @@ export function normalizePiCompatibleEvent( } } + const isOmp = agentType === 'omp' + const sessionBoundary = isOmp && (eventName === 'session_start' || eventName === 'session_switch') + if (sessionBoundary) { + clearPaneTurnCacheState(state, paneKey) + } + const previous = state.lastStatusByPaneKey.get(paneKey)?.payload + const active = hookPayload.has_pending_messages === true || hookPayload.has_active_jobs === true + const ready = + hookPayload.is_idle === true && + hookPayload.has_pending_messages === false && + hookPayload.has_active_jobs === false + const reliabilityStart = + isOmp && + (eventName === 'auto_retry_start' || + eventName === 'auto_compaction_start' || + eventName === 'retry_fallback_applied') + const reliabilityEnd = + isOmp && + (eventName === 'auto_retry_end' || + eventName === 'auto_compaction_end' || + eventName === 'retry_fallback_succeeded') // Why: the OMP extension stamps `provider/id` on every post; Pi posts carry none. const model = readString(hookPayload, 'model') const modelSwitchCommand = @@ -80,6 +99,32 @@ export function normalizePiCompatibleEvent( } else if (isPiUiPromptEnd) { stateName = hookPayload.is_idle === true ? 'done' : 'working' } + if (isOmp) { + if (sessionBoundary) { + stateName = ready ? 'done' : 'working' + } + if (reliabilityStart) { + stateName = 'working' + } + if (reliabilityEnd) { + stateName = + ready && hookPayload.willRetry !== true && hookPayload.willContinue !== true + ? 'done' + : 'working' + } + if (isOmpApprovalResolution || eventName === 'ui_prompt_end') { + stateName = ready ? 'done' : 'working' + } + if (eventName === 'agent_end') { + stateName = active ? 'working' : 'done' + } + if ( + !isOmpApprovalRequest && + (eventName === 'ui_prompt_start' || hookPayload.ui_prompt_active === true) + ) { + stateName = 'waiting' + } + } if (!stateName) { return null @@ -92,12 +137,35 @@ export function normalizePiCompatibleEvent( { resetOnNewTurn: isNewTurnEvent(agentType, eventName) } ) + const previousMain = previous?.agentType === agentType ? previous.mainAgent : undefined + const outcome = + hookPayload.turn_outcome === 'success' || + hookPayload.turn_outcome === 'failure' || + hookPayload.turn_outcome === 'cancellation' + ? hookPayload.turn_outcome + : undefined + const mainAgent = + isOmp && !sessionBoundary && !isNewTurnEvent(agentType, eventName) + ? eventName === 'agent_end' + ? { + state: 'done', + outcome, + stateStartedAt: + previousMain?.state === 'done' ? previousMain.stateStartedAt : Date.now() + } + : previousMain + : undefined + const idleBoundary = + isOmp && stateName === 'done' && eventName !== 'agent_end' && mainAgent?.state !== 'done' return normalizeAgentStatusPayload({ state: stateName, prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn: isNewTurnEvent(agentType, eventName) }), agentType, + sessionBoundary: sessionBoundary || idleBoundary || undefined, + mainAgent, + interrupted: mainAgent?.outcome === 'cancellation' ? true : undefined, model, modelSwitchCommand, toolName: snapshot.toolName, From 426cea00f51933bcbb2ea74fe6fd4af3edeed817 Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Wed, 30 Sep 2026 23:41:59 +0530 Subject: [PATCH 02/10] fix: preserve genuine OMP readiness title evidence --- docs/reference/agent-status-store.md | 5 +++ .../orca-runtime-apply-tracked-pty-title.ts | 4 ++ ...me-maybe-hydrate-headless-from-renderer.ts | 4 ++ .../orca-runtime-resolve-exit-waiters.ts | 4 ++ .../runtime/orca-runtime-sync-window-graph.ts | 1 + ...terminal-side-effect-facts-part-03.spec.ts | 37 +++++++++++++++ .../runtime/runtime-terminal-state-records.ts | 2 + src/main/runtime/tui-idle-agent-fixture.mjs | 11 ++++- src/main/runtime/tui-idle-evidence.test.ts | 41 +++++++++++++++++ src/main/runtime/tui-idle-evidence.ts | 45 +++++-------------- ...dle-name-only-real-pty.integration.test.ts | 13 +++++- .../src/lib/agent-send-title-status.test.ts | 21 +++++++++ .../src/lib/agent-send-title-status.ts | 4 +- 13 files changed, 156 insertions(+), 36 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index c81b4472e1e5..1da669f9c802 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -84,6 +84,11 @@ No second reporter or reader-side rule is required. - Shutdown clears local timers and dialog tracking without a completion event. Missing hooks or transport loss never prove process exit. Process verdicts remain `live`, `unverifiable`, and `exited`. +- Timer-cleared working titles do not prove readiness. Their origin survives + graph rebuilds and same-title hydration. Genuine idle titles remain valid evidence. +- A fresh working hook prevents a retained prompt from proving readiness. +- Selected-target sends recognize current Pi and OMP idle state markers, + including wrapped titles, and keep support for the legacy idle title. Activity and outcome fields are optional payload fields. Existing remote clients do not need a new wire opcode. A source change does not update an installed app diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index f4f8826e6e78..12cd5ba40824 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -36,6 +36,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.lastOscTitle = recordedTitle pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt pty.lastOscTitleEpochMs = observedAtEpochMs + pty.lastOscTitleStaleWorkingClear = meta?.staleWorkingTitleClear === true pty.lastAgentStatus = agentStatus pty.lastAgentStatusObservedLive = true if (prevStatus === 'working' && agentStatus === null) { @@ -105,6 +106,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper const prevLeafTitle = leaf.lastOscTitle leaf.lastOscTitle = recordedTitle leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence() + leaf.lastOscTitleStaleWorkingClear = meta?.staleWorkingTitleClear === true // Why: when a new OSC title doesn't classify as an agent state (e.g. // bare shell title after the agent exits), clear lastAgentStatus so // it is no longer sticky. Tui-idle waiters that needed the previous @@ -178,6 +180,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.lastOscTitle = null pty.lastOscTitleAt = null pty.lastOscTitleEpochMs = null + pty.lastOscTitleStaleWorkingClear = false pty.lastAgentStatus = null // Why: the prior process's first-party status would otherwise veto idle for its // replacement — a stale `working` keeps tui-idle unresolved on the new generation. @@ -196,6 +199,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper for (const leaf of this.getLeavesForPty(ptyId)) { leaf.lastOscTitle = null leaf.lastOscTitleAt = null + leaf.lastOscTitleStaleWorkingClear = false leaf.lastAgentStatus = null leaf.lastAgentStatusObservedLive = false leaf.waitBlockedAt = null diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 665a5ef22535..1a766cbc5bb5 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -134,6 +134,8 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime const pty = this.ptysById.get(ptyId) if (pty) { const observedAt = this.nextTitleObservationSequence() + pty.lastOscTitleStaleWorkingClear = + pty.lastOscTitle === seededTitle && pty.lastOscTitleStaleWorkingClear === true pty.lastOscTitle = seededTitle pty.lastOscTitleAt = observedAt this.setPtyManagementTitleFromObservedTitle(pty, seededTitle, observedAt) @@ -142,6 +144,8 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime // Why: seed lastOscTitle even when the seeded title doesn't classify // as an agent state, so worktree.ps recomputes status from the live // title rather than treating the leaf as agentless. + leaf.lastOscTitleStaleWorkingClear = + leaf.lastOscTitle === seededTitle && leaf.lastOscTitleStaleWorkingClear === true leaf.lastOscTitle = seededTitle leaf.lastOscTitleAt = this.nextTitleObservationSequence() if (status !== null) { diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index f7ead603e022..f2732b500254 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -9,6 +9,7 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import type { AgentStatus } from '../../shared/agent-detection' +import { getPiStateTitleStatus } from '../../shared/pi-state-title-marker' import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' import { @@ -219,6 +220,9 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc protected getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { const title = this.getAdoptedPtyTitle(pty) + if (title && pty.lastOscTitleStaleWorkingClear && getPiStateTitleStatus(title) === 'idle') { + return null + } return title ? detectExplicitIdleStatusFromTitle(title) : null } diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 893c51c68cce..3fbc721ca336 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -144,6 +144,7 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow lastAgentStatusObservedLive: tailSource?.lastAgentStatusObservedLive ?? false, lastOscTitle: tailSource?.lastOscTitle ?? null, lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, + lastOscTitleStaleWorkingClear: tailSource?.lastOscTitleStaleWorkingClear ?? false, paneTitleUpdatedAt: existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle ? existing.paneTitleUpdatedAt diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts index cc45d9900c82..7a1baad09969 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts @@ -46,6 +46,43 @@ describe('terminal side-effect fact channel', () => { } }) + it('waits for a genuine OMP idle title after a timer clear and graph rebuild', async () => { + vi.useFakeTimers() + try { + const { runtime } = createSideEffectRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1', { tabTitle: 'OMP' }) + runtime.onPtyData('pty-1', '\x1b]0;π : project\x07', Date.now()) + runtime.onPtyData('pty-1', 'still working\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + runtime.onPtyData('pty-1', 'work continues\r\n', Date.now()) + syncSinglePty(runtime, 'pty-1', { tabTitle: 'OMP', paneTitle: 'π > project' }) + const [terminal] = (await runtime.listTerminals()).terminals + const staleWait = runtime.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 200 + }) + const timeoutAssertion = expect(staleWait).rejects.toThrow('timeout') + await vi.advanceTimersByTimeAsync(200) + await timeoutAssertion + + const genuineWait = runtime.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 5_000 + }) + runtime.onPtyData('pty-1', '\x1b]0;π > project\x07', Date.now()) + const readyAssertion = expect(genuineWait).resolves.toMatchObject({ satisfied: true }) + await vi.advanceTimersByTimeAsync(2_000) + await readyAssertion + } finally { + vi.useRealTimers() + } + }) + it('emits command-code-working facts only after the banner arms the scrape', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 32ebf1a423b7..7fd2337e7b26 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -42,6 +42,7 @@ export type RuntimeLeafRecord = RuntimeSyncedLeaf & lastAgentStatusObservedLive: boolean lastOscTitle: string | null lastOscTitleAt: number | null + lastOscTitleStaleWorkingClear?: boolean paneTitleUpdatedAt: number | null } @@ -87,6 +88,7 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { lastOscTitle: string | null lastOscTitleAt: number | null lastOscTitleEpochMs: number | null + lastOscTitleStaleWorkingClear?: boolean managementTitle: string | null managementTitleAt: number | null controllerTitle: string | null diff --git a/src/main/runtime/tui-idle-agent-fixture.mjs b/src/main/runtime/tui-idle-agent-fixture.mjs index cc12924f93cd..2cecd0eb7cb9 100644 --- a/src/main/runtime/tui-idle-agent-fixture.mjs +++ b/src/main/runtime/tui-idle-agent-fixture.mjs @@ -4,7 +4,10 @@ const mode = process.argv[2] const workMs = Number(process.argv[3] ?? 6000) const osc = (title) => `]0;${title}` -process.stdout.write(osc('Codex')) +process.stdout.write(osc(mode === 'omp-stale' ? 'π : project' : 'Codex')) +if (mode === 'omp-stale') { + setTimeout(() => process.stdout.write('working before pause\n'), 100) +} const end = Date.now() + workMs const streaming = setInterval(() => { if (Date.now() >= end) { @@ -12,6 +15,12 @@ const streaming = setInterval(() => { if (mode === 'explicit-idle') { process.stdout.write(osc('Codex ready')) } + if (mode === 'omp-stale') { + process.stdout.write(osc('π > project')) + } + return + } + if (mode === 'omp-stale' && Date.now() < end - workMs + 3_500) { return } process.stdout.write(`analysing chunk ${Date.now()}\n`) diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 818d96545106..65bd44e3f164 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -111,6 +111,47 @@ describe('evaluateTuiIdle ranking', () => { expect(verdict).toEqual({ kind: 'ready-strong' }) }) + it('rejects a timer-cleared idle title while the OMP hook still reports working', () => { + const verdict = evaluateTuiIdle( + input({ + ...noMuse, + agent: 'omp', + record: { + ...record({ lastAgentStatus: 'idle', lastOscTitle: 'π > project' }), + lastOscTitleStaleWorkingClear: true + }, + firstPartyStatus: { state: 'working', updatedAt: Date.now() } + }) + ) + expect(verdict).toEqual({ kind: 'working' }) + }) + + it('does not let an old prompt bypass a fresh working hook', () => { + expect( + evaluateTuiIdle( + input({ + ...noMuse, + agent: 'claude', + readPositiveBodyEvidence: () => true, + firstPartyStatus: { state: 'working', updatedAt: Date.now() } + }) + ) + ).toEqual({ kind: 'working' }) + }) + + it('accepts a genuine OMP idle title despite a retained working hook', () => { + expect( + evaluateTuiIdle( + input({ + ...noMuse, + agent: 'omp', + record: record({ lastAgentStatus: 'idle', lastOscTitle: 'π > project' }), + firstPartyStatus: { state: 'working', updatedAt: Date.now() } + }) + ) + ).toEqual({ kind: 'ready-strong' }) + }) + it('calls a name-only title weak, even for an agent it is the only rest signal of', () => { const verdict = evaluateTuiIdle( input({ ...noMuse, agent: 'grok', record: record({ lastAgentStatus: 'idle' }) }) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 3df42c82166c..d3ce15d94b9e 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -17,40 +17,13 @@ import { isQuietReadyScreenBody } from './terminal-wait-detection' -/** - * Ranking the evidence that a `tui-idle` wait may settle on. - * - * Why a ranking: a thinking TUI and a finished TUI are both silent, so the absence - * of a working marker can never prove completion. `detectAgentStatusFromTitle` - * DEFAULTS a name-only agent title to `idle` — the sidebar needs that to clear a - * stale spinner (#1437) — so a busy Codex/Devin pane is routinely titled idle, and - * accepting it satisfied a wait in ~0s mid-turn (#6011). - * - * 0. BLOCKED — the tail shows a prompt waiting on the user. - * 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own - * title, or a known ready-prompt body. - * 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, so their - * ready-screen body stands in for the strong evidence, believed only once quiet. - * 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/ - * waiting, or a working title. The agent's own account of itself outranks anything - * inferred. - * 3. WEAK READY — a name-only title, or a quiet non-shell foreground process. A last - * resort, and only once sustained. - * - * Why weak ready is a verdict class rather than a per-evidence flag: none of it can see a - * start-up dialog the line tail lost (Claude's workspace trust), so ONLY the poll may settle - * on it, after the rendered screen shows no blocker. Synchronous sites settle on tiers 0-1b. - * - * Why derived here rather than stamped onto the record at write time: `syncWindowGraph` - * rebuilds every leaf from an explicit field list, so a bespoke provenance field is - * silently dropped on any renderer publish and the verdict silently flips. `lastOscTitle` - * is copied, so reading the rank back off it cannot decay. - */ +// Genuine idle titles outrank hooks; inferred screen readiness cannot override a fresh working hook. export type TuiIdleEvidenceRecord = { lastAgentStatus: AgentStatus | null lastOutputAt: number | null lastOscTitle?: string | null + lastOscTitleStaleWorkingClear?: boolean } export type FirstPartyAgentStatus = { @@ -69,6 +42,9 @@ export function hasExplicitIdleTitle( // one dropped an explicit `Codex ready` to the tier-3 lane and delayed it by the // whole quiescence window. for (const title of [rendererTitle, record.lastOscTitle]) { + if (record.lastOscTitleStaleWorkingClear && title === record.lastOscTitle) { + continue + } if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') { return true } @@ -264,9 +240,7 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { ? READY_STRONG : { kind: 'pending', quietForeground: 'closed' } } - // Why the title before the body: both are tier 1, so either settles, but the title is a - // memoized lookup and the body is a fresh multi-KB scan. Same verdict, cheaper order. - if (hasExplicitIdleTitle(input.record, input.rendererTitle) || input.readPositiveBodyEvidence()) { + if (hasExplicitIdleTitle(input.record, input.rendererTitle)) { return READY_STRONG } // Why beside the title lane, not after the veto: both are tier 1, and a first-party `done` @@ -281,6 +255,9 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { ? WORKING : { kind: 'pending', quietForeground: 'closed' } } + if (input.readPositiveBodyEvidence()) { + return READY_STRONG + } // Why after the veto: a first-party working account outranks inferred body evidence. // Why before the working title: Codex can leave a stale spinner title after a turn, and a // live spinner emits output every ~100 ms, so a spinning pane is never quiet here. @@ -363,7 +340,9 @@ export function ptyTuiIdleEvidence( record: pty, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), readPositiveBodyEvidence: () => - (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || + (!pty.lastOscTitleStaleWorkingClear && + agent !== 'qoder' && + source.getAdoptedPtyIdleStatus(pty) === 'idle') || isKnownReadyPromptBody( waitText(), agent, diff --git a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts index 562ab23ee3ec..1f28ee8d9f21 100644 --- a/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts +++ b/src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts @@ -32,7 +32,7 @@ afterEach(() => { } }) -async function startRealAgentPane(mode: 'explicit-idle' | 'quiet', workMs: number) { +async function startRealAgentPane(mode: 'explicit-idle' | 'quiet' | 'omp-stale', workMs: number) { const child = pty.spawn(process.execPath, [FIXTURE, mode, String(workMs)], { name: 'xterm-256color', cols: 120, @@ -126,6 +126,17 @@ describe.skipIf(process.platform === 'win32')('tui-idle against a real agent pty expect(outcome.elapsedMs).toBeGreaterThanOrEqual(1_500) }, 28_000) + it('rejects a timer-cleared OMP title until the real process reports idle', async () => { + const { runtime, transcript, handle } = await startRealAgentPane('omp-stale', 6_000) + await expect.poll(() => transcript.join(''), { timeout: 5_000 }).toContain('analysing chunk') + + const staleOutcome = await terminalWait(runtime, handle, 500) + expect(staleOutcome.satisfied).toBe(false) + const genuineOutcome = await terminalWait(runtime, handle, 10_000) + expect(genuineOutcome.satisfied).toBe(true) + expect(transcript.join('')).toContain('\x1b]0;π > project\x07') + }, 20_000) + it('satisfies once the real process goes quiet with the agent still in foreground', async () => { const { runtime, handle } = await startRealAgentPane('quiet', 3_000) await new Promise((resolve) => setTimeout(resolve, 500)) diff --git a/src/renderer/src/lib/agent-send-title-status.test.ts b/src/renderer/src/lib/agent-send-title-status.test.ts index f4e2247dadcc..e37b088f62eb 100644 --- a/src/renderer/src/lib/agent-send-title-status.test.ts +++ b/src/renderer/src/lib/agent-send-title-status.test.ts @@ -24,4 +24,25 @@ describe('detectAgentSendTitleStatus', () => { expect(detectAgentSendTitleStatus('Codex ready')).toBe('idle') expect(detectAgentSendTitleStatus('zsh')).toBeNull() }) + + it.each(['π > project', 'OMP > project', 'Pi > project', 'zsh | π > project', 'π - project'])( + 'accepts Pi/OMP idle title %j for send', + (title) => { + expect(detectAgentSendTitleStatus(title)).toBe('idle') + } + ) + + it.each(['π : project', 'OMP : project', 'Pi : project'])( + 'preserves Pi/OMP working title %j', + (title) => { + expect(detectAgentSendTitleStatus(title)).toBe('working') + } + ) + + it.each(['π ! project', 'OMP ! project', 'Pi ! project'])( + 'preserves Pi/OMP attention title %j', + (title) => { + expect(detectAgentSendTitleStatus(title)).toBe('permission') + } + ) }) diff --git a/src/renderer/src/lib/agent-send-title-status.ts b/src/renderer/src/lib/agent-send-title-status.ts index 4e03d70b174d..2d8c0db4fffc 100644 --- a/src/renderer/src/lib/agent-send-title-status.ts +++ b/src/renderer/src/lib/agent-send-title-status.ts @@ -1,5 +1,6 @@ import type { AgentStatus } from './agent-status' import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' +import { getPiStateTitleStatus } from '../../../shared/pi-state-title-marker' import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' const EXPLICIT_IDLE_SEND_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i @@ -31,6 +32,7 @@ function isExplicitIdleSendTitle(title: string): boolean { title.startsWith(CLAUDE_IDLE_PREFIX) || title.startsWith('* ') || title.includes(GEMINI_IDLE_PREFIX) || - title.startsWith(PI_IDLE_PREFIX) + title.startsWith(PI_IDLE_PREFIX) || + getPiStateTitleStatus(title) === 'idle' ) } From 39e2d31437b2241f56a243aedbca72eddeb3dd55 Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Thu, 1 Oct 2026 00:10:27 +0530 Subject: [PATCH 03/10] fix: keep OMP async completion checks after agent settled --- docs/reference/agent-status-store.md | 2 + src/main/pi/agent-status-handler-source.ts | 2 +- ...agent-status-omp-state-transitions.test.ts | 57 ++++++++++++------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 1da669f9c802..e7fe58b32be4 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -78,6 +78,8 @@ No second reporter or reader-side rule is required. - `agent_end` with `willContinue: true` does not complete a turn. Owned jobs, children, and pending messages keep the combined row `working` after the main agent completes. The final all-clear keeps the main-agent outcome and its clock. +- OMP keeps checking owned jobs and pending messages after `agent_settled` until + they clear. Pi still uses `agent_settled` instead of its idle fallback check. - The last assistant stop reason maps `stop` to `success`, `error` to `failure`, and `aborted` to `cancellation`. Other reasons have no outcome. Normal provider completion does not prove that the user's task succeeded. diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 7ecac802c556..32e159d46dcd 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -266,7 +266,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' function checkPendingAgentEnd(): void {', ' pendingAgentEndCheck = null', ' const ctx = pendingAgentEndContext', - ' if (!ctx || agentSettledSupported || completionPostedGeneration === endedRunGeneration) {', + ' if (!ctx || (agentSettledSupported && !isOmpRuntime()) || completionPostedGeneration === endedRunGeneration) {', ' pendingAgentEndContext = null', ' return', ' }', diff --git a/src/main/pi/agent-status-omp-state-transitions.test.ts b/src/main/pi/agent-status-omp-state-transitions.test.ts index dc6bea79c118..1a14b55bd439 100644 --- a/src/main/pi/agent-status-omp-state-transitions.test.ts +++ b/src/main/pi/agent-status-omp-state-transitions.test.ts @@ -89,7 +89,7 @@ describe('managed OMP status transitions', () => { } ) - it('keeps combined work active until pending messages clear', async () => { + it.each([false, true])('keeps pending messages active with settled event %s', async (settled) => { const { harness, rows } = createStatusScenario() let pending = true const context = { ...idle, hasPendingMessages: () => pending } @@ -102,6 +102,11 @@ describe('managed OMP status transitions', () => { await vi.waitFor(() => expect(rows.at(-1)).toMatchObject({ state: 'working', mainAgent: { outcome: 'success' } }) ) + if (settled) { + await harness.callHook('agent_settled', {}, context) + } + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rows.at(-1)?.state).toBe('working') pending = false await vi.waitFor(() => expect(rows.at(-1)).toMatchObject({ state: 'done', mainAgent: { outcome: 'success' } }) @@ -145,27 +150,35 @@ describe('managed OMP status transitions', () => { } ) - it('keeps combined work active while jobs run and preserves the main-agent outcome clock', async () => { - const { harness, rows } = createStatusScenario() - let running = true - const context = { ...idle, getAsyncJobSnapshot: () => ({ running: running ? [{}] : [] }) } - await harness.callHook('agent_start', {}, context) - await harness.callHook( - 'agent_end', - { messages: [{ role: 'assistant', stopReason: 'error' }] }, - context - ) - await vi.waitFor(() => - expect(rows.at(-1)).toMatchObject({ - state: 'working', - mainAgent: { state: 'done', outcome: 'failure' } - }) - ) - const clock = rows.at(-1)?.mainAgent?.stateStartedAt - running = false - await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('done')) - expect(rows.at(-1)?.mainAgent).toMatchObject({ outcome: 'failure', stateStartedAt: clock }) - }) + it.each([false, true])( + 'preserves the job outcome clock with settled event %s', + async (settled) => { + const { harness, rows } = createStatusScenario() + let running = true + const context = { ...idle, getAsyncJobSnapshot: () => ({ running: running ? [{}] : [] }) } + await harness.callHook('agent_start', {}, context) + await harness.callHook( + 'agent_end', + { messages: [{ role: 'assistant', stopReason: 'error' }] }, + context + ) + await vi.waitFor(() => + expect(rows.at(-1)).toMatchObject({ + state: 'working', + mainAgent: { state: 'done', outcome: 'failure' } + }) + ) + const clock = rows.at(-1)?.mainAgent?.stateStartedAt + if (settled) { + await harness.callHook('agent_settled', {}, context) + } + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rows.at(-1)?.state).toBe('working') + running = false + await vi.waitFor(() => expect(rows.at(-1)?.state).toBe('done')) + expect(rows.at(-1)?.mainAgent).toMatchObject({ outcome: 'failure', stateStartedAt: clock }) + } + ) it('restores readiness after nested dialogs and ignores a late close from an old session', async () => { const { harness, rows } = createStatusScenario() From 38c5d65660967ccdd3b25cbabae5b24c49ccbdec Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Thu, 1 Oct 2026 00:24:22 +0530 Subject: [PATCH 04/10] fix: reject stale-clear renderer titles as idle evidence --- docs/reference/agent-status-store.md | 4 +++- src/main/runtime/tui-idle-evidence.test.ts | 23 +++++++++++++--------- src/main/runtime/tui-idle-evidence.ts | 11 ++++------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index e7fe58b32be4..6461cefdd29e 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -87,7 +87,9 @@ No second reporter or reader-side rule is required. Missing hooks or transport loss never prove process exit. Process verdicts remain `live`, `unverifiable`, and `exited`. - Timer-cleared working titles do not prove readiness. Their origin survives - graph rebuilds and same-title hydration. Genuine idle titles remain valid evidence. + graph rebuilds and same-title hydration. While the stale-clear flag is set, + neither the OSC title nor the renderer title proves idle, even after rebranding. + A genuine OSC title clears the flag and restores idle-title evidence. - A fresh working hook prevents a retained prompt from proving readiness. - Selected-target sends recognize current Pi and OMP idle state markers, including wrapped titles, and keep support for the legacy idle title. diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 65bd44e3f164..e33f81204415 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -111,20 +111,25 @@ describe('evaluateTuiIdle ranking', () => { expect(verdict).toEqual({ kind: 'ready-strong' }) }) - it('rejects a timer-cleared idle title while the OMP hook still reports working', () => { - const verdict = evaluateTuiIdle( - input({ + it.each([undefined, 'π > project', 'OMP > project'])( + 'rejects timer-cleared title evidence with renderer title %s until genuine idle arrives', + (rendererTitle) => { + const evidence = input({ ...noMuse, agent: 'omp', - record: { - ...record({ lastAgentStatus: 'idle', lastOscTitle: 'π > project' }), + record: record({ + lastAgentStatus: 'idle', + lastOscTitle: 'π > project', lastOscTitleStaleWorkingClear: true - }, + }), + rendererTitle, firstPartyStatus: { state: 'working', updatedAt: Date.now() } }) - ) - expect(verdict).toEqual({ kind: 'working' }) - }) + expect(evaluateTuiIdle(evidence)).toEqual({ kind: 'working' }) + evidence.record.lastOscTitleStaleWorkingClear = false + expect(evaluateTuiIdle(evidence)).toEqual({ kind: 'ready-strong' }) + } + ) it('does not let an old prompt bypass a fresh working hook', () => { expect( diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index d3ce15d94b9e..85c598918b3a 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -37,14 +37,11 @@ export function hasExplicitIdleTitle( record: TuiIdleEvidenceRecord, rendererTitle?: string | null ): boolean { - // Why lastOscTitle too, not just the renderer's pane title: a daemon-hosted or - // background pane has no renderer publishing a title, so reading only the synced - // one dropped an explicit `Codex ready` to the tier-3 lane and delayed it by the - // whole quiescence window. + if (record.lastOscTitleStaleWorkingClear) { + return false + } + // Headless panes retain idle title evidence without a renderer title. for (const title of [rendererTitle, record.lastOscTitle]) { - if (record.lastOscTitleStaleWorkingClear && title === record.lastOscTitle) { - continue - } if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') { return true } From bb71fd25f725ebdfe46bb7b7213f2837386f8ac8 Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Thu, 1 Oct 2026 01:34:00 +0530 Subject: [PATCH 05/10] fix: preserve OMP readiness boundaries after completed turns --- docs/reference/agent-status-store.md | 2 + .../agent-hook-listener-pi-compatible.test.ts | 47 +++++++++++++++++++ .../providers/pi-family-events.ts | 3 +- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 6461cefdd29e..1ba79613df3d 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -71,6 +71,8 @@ No second reporter or reader-side rule is required. - Startup and session switches send current activity. Readiness requires idle, no pending messages, and no active jobs. Missing activity does not prove readiness. - Idle readiness uses `done` with `sessionBoundary: true`. It is not turn completion. + This also applies after a completed turn: retry, compaction, dialog close, and + approval resolution keep the prior main-agent outcome without another completion. - Retry and compaction start as `working`. Their end can restore readiness only when current activity is clear and no continuation flag is true. - Real UI dialogs use `waiting`; tool approvals use `blocked`. Closing a dialog diff --git a/src/shared/agent-hook-listener-pi-compatible.test.ts b/src/shared/agent-hook-listener-pi-compatible.test.ts index d1103bafda4d..86f7d1f46fbf 100644 --- a/src/shared/agent-hook-listener-pi-compatible.test.ts +++ b/src/shared/agent-hook-listener-pi-compatible.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createHookListenerState, + seedLegacyAgentStatusForTests, type HookListenerState } from './agent-hook-listener/listener-state' import { normalizeHookPayload } from './agent-hook-listener' @@ -384,6 +385,52 @@ describe('shared agent-hook-listener', () => { expect(resolved?.payload.toolInput).toBeUndefined() }) + it.each([ + ['auto_retry_start', 'auto_retry_end', 'working'], + ['auto_compaction_start', 'auto_compaction_end', 'working'], + ['retry_fallback_applied', 'retry_fallback_succeeded', 'working'], + ['ui_prompt_start', 'ui_prompt_end', 'waiting'], + ['tool_approval_requested', 'tool_approval_resolved', 'blocked'] + ])( + 'marks %s readiness after a completed turn without another completion', + (start, end, active) => { + const emit = (eventName: string, fields: Record = {}) => { + const event = normalizeHookPayload( + state, + 'omp', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: eventName, + is_idle: true, + has_pending_messages: false, + has_active_jobs: false, + ...fields + } + }, + 'production' + ) + if (!event) { + throw new Error(`Missing OMP status for ${eventName}`) + } + seedLegacyAgentStatusForTests(state, event) + return event.payload + } + emit('agent_start') + const completed = emit('agent_end', { turn_outcome: 'success' }) + expect(completed).toMatchObject({ state: 'done', mainAgent: { outcome: 'success' } }) + expect(completed.sessionBoundary).toBeUndefined() + expect(emit(start).state).toBe(active) + const restored = emit(end) + expect(restored).toMatchObject({ state: 'done', sessionBoundary: true }) + expect(restored.mainAgent).toEqual(completed.mainAgent) + emit('agent_start') + const nextCompletion = emit('agent_end', { turn_outcome: 'failure' }) + expect(nextCompletion).toMatchObject({ state: 'done', mainAgent: { outcome: 'failure' } }) + expect(nextCompletion.sessionBoundary).toBeUndefined() + } + ) + it.each(['tool_approval_requested', 'tool_approval_resolved'])( 'ignores %s from Pi-compatible agents that do not emit it', (hookEventName) => { diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts index 0c7e7fd3edd5..6c8e89dd912c 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -155,8 +155,7 @@ export function normalizePiCompatibleEvent( } : previousMain : undefined - const idleBoundary = - isOmp && stateName === 'done' && eventName !== 'agent_end' && mainAgent?.state !== 'done' + const idleBoundary = isOmp && stateName === 'done' && eventName !== 'agent_end' return normalizeAgentStatusPayload({ state: stateName, prompt: resolvePrompt(state, paneKey, promptText, { From 370eb494cd612eea96d697aad357ab7d511db80d Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Thu, 1 Oct 2026 14:12:25 +0530 Subject: [PATCH 06/10] fix: require shell evidence before retiring agent launch authority --- docs/reference/agent-status-store.md | 7 + .../omp-command-finished-status.test.ts | 265 ++++++++++++++++++ ...pencode-finished-session-authority.test.ts | 76 +---- ...-runtime-emit-daemon-pty-transient-fact.ts | 2 +- ...me-get-orchestration-dispatch-authority.ts | 38 +++ ...e-get-unpersisted-tracked-title-for-pty.ts | 2 +- 6 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 src/main/runtime/omp-command-finished-status.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index d30afb1f757f..2906f221ce23 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -88,6 +88,13 @@ No second reporter or reader-side rule is required. - Shutdown clears local timers and dialog tracking without a completion event. Missing hooks or transport loss never prove process exit. Process verdicts remain `live`, `unverifiable`, and `exited`. +- OMP redraws can emit `OSC 133;D` while its process still runs. Neither parsed + bytes nor daemon command-finished facts retire launch authority on their own. + Retirement requires a shell confirmed by the current execution host. Unknown, + failed, and non-shell foreground reads keep the row and admit later hooks. + Pending reads must still match the controller, PTY incarnation, lifecycle, + launch identity, and restored inventory receipt. Actual PTY exit keeps its + existing retirement rules. - Timer-cleared working titles do not prove readiness. Their origin survives graph rebuilds and same-title hydration. While the stale-clear flag is set, neither the OSC title nor the renderer title proves idle, even after rebranding. diff --git a/src/main/runtime/omp-command-finished-status.test.ts b/src/main/runtime/omp-command-finished-status.test.ts new file mode 100644 index 000000000000..b3ca55abbb15 --- /dev/null +++ b/src/main/runtime/omp-command-finished-status.test.ts @@ -0,0 +1,265 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { AgentHookServer } from '../agent-hooks/server' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +const PTY_ID = 'pty-omp-command-finished' +const INCARNATION = 'omp-incarnation-1' +const WORKTREE_ID = 'path:/tmp/omp-status-lifetime' +const TAB_ID = '9a80b65d-34bf-47f7-b507-20dcae366a01' +const LEAF_ID = '9a80b65d-34bf-47f7-b507-20dcae366a02' +const HANDLE = 'term_omp-status-lifetime' +const LAUNCH_TOKEN = 'omp-status-lifetime-token' +const CAPTURED_OMP_COMMAND_FINISHED = '\x1b]133;B\x07\x1b]133;C\x07\x1b]133;D;0\x07' + +class StatusRuntime extends OrcaRuntimeService { + bindPane(incarnationId = INCARNATION): void { + this.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId, + agentLaunchAuthority: { launchToken: LAUNCH_TOKEN, launchAgent: 'omp' } + }) + this.adoptControllerTerminalHandle(PTY_ID, HANDLE, incarnationId) + } + + async restoreInventory(handle: string): Promise { + await this.refreshPtyWorktreeRecordsWithControllerInventory([], WORKTREE_ID) + expect(this.restoredOrchestrationAuthorityByPtyId.get(PTY_ID)?.terminalHandle).toBe(handle) + } + + retireLaunch(): void { + this.retirePtyAgentLaunchAuthority(PTY_ID) + } + + replaceIncarnation(): void { + this.bindPane('omp-incarnation-2') + } +} + +const servers: AgentHookServer[] = [] +const directories: string[] = [] + +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +async function settle(): Promise { + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) +} + +async function createPane(getForegroundProcess: () => Promise) { + const directory = mkdtempSync(join(tmpdir(), 'orca-omp-lifetime-')) + directories.push(directory) + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production', userDataPath: directory }) + const runtime = new StatusRuntime( + { + ...makeStore(), + getGitHubCache: () => ({ pr: {}, issue: {} }), + getSettings: () => ({ + ...makeStore().getSettings(), + refreshLocalBaseRefOnWorktreeCreate: false + }), + getWorkspaceSession: () => ({ + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: TAB_ID, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TAB_ID, + ptyId: PTY_ID, + worktreeId: WORKTREE_ID, + title: 'OMP', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + }, + terminalPtyIncarnationsByPaneKey: { [`${TAB_ID}:${LEAF_ID}`]: INCARNATION } + }) + }, + undefined, + { + retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + server.attestCompatibilityAuthority(candidate) + } + ) + const handle = HANDLE + const controller = { + spawn: async () => ({ id: PTY_ID, incarnationId: INCARNATION }), + write: () => true, + kill: () => true, + getForegroundProcess, + listProcesses: async () => [ + { + id: PTY_ID, + terminalHandle: handle, + incarnationId: INCARNATION, + worktreeId: WORKTREE_ID, + title: 'omp', + cwd: '/tmp/omp-status-lifetime' + } + ] + } + runtime.setPtyController(controller) + runtime.bindPane() + const paneKey = `${TAB_ID}:${LEAF_ID}` + const hookEnv = server.buildPtyEnv() + const post = async (payload: Record): Promise => { + const response = await fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/omp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': hookEnv.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey, + tabId: paneKey.split(':')[0], + worktreeId: WORKTREE_ID, + launchToken: LAUNCH_TOKEN, + env: 'production', + payload + }) + }) + expect(response.status).toBe(204) + } + const state = () => server.getStatusSnapshot().find((row) => row.paneKey === paneKey)?.state + await post({ hook_event_name: 'before_agent_start' }) + expect(state()).toBe('working') + return { runtime, server, controller, paneKey, handle, post, state } +} + +for (const path of ['bytes', 'daemon'] as const) { + const commandFinished = (runtime: OrcaRuntimeService): void => { + if (path === 'bytes') { + runtime.onPtyData(PTY_ID, CAPTURED_OMP_COMMAND_FINISHED, Date.now()) + } else { + runtime.emitDaemonPtyTransientFact(PTY_ID, { kind: 'command-finished', exitCode: 0 }) + } + } + + describe(`OMP command-finished lifetime (${path})`, () => { + it.each([ + ['live OMP', async () => 'omp'], + ['unknown foreground', async () => null], + ['another foreground command', async () => 'sleep'], + [ + 'unavailable execution host', + async () => { + throw new Error('transport unavailable') + } + ] + ])('preserves completion with %s and admits the next turn', async (_label, foreground) => { + const pane = await createPane(foreground) + await pane.post({ hook_event_name: 'agent_end' }) + expect(pane.state()).toBe('done') + commandFinished(pane.runtime) + await settle() + expect(pane.state()).toBe('done') + await pane.post({ hook_event_name: 'before_agent_start' }) + expect(pane.state()).toBe('working') + commandFinished(pane.runtime) + await settle() + await pane.post({ hook_event_name: 'agent_end' }) + expect(pane.state()).toBe('done') + }) + + it('keeps status after inventory restores launch authority', async () => { + const pane = await createPane(async () => 'omp') + pane.runtime.retireLaunch() + await pane.post({ hook_event_name: 'before_agent_start' }) + await pane.runtime.restoreInventory(pane.handle) + commandFinished(pane.runtime) + await settle() + expect(pane.state()).toBe('working') + await pane.post({ hook_event_name: 'agent_end', has_active_jobs: true }) + expect(pane.state()).toBe('working') + commandFinished(pane.runtime) + await settle() + await pane.post({ + hook_event_name: 'agent_end', + has_active_jobs: false, + has_pending_messages: false + }) + expect(pane.state()).toBe('done') + }) + + it('preserves an open input dialog and session reset readiness', async () => { + const pane = await createPane(async () => 'omp') + await pane.post({ hook_event_name: 'ui_prompt_start', ui_prompt_active: true }) + expect(pane.state()).toBe('waiting') + commandFinished(pane.runtime) + await settle() + expect(pane.state()).toBe('waiting') + await pane.post({ hook_event_name: 'ui_prompt_end', is_idle: false }) + expect(pane.state()).toBe('working') + await pane.post({ hook_event_name: 'agent_end' }) + await pane.post({ + hook_event_name: 'session_switch', + is_idle: true, + has_active_jobs: false, + has_pending_messages: false + }) + commandFinished(pane.runtime) + await settle() + expect(pane.state()).toBe('done') + }) + + it('retires authority only when the execution host confirms a shell', async () => { + const pane = await createPane(async () => 'zsh') + await pane.post({ hook_event_name: 'agent_end' }) + commandFinished(pane.runtime) + await settle() + expect(pane.state()).toBeUndefined() + await pane.post({ hook_event_name: 'agent_end' }) + expect(pane.state()).toBeUndefined() + }) + + it.each(['controller', 'incarnation'] as const)( + 'ignores a shell read from an old %s', + async (replacement) => { + let resolveForeground: ((process: string | null) => void) | undefined + const foreground = new Promise((resolve) => { + resolveForeground = resolve + }) + const pane = await createPane(() => foreground) + await pane.post({ hook_event_name: 'agent_end' }) + commandFinished(pane.runtime) + if (replacement === 'controller') { + pane.runtime.setPtyController({ + ...pane.controller, + getForegroundProcess: async () => 'omp' + }) + } else { + pane.runtime.replaceIncarnation() + } + resolveForeground?.('zsh') + await settle() + expect(pane.state()).toBe('done') + } + ) + }) +} diff --git a/src/main/runtime/opencode-finished-session-authority.test.ts b/src/main/runtime/opencode-finished-session-authority.test.ts index ded271c06175..1002b5dc52df 100644 --- a/src/main/runtime/opencode-finished-session-authority.test.ts +++ b/src/main/runtime/opencode-finished-session-authority.test.ts @@ -7,10 +7,6 @@ import { AgentHookServer } from '../agent-hooks/server' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' -// STA-4557: #14866 deferred command-finished retirement for OpenCode panes behind an -// async "is the foreground still OpenCode?" read. These pin the two ways a finished -// session then kept or regained orchestration authority (the #14943 revert reason). - const WORKTREE_PATH = '/tmp/worktree-a' const WORKTREE = { path: WORKTREE_PATH, @@ -93,7 +89,7 @@ async function launchOpenCodePane(options: { type OrcaRuntimeServiceDeps = NonNullable[2]> -/** Drain the microtask + timer queues the deferred foreground read chains through. */ +// Drain deferred foreground reads before asserting retirement. async function settle(ticks = 40): Promise { for (let tick = 0; tick < ticks; tick += 1) { await new Promise((resolve) => setTimeout(resolve, 0)) @@ -129,8 +125,7 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { expect(pane.runtime.verifyOrchestrationCompatibilityCaller(pane.evidence)).not.toBeNull() - // OpenCode exits; the shell prints its OSC 133;D and repaints its title while the - // foreground read is still in flight, then the read lands proving a plain shell. + // A title repaint does not invalidate the execution host's shell observation. pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100) pane.runtime.onPtyData(pane.ptyId, '\x1b]0;~/worktree-a\x07', 101) resolveForeground?.('zsh') @@ -172,8 +167,7 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { await server.start({ env: 'production' }) const pane = await launchOpenCodePane({ ptyId: 'pty-opencode-reuse', - // OpenCode is a TUI: it is still the foreground process when its command completes. - getForegroundProcess: async () => 'opencode', + getForegroundProcess: async () => 'zsh', retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey), attestAgentHookCompatibilityAuthority: (candidate) => server.attestCompatibilityAuthority(candidate) @@ -210,8 +204,7 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100) await settle() - // Every later process in this shell inherits ORCA_AGENT_LAUNCH_TOKEN from the PTY env, - // so the finished session's token must stop attesting once its command completed. + // A confirmed shell cannot use its previous agent's inherited launch token. expect( server.attestCompatibilityAuthority({ paneKey: pane.paneKey, @@ -222,62 +215,6 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { ).toBeNull() }) - it('does not let a later OpenCode session satisfy the previous pending retirement', async () => { - const server = new AgentHookServer() - servers.push(server) - await server.start({ env: 'production' }) - let resolveForeground: ((process: string | null) => void) | undefined - const foreground = new Promise((resolve) => { - resolveForeground = resolve - }) - const pane = await launchOpenCodePane({ - ptyId: 'pty-opencode-session-boundary', - getForegroundProcess: () => foreground, - retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - server.attestCompatibilityAuthority(candidate) - }) - const hookEnv = server.buildPtyEnv() - // Both sessions post the same launchToken: it lives in the PTY env, so every - // process started in this shell inherits it. Only sessionID separates them. - const post = (sessionId: string): Promise => - fetch(`http://127.0.0.1:${hookEnv.ORCA_AGENT_HOOK_PORT}/hook/opencode`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': hookEnv.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify({ - paneKey: pane.paneKey, - launchToken: pane.launchToken, - tabId: pane.tabId, - worktreeId: 'wt-opencode', - env: 'production', - payload: { hook_event_name: 'SessionBusy', sessionID: sessionId } - }) - }) - const attestCurrent = (): unknown => - server.attestCompatibilityAuthority({ - paneKey: pane.paneKey, - launchTokenHash: createHash('sha256').update(pane.launchToken).digest('hex'), - connectionId: null, - terminalProvenance: 'current_runtime' - }) - - await post('session-1') - expect(attestCurrent()).not.toBeNull() - - // Session 1 ends. Nothing about the PTY changes across an agent session boundary: - // same record, same incarnation, no title write — so every guard on the deferred - // read still matches the baseline captured for session 1. - pane.runtime.onPtyData(pane.ptyId, '\x1b]133;D;0\x07', 100) - await post('session-2') - resolveForeground?.('opencode') - await settle() - - expect(attestCurrent()).toBeNull() - }) - it('does not rehydrate a finished session token as restored authority after a restart', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-sta4557-')) tempDirs.push(userDataPath) @@ -286,7 +223,7 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { await first.start({ env: 'production', userDataPath }) const pane = await launchOpenCodePane({ ptyId: 'pty-opencode-restart', - getForegroundProcess: async () => 'opencode', + getForegroundProcess: async () => 'zsh', retireAgentHookCompatibilityAuthority: (paneKey) => first.retirePaneAuthority(paneKey) }) const hookEnv = first.buildPtyEnv() @@ -315,8 +252,7 @@ describe('OpenCode finished-session launch authority (STA-4557)', () => { servers.push(restarted) await restarted.start({ env: 'production', userDataPath }) - // After a restart the PTY survives with ORCA_AGENT_LAUNCH_TOKEN still in its env and - // pty.launchToken gone, so a persisted commitment is the whole proof of authority. + // Retired launch commitments must not become authority after a hook-server restart. expect( restarted.attestCompatibilityAuthority({ paneKey: pane.paneKey, diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index dbbb124d34aa..3246097d8f53 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -18,7 +18,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': - this.retirePtyAgentLaunchAuthority(ptyId) + void this.retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode: fact.exitCode diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 2e52e6f07483..26e8357c84c0 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -2,6 +2,7 @@ import { OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller } from './orca-runtime-verify-orchestration-compatibility-caller' import type { OrchestrationCompatibilityTerminalAuthority } from './runtime-terminal-contracts' import { createHash } from 'node:crypto' +import { isShellProcess } from '../../shared/shell-process-detection' import { isTerminalLeafId, makePaneKey, @@ -125,6 +126,43 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } } + protected async retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId: string): Promise { + const pty = this.ptysById.get(ptyId) + if (!pty?.connected) { + return + } + const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) + const launchToken = pty.launchToken + const launchAgent = pty.launchAgent + if (!launchToken && !receipt && !launchAgent) { + return + } + const incarnationId = pty.incarnationId + const generation = this.getPtyLifecycleGeneration(ptyId) + const result = await this.readPtyForegroundProcessFromController(ptyId, pty.lastOscTitleAt ?? 0) + // OMP emits command-finished markers during live TUI redraws, not only on process exit. + if ( + !result?.available || + result.controller !== this.ptyController || + !result.process || + !isShellProcess(result.process) + ) { + return + } + if ( + this.ptysById.get(ptyId) !== pty || + !pty.connected || + pty.incarnationId !== incarnationId || + this.getPtyLifecycleGeneration(ptyId) !== generation || + pty.launchToken !== launchToken || + pty.launchAgent !== launchAgent || + this.restoredOrchestrationAuthorityByPtyId.get(ptyId) !== receipt + ) { + return + } + this.retirePtyAgentLaunchAuthority(ptyId) + } + protected retirePtyAgentLaunchAuthority(ptyId: string): void { const pty = this.ptysById.get(ptyId) if (!pty) { diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 2e6f30770b0f..41cc5df4bea9 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -147,7 +147,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime }, onCommandFinished: (exitCode: number | null) => { void this.recheckHookAgentPresenceForPty(ptyId) - this.retirePtyAgentLaunchAuthority(ptyId) + void this.retirePtyAgentLaunchAuthorityAfterCommandFinished(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) }, From 58a7bb793b23a4bef95aadad26a63e28c759cfdc Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Thu, 1 Oct 2026 16:29:31 +0530 Subject: [PATCH 07/10] fix: retain agent status and completed turn duration --- docs/reference/agent-status-store.md | 10 ++ .../agent-hooks/server-agent-presence.test.ts | 12 -- .../server/server-agent-presence.ts | 8 -- src/main/ipc/agent-hooks.ts | 4 - src/main/pi/agent-status-extension-source.ts | 2 +- src/preload/api/agent-status-api.ts | 2 - src/preload/api/agent-status-bridge.ts | 2 - .../native-chat-terminal-turn.test.ts | 14 ++ .../native-chat/native-chat-terminal-turn.ts | 9 +- .../parked-terminal-command-status.test.ts | 43 ++++--- .../parked-terminal-command-status.ts | 44 +------ ...onnection-command-finished-cleanup.test.ts | 4 +- ...onnection-foreground-agent-routing.test.ts | 42 ------ ...nnection-unanswered-command-finish.test.ts | 121 +++++++++--------- .../pty-connection/pane-agent-identity.ts | 33 +---- .../web/preload-api/web-agent-status-api.ts | 1 - 16 files changed, 126 insertions(+), 225 deletions(-) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 2906f221ce23..bf9f82b761bf 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -73,6 +73,8 @@ No second reporter or reader-side rule is required. - Idle readiness uses `done` with `sessionBoundary: true`. It is not turn completion. This also applies after a completed turn: retry, compaction, dialog close, and approval resolution keep the prior main-agent outcome without another completion. + Native chat keeps the completed main-agent duration when its host turn stamp + remains present; a readiness-only row without that clock has no turn duration. - Retry and compaction start as `working`. Their end can restore readiness only when current activity is clear and no continuation flag is true. - Real UI dialogs use `waiting`; tool approvals use `blocked`. Closing a dialog @@ -95,6 +97,14 @@ No second reporter or reader-side rule is required. Pending reads must still match the controller, PTY incarnation, lifecycle, launch identity, and restored inventory receipt. Actual PTY exit keeps its existing retirement rules. +- Renderer command-finished cleanup also requires a confirmed shell for a known + agent. An unanswered or failed read cancels pending cleanup without dropping the + status or launch record, even when the host has no checkable process identity. + A known `done` hook row still requires confirmation: turn completion is not + process exit. Repeated command-finished markers do not change this rule. +- Parked SSH command-finished markers do not drop hook status or launch records. + They carry no execution-host process-exit proof. Host-proved process exit and + explicit user dismissal keep their existing cleanup rules. - Timer-cleared working titles do not prove readiness. Their origin survives graph rebuilds and same-title hydration. While the stale-clear flag is set, neither the OSC title nor the renderer title proves idle, even after rebranding. diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index cd8da3aecb4c..d050e6b71189 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -216,18 +216,6 @@ describe('host-owned hook presence', () => { } ) - it('reports a checkable agent process only for an identified, running owner', async () => { - const server = await createServer() - expect(server.hasVerifiableAgentProcess(PANE)).toBe(false) - await hook(server, 'SessionStart', 'unidentified', undefined, null) - expect(server.hasVerifiableAgentProcess(PANE)).toBe(false) - const identified = await createServer() - await hook(identified, 'SessionStart') - expect(identified.hasVerifiableAgentProcess(PANE)).toBe(true) - await hook(identified, 'SessionEnd', 'session-a', 'prompt_input_exit') - expect(identified.hasVerifiableAgentProcess(PANE)).toBe(false) - }) - it('checks each pane once after replaying its spooled hooks', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-presence-spool-')) const first = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index ec09832ccb85..a955e8fcfef3 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -21,14 +21,6 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy } } - /** Whether this pane's owner carries a process identity that its execution host can check. */ - hasVerifiableAgentProcess(paneKey: string): boolean { - const presence = this.state.lastStatusByPaneKey.get( - this.resolvePaneKeyAlias(paneKey) - )?.agentPresence - return presence?.process !== undefined && !presence.ended - } - checkAgentPresence(paneKey: string): Promise { const resolved = this.resolvePaneKeyAlias(paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index e6fecfc06ee0..f460be06b5fe 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -36,7 +36,6 @@ export function registerAgentHookHandlers( ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:inferQuestionAnswered') - ipcMain.removeHandler('agentStatus:hasVerifiableAgentProcess') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') registerAgentStatusRowTeardownIpcHandlers() registerAgentPaneAuthorityIpcHandlers({ @@ -70,9 +69,6 @@ export function registerAgentHookHandlers( } return agentHookServer.inferQuestionAnswered(request as AgentQuestionAnsweredInferenceRequest) }) - ipcMain.handle('agentStatus:hasVerifiableAgentProcess', (_event, paneKey: unknown): boolean => - typeof paneKey === 'string' ? agentHookServer.hasVerifiableAgentProcess(paneKey) : false - ) ipcMain.handle( 'agentStatus:getMigrationUnsupportedSnapshot', (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 57c17f31f671..3e1892991422 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -206,7 +206,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' const ompRuntime = isOmpRuntime()', ' cancelPostRetry()', ' const metadata = getPostSessionMetadata(ompRuntime)', - '// Model changes must not erase an unacknowledged completion in the latest-only slot.', + ' // Model and dialog events must not replace an undelivered completion.', " const previousCompletion = latestPost?.hookEventName === 'agent_end' && !latestPost.delivered && latestPost.metadata.session_id === metadata.session_id", " const preserveCompletion = ompRuntime && previousCompletion && ['model_select', 'ui_prompt_start', 'ui_prompt_end'].includes(hookEventName)", ' pendingPost = {', diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 119a3c55f6c8..909f0fd20fba 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -18,8 +18,6 @@ export type AgentStatusApi = { inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise /** Guarded clear for an answered AskUserQuestion wait — the CLI emits no hook at answer time, so the renderer reports the submit keystroke. */ inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise - /** Whether the host can check this pane's agent process; without that, silence keeps today's cleanup. */ - hasVerifiableAgentProcess?: (paneKey: string) => Promise /** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */ onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 1a056252634e..5b3566a87113 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -28,8 +28,6 @@ export const agentStatusApi = { ipcRenderer.invoke('agentStatus:getSnapshot'), inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferInterrupt', request), - hasVerifiableAgentProcess: (paneKey: string): Promise => - ipcRenderer.invoke('agentStatus:hasVerifiableAgentProcess', paneKey), inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), onMigrationUnsupported: ( diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts b/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts index 4e77c18ace64..6e5a0208cc28 100644 --- a/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-terminal-turn.test.ts @@ -119,6 +119,20 @@ describe('nativeChatHookLatestTurnWorkedSeconds', () => { ).toBe(30) }) + it('keeps the completed main-agent duration after readiness returns', () => { + expect( + nativeChatHookLatestTurnWorkedSeconds( + { + ...done, + stateStartedAt: 200_000, + sessionBoundary: true, + mainAgent: { state: 'done', stateStartedAt: 31_000 } + }, + false + ) + ).toBe(30) + }) + // Staleness ages the row out of the pane, not the host's record that the turn finished. it('keeps a host-ended duration once the row is stale', () => { expect(nativeChatHookLatestTurnWorkedSeconds(done, true)).toBe(90) diff --git a/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts b/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts index 2926c5d75f2c..da33238d3b97 100644 --- a/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts +++ b/src/renderer/src/components/native-chat/native-chat-terminal-turn.ts @@ -70,12 +70,7 @@ export function nativeChatHookTurnStartedAt( : entry.stateStartedAt } -/** - * The latest turn's duration by the host, for a turn the pane is not running: a number once the - * main agent is done (its done stamp minus the host's turn start), null when the host went quiet - * mid-turn (nothing says the turn ended, so a locally measured end would be a false claim), and - * undefined to keep what the pane observed (an old host, a session boundary, a pane-side end). - */ +// Completed main-agent clocks survive readiness updates; unknown turn ends have no duration. export function nativeChatHookLatestTurnWorkedSeconds( entry: NativeChatHookTurnEntry | undefined, hookSilent: boolean @@ -85,7 +80,7 @@ export function nativeChatHookLatestTurnWorkedSeconds( } const mainAgent = entry.mainAgent ?? entry if (mainAgent.state === 'done') { - return entry.sessionBoundary === true || entry.turnStartedAt === undefined + return (entry.sessionBoundary === true && !entry.mainAgent) || entry.turnStartedAt === undefined ? undefined : Math.max(0, Math.floor((mainAgent.stateStartedAt - entry.turnStartedAt) / 1000)) } diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index b7847f16a8ef..88450dd434d0 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -459,28 +459,39 @@ describe('createParkedTerminalCommandStatusPolicy', () => { expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID, 0) }) - it('drops a same-turn status row on command finished for SSH PTYs only', async () => { - mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry() - const local = await createPolicy(PTY_ID_LOCAL) - local.onCommandFinished(0) - // Why: local drops need the mounted pane's foreground process-confirm ladder - // (leaked nested-shell 133;D protection), so the watcher must not drop them. - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - local.dispose() + it.each(['working', 'done'] as const)( + 'keeps a parked SSH OMP %s row without execution-host exit evidence', + async (state) => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ + agentType: 'omp', + state + }) + mockStoreState.dropAgentStatus.mockImplementation((key: string) => { + delete mockStoreState.agentStatusByPaneKey[key] + }) + const ssh = await createPolicy(PTY_ID_SSH) - const ssh = await createPolicy(PTY_ID_SSH) - ssh.onCommandFinished(0) - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(PANE_KEY) - ssh.dispose() - }) + ssh.onCommandFinished(0) + + expect(mockStoreState.agentStatusByPaneKey[PANE_KEY]).toMatchObject({ + agentType: 'omp', + state, + prompt: 'build the feature' + }) + ssh.dispose() + } + ) - it('clears the launch registry on SSH command finished when no status row exists', async () => { + it('keeps the parked SSH launch identity before the first hook', async () => { + mockStoreState.agentLaunchConfigByPaneKey[PANE_KEY] = { identity: { agentType: 'omp' } } + mockStoreState.clearAgentLaunchConfig.mockImplementation((key: string) => { + delete mockStoreState.agentLaunchConfigByPaneKey[key] + }) const ssh = await createPolicy(PTY_ID_SSH) ssh.onCommandFinished(0) - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(PANE_KEY) - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.agentLaunchConfigByPaneKey[PANE_KEY]?.identity.agentType).toBe('omp') ssh.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index 6a732c8e43d9..1e1318e0bb70 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -1,10 +1,4 @@ -/** - * Parked-pane command-lifecycle status policy. - * Why: parking unmounts TerminalPane, so OSC 133;D and Command Code scrape signals went dark. - * This ports the store-level subset of pty-connection's handlers; the pane-coupled parts - * (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane. - */ -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +// Parked panes update command status without pane-specific input handling. import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { isShellProcess } from '../../../../shared/shell-process-detection' @@ -82,33 +76,6 @@ export function createParkedTerminalCommandStatusPolicy(options: { }) } - // Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference - // option: parked panes receive no key events, so inference never has evidence here. - const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => { - const state = useAppStore.getState() - if (!entry) { - // Why: an Orca-started agent can exit before its first hook status; clear the launch - // registry on command exit like the mounted path does. - state.clearAgentLaunchConfig(paneKey) - return - } - const current = state.agentStatusByPaneKey[paneKey] - if (!current) { - state.clearAgentLaunchConfig(paneKey) - return - } - const unchanged = - current.state === entry.state && - current.prompt === entry.prompt && - current.updatedAt === entry.updatedAt && - current.stateStartedAt === entry.stateStartedAt && - current.agentType === entry.agentType - if (!unchanged) { - return - } - state.dropAgentStatus(paneKey) - } - // Complete the row only while it is still this turn's command-code/working entry. const settleCommandCodeDone = (normalizedPrompt: string): void => { const routing = resolveRouting() @@ -186,15 +153,8 @@ export function createParkedTerminalCommandStatusPolicy(options: { // Why: the finished command may have moved HEAD or the index (an agent running // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode) + // Command-finished markers do not prove that an agent process exited. void retireForegroundAgentUnlessConfirmed() - // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted - // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need - // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a - // real agent exit, so their drop stays with the mounted pane. - if (parseAppSshPtyId(ptyId) === null) { - return - } - dropCommandFinishedStatusIfSameTurn(useAppStore.getState().agentStatusByPaneKey[paneKey]) }, // Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only). diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index 75b96f026842..701a7ea0995f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts @@ -665,7 +665,7 @@ describe('connectPanePty', () => { expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(5) }) - it('retires stale routing after unavailable command-finish reads without asserting shell', async () => { + it('revokes stale routing but keeps launch identity after unavailable command-finish reads', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) @@ -699,7 +699,7 @@ describe('connectPanePty', () => { await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) await flushAsyncTicks() - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(mockStoreState.agentLaunchConfigByPaneKey[paneKey]?.identity?.agentType).toBe('droid') expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: null, shellForeground: false diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts index 31cbe3ed444c..27e2642c339b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts @@ -153,48 +153,6 @@ describe('connectPanePty', () => { await restoreTerminalTestGlobals() }) - it('drops agent status without retaining when OSC 133 reports the command finished', async () => { - const { connectPanePty } = await import('./pty-connection') - - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport() - let currentPtyId: string | null = null - vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - currentPtyId = 'pty-local-1' - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-local-1' - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'done', - prompt: 'hi', - updatedAt: 1000, - stateStartedAt: 1000, - agentType: 'codex', - stateHistory: [] - } - } - } - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ isVisibleRef: { current: false } }) - - connectPanePty(pane as never, manager as never, deps as never) - - capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks() - - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() - }) - it('clears pre-hook launch config when an Orca-started command exits', async () => { vi.useFakeTimers({ toFake: ['setTimeout'] }) const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts index e79d09e9857b..4c8d315205c5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts @@ -144,67 +144,68 @@ describe('command-finished cleanup when the shell check cannot answer', () => { await restoreTerminalTestGlobals() }) - it.each([ - ['clears', 'a row without a process identity', false], - ['keeps', 'a row whose agent process the host can check', true] - ])( - '%s %s when an SSH pane finishes and the shell check cannot answer', - async (_verb, _label, verifiable) => { - vi.useFakeTimers() - const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) - const hasVerifiableAgentProcess = vi.fn(async () => verifiable) - Object.assign(window.api.agentStatus, { hasVerifiableAgentProcess }) - const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } - const ptyId = 'ssh:conn@@pty-codex-exit' - const tabId = 'tab-ssh-codex-exit' - const paneKey = makePaneKey(tabId, LEAF_1) - const transport = createMockTransport(ptyId) - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - dataCallbackRef.current = callbacks.onData ?? null - return { id: ptyId } - } - ) - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'done', - prompt: 'remote task', - updatedAt: 1_000, - stateStartedAt: 900, - agentType: 'codex', - terminalTitle: 'Codex', - stateHistory: [] + describe.each([ + { label: 'unanswered local read', ptyId: 'pty-omp-unanswered', fails: false }, + { label: 'failed local read', ptyId: 'pty-omp-failed', fails: true }, + { label: 'unavailable SSH host', ptyId: 'ssh:conn@@pty-omp-unavailable', fails: false } + ])('$label', ({ ptyId, fails }) => { + it.each(['working', 'done'] as const)( + 'keeps %s OMP status through repeated command finishes without a launch record', + async (state) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => { + if (fails) { + throw new Error('execution host unavailable') + } + return null + }) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const tabId = 'tab-omp-unanswered' + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } } + ) + transportFactoryQueue.push(transport) + mockStoreState.agentStatusByPaneKey[paneKey] = { + paneKey, + state, + prompt: 'worker task', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'omp', + terminalTitle: 'OMP', + stateHistory: [] + } + mockStoreState.dropAgentStatus.mockImplementation((key: string) => { + delete mockStoreState.agentStatusByPaneKey[key] + }) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + + for (const bytes of ['\x1b]133;B\x07\x1b]133;C\x07\x1b]133;D;0\x07', '\x1b]133;D;0\x07']) { + dataCallbackRef.current?.(bytes) + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + agentType: 'omp', + state, + prompt: 'worker task' + }) + expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled() } } - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ tabId, isVisibleRef: { current: false } }) as never - ) - await vi.advanceTimersByTimeAsync(20) - await flushAsyncTicks() - mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { - launchConfig: { agentArgs: '', agentEnv: {} }, - identity: { agentType: 'codex' } - } - - dataCallbackRef.current?.('\x1b]133;D;0\x07') - await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - await flushAsyncTicks() - - expect(hasVerifiableAgentProcess).toHaveBeenCalledWith(paneKey) - if (verifiable) { - expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - } else { - expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) - } - } - ) + ) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 789c51f7b7ab..6f9b2a0c50a7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -53,16 +53,14 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { const candidate = resolveLaunchAgentCandidate(useAppStore.getState()) return isTuiAgent(candidate) ? candidate : null } - // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a - // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, - // even on a restore where no command-start read has recorded evidence yet. + // Known agent identity requires process confirmation before command-finished cleanup. session.paneHasKnownAgentIdentity = (): boolean => { const state = useAppStore.getState() const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType return ( Boolean(state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent) || - session.paneHasLiveHookAgentIcon(state) || + Boolean(agentTypeToIconAgent(state.agentStatusByPaneKey[session.cacheKey]?.agentType)) || isTuiAgent(registeredLaunchAgent) ) } @@ -120,30 +118,13 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { ): void => { const dropStatus = session.deferredCommandFinishedStatusDrop const reconcile = session.deferredConfirmedShellReconcile + session.deferredCommandFinishedStatusDrop = null session.deferredConfirmedShellReconcile = null - if (options.confirmedShell || !dropStatus) { - session.deferredCommandFinishedStatusDrop = null - if (options.confirmedShell) { - dropStatus?.() - reconcile?.() - } - return - } - // Why: only a pane whose agent process the host can check keeps its row on an unanswered read; - // every other pane keeps today's cleanup until the renderer reads the owner record (step 2). - // The drop stays armed while main answers, so a new command start still cancels it. - const dropUnlessVerifiable = (verifiable: boolean): void => { - if (session.deferredCommandFinishedStatusDrop !== dropStatus) { - return - } - session.deferredCommandFinishedStatusDrop = null - if (!verifiable) { - dropStatus() - } + // Unanswered foreground reads do not prove that the agent exited. + if (options.confirmedShell) { + dropStatus?.() + reconcile?.() } - void Promise.resolve(window.api?.agentStatus?.hasVerifiableAgentProcess?.(session.cacheKey)) - .then((verifiable) => dropUnlessVerifiable(verifiable === true)) - .catch(() => dropUnlessVerifiable(false)) } const isRemotePtyId = (id: string): boolean => Boolean(isRemoteExecutionHostPtyId(id) || parseAppSshPtyId(id)) diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts index d4b2c0db1630..d7c9740018c8 100644 --- a/src/renderer/src/web/preload-api/web-agent-status-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-status-api.ts @@ -9,7 +9,6 @@ export function createWebAgentStatusApi(): Partial { getSnapshot: () => Promise.resolve([]), inferInterrupt: () => Promise.resolve(false), inferQuestionAnswered: () => Promise.resolve(false), - hasVerifiableAgentProcess: () => Promise.resolve(false), onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, From bee3570cea9fff8d63ed539a4d71b89cf83dc207 Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Thu, 1 Oct 2026 21:35:57 +0530 Subject: [PATCH 08/10] fix: fence stale OMP launch authority and hook retirement --- docs/reference/agent-status-store.md | 11 +- .../omp-command-finished-status.test.ts | 49 ++++++- .../runtime/orca-runtime-create-terminal.ts | 1 + ...me-get-orchestration-dispatch-authority.ts | 10 ++ ...-provider-reattach-launch-identity.test.ts | 24 ---- .../orca-runtime-record-pty-worktree.ts | 1 + src/main/runtime/orca-runtime-register-pty.ts | 1 + ...rify-orchestration-compatibility-caller.ts | 4 +- .../rpc/orchestration-legacy-compatibility.ts | 3 +- ...-legacy-takeover-current-authority.test.ts | 132 ++++++++++++++++++ .../runtime-agent-hook-retirement-fence.ts | 50 +++++++ .../runtime/runtime-terminal-state-records.ts | 1 + 12 files changed, 256 insertions(+), 31 deletions(-) create mode 100644 src/main/runtime/runtime-agent-hook-retirement-fence.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index bf9f82b761bf..a8e29448c339 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -95,8 +95,15 @@ No second reporter or reader-side rule is required. Retirement requires a shell confirmed by the current execution host. Unknown, failed, and non-shell foreground reads keep the row and admit later hooks. Pending reads must still match the controller, PTY incarnation, lifecycle, - launch identity, and restored inventory receipt. Actual PTY exit keeps its - existing retirement rules. + launch identity, restored inventory receipt, and host hook observations. + A new session, turn, or observation revision cancels an older shell response, + including activity with the same state and timestamp. Actual PTY exit keeps + its existing retirement rules. +- A command-finished marker ends token-only startup takeover permission before + the foreground read starts. Unknown, failed, and non-shell reads still retain + status and the launch token, but takeover requires matching host hook + attestation afterward. Registering the same launch does not restore startup + permission. Admitting a fresh launch token restores the startup exception. - Renderer command-finished cleanup also requires a confirmed shell for a known agent. An unanswered or failed read cancels pending cleanup without dropping the status or launch record, even when the host has no checkable process identity. diff --git a/src/main/runtime/omp-command-finished-status.test.ts b/src/main/runtime/omp-command-finished-status.test.ts index b3ca55abbb15..05b96601d0f3 100644 --- a/src/main/runtime/omp-command-finished-status.test.ts +++ b/src/main/runtime/omp-command-finished-status.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from '../agent-hooks/server' import { OrcaRuntimeService } from './orca-runtime' import { makeStore } from './runtime-rpc-worktree-store-fixtures' @@ -44,6 +44,7 @@ const servers: AgentHookServer[] = [] const directories: string[] = [] afterEach(() => { + vi.restoreAllMocks() for (const server of servers.splice(0)) { server.stop() } @@ -103,6 +104,9 @@ async function createPane(getForegroundProcess: () => Promise) { undefined, { retireAgentHookCompatibilityAuthority: (paneKey) => server.retirePaneAuthority(paneKey), + getAgentStatusSnapshot: () => server.getStatusSnapshot(), + getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), attestAgentHookCompatibilityAuthority: (candidate) => server.attestCompatibilityAuthority(candidate) } @@ -228,6 +232,49 @@ for (const path of ['bytes', 'daemon'] as const) { expect(pane.state()).toBe('done') }) + it.each([ + ['new session', 'new-session', true], + ['new turn in the same session', 'old-session', true], + ['fresh activity without a state change', 'old-session', false] + ] as const)('ignores an old shell read after %s', async (_label, sessionId, completeTurn) => { + vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000) + let releaseForeground: ((process: string | null) => void) | undefined + let markReadStarted: (() => void) | undefined + const readStarted = new Promise((resolve) => { + markReadStarted = resolve + }) + const foreground = new Promise((resolve) => { + releaseForeground = resolve + }) + const pane = await createPane(() => { + markReadStarted?.() + return foreground + }) + await pane.post({ hook_event_name: 'before_agent_start', session_id: 'old-session' }) + if (completeTurn) { + await pane.post({ hook_event_name: 'agent_end', session_id: 'old-session' }) + } + commandFinished(pane.runtime) + await readStarted + commandFinished(pane.runtime) + await pane.post({ hook_event_name: 'before_agent_start', session_id: sessionId }) + releaseForeground?.('zsh') + await settle() + expect(pane.state()).toBe('working') + expect(pane.server.getStatusSnapshotForPane(pane.paneKey)[0]?.providerSession?.id).toBe( + sessionId + ) + expect( + pane.runtime.getOrchestrationDispatchAuthority(pane.handle)?.launchTokenHash + ).not.toBeNull() + commandFinished(pane.runtime) + await settle() + expect(pane.state()).toBeUndefined() + expect( + pane.runtime.getOrchestrationDispatchAuthority(pane.handle)?.launchTokenHash + ).toBeNull() + }) + it('retires authority only when the execution host confirms a shell', async () => { const pane = await createPane(async () => 'zsh') await pane.post({ hook_event_name: 'agent_end' }) diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 242e0f7f4544..961d13c8d722 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -235,6 +235,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate : null pty.launchToken = launchToken ?? null pty.launchIncarnationId = launchToken ? pty.incarnationId : null + pty.launchNeedsHookAttestation = false pty.launchAgent = launchOpts.launchAgent ?? null } recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)) diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 26e8357c84c0..f23c12a6027b 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -24,6 +24,7 @@ import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet- import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { matchesProcessIncarnation } from './orchestration/worker-terminal-process-liveness' +import { captureAgentHookRetirementFence } from './runtime-agent-hook-retirement-fence' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -131,6 +132,7 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim if (!pty?.connected) { return } + pty.launchNeedsHookAttestation = true const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) const launchToken = pty.launchToken const launchAgent = pty.launchAgent @@ -139,6 +141,11 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim } const incarnationId = pty.incarnationId const generation = this.getPtyLifecycleGeneration(ptyId) + const hookObservationsUnchanged = captureAgentHookRetirementFence( + this.collectPaneKeysForPty(ptyId), + this.getAgentProviderSessionRowsForPaneFn, + this.getAgentProviderSessionSnapshotFn ?? this.getAgentStatusSnapshotFn + ) const result = await this.readPtyForegroundProcessFromController(ptyId, pty.lastOscTitleAt ?? 0) // OMP emits command-finished markers during live TUI redraws, not only on process exit. if ( @@ -160,6 +167,9 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim ) { return } + if (!hookObservationsUnchanged()) { + return + } this.retirePtyAgentLaunchAuthority(ptyId) } diff --git a/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts b/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts index a463dd75cf41..8b8360382654 100644 --- a/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts +++ b/src/main/runtime/orca-runtime-provider-reattach-launch-identity.test.ts @@ -73,28 +73,4 @@ describe('provider reattach launch identity', () => { launchIncarnationId: null }) }) - - it('retires daemon launch identity when the agent command finishes', () => { - const runtime = new OrcaRuntimeService(null) - - runtime.registerPty('pty-finished-reattach', WORKTREE_ID, null, { - tabId: TAB_ID, - leafId: LEAF_ID, - incarnationId: INCARNATION_ID, - providerReattachLaunchIdentity: { - incarnationId: INCARNATION_ID, - launchAgent: 'codex' - } - }) - runtime.emitDaemonPtyTransientFact('pty-finished-reattach', { - kind: 'command-finished', - exitCode: 0 - }) - - expect(getPty(runtime, 'pty-finished-reattach')).toMatchObject({ - launchAgent: null, - launchToken: null, - launchIncarnationId: null - }) - }) }) diff --git a/src/main/runtime/orca-runtime-record-pty-worktree.ts b/src/main/runtime/orca-runtime-record-pty-worktree.ts index cfe3b3e23557..da16df8673c8 100644 --- a/src/main/runtime/orca-runtime-record-pty-worktree.ts +++ b/src/main/runtime/orca-runtime-record-pty-worktree.ts @@ -71,6 +71,7 @@ export class OrcaRuntimeWithRecordPtyWorktree extends OrcaRuntimeWithRefreshRepo launchConfig: null, launchToken: null, launchIncarnationId: null, + launchNeedsHookAttestation: false, launchAgent: null, agentSessionOwners: (state.agentSessionOwners ?? []).map(cloneAgentSessionOwnerBinding), foregroundAgent: null, diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index bd9c96a5c0fa..c18ebbf0ee94 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -111,6 +111,7 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand ) { pty.launchToken = agentLaunchAuthority.launchToken pty.launchIncarnationId = binding.incarnationId + pty.launchNeedsHookAttestation = false pty.launchAgent = agentLaunchAuthority.launchAgent } const providerReattachLaunchIdentity = binding?.providerReattachLaunchIdentity diff --git a/src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts b/src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts index b3794033bf1a..1e374237ae50 100644 --- a/src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts +++ b/src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts @@ -58,10 +58,10 @@ export class OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller extends OrcaR if ( options?.currentRuntimeLaunchSufficient && terminalProvenance === 'current_runtime' && + this.ptysById.get(terminal.ptyId)?.launchNeedsHookAttestation !== true && claimedPaneKey === terminal.paneKey ) { - // Why: the checks above bind a fresh launch to its live PTY, host, and - // launch secret. Only an exact live-pane match may skip hook attestation. + // Only an exact launch before its first command-finished marker may skip hook attestation. return this.freezeOrchestrationCompatibilityCallerAuthority( terminal, terminal.processIncarnation, diff --git a/src/main/runtime/rpc/orchestration-legacy-compatibility.ts b/src/main/runtime/rpc/orchestration-legacy-compatibility.ts index bacf3e0fbeb9..3ee3bba03125 100644 --- a/src/main/runtime/rpc/orchestration-legacy-compatibility.ts +++ b/src/main/runtime/rpc/orchestration-legacy-compatibility.ts @@ -83,8 +83,7 @@ export class OrchestrationLegacyCompatibility { return { handled: false } } if (request.method === 'orchestration.runUse' && values.takeoverLegacy === true) { - // Why: takeover is a current-contract recovery action. An exact fresh runtime launch - // already proves its live PTY, process, pane, host, handle, and launch secret. + // A launch without a command-finished marker can authorize takeover before its first hook. const callerAuthority = this.runtime.verifyOrchestrationCompatibilityCaller( request.orchestrationCompatibilityEvidence, { currentRuntimeLaunchSufficient: true } diff --git a/src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts b/src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts index 4de71ce6f2a0..c876e3809a31 100644 --- a/src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts @@ -13,11 +13,143 @@ import { } from './orchestration-legacy-compatibility-dispatcher-test-fixture' import { ORCHESTRATION_METHODS } from './methods/orchestration' +const PTY_ID = 'pty-current-launch' + +class CurrentLaunchRuntime extends OrcaRuntimeService { + bindLaunch(token: string, incarnation = 'current-launch-1'): void { + const [tabId, leafId] = CURRENT_COORDINATOR_PANE.split(':') + this.registerPty(PTY_ID, 'repo::/worktree', null, { + tabId, + leafId, + incarnationId: incarnation, + agentLaunchAuthority: { launchToken: token, launchAgent: 'omp' } + }) + this.adoptControllerTerminalHandle(PTY_ID, CURRENT_COORDINATOR_HANDLE, incarnation) + } + + endLaunch(): void { + this.retirePtyAgentLaunchAuthority(PTY_ID) + } +} afterEach(() => { cleanupLegacyCompatibilityDispatcherHarnesses() }) describe('legacy takeover by current runtime authority', () => { + it.each([ + ['bytes', 'sleep'], + ['daemon', null], + ['bytes', 'failed'] + ] as const)( + 'requires hook authority after a %s marker with a %s foreground read', + async (path, foregroundResult) => { + const harness = createHarness() + const hookServer = new AgentHookServer() + let resolveForeground: ((process: string | null) => void) | undefined + let rejectForeground: ((error: Error) => void) | undefined + const foreground = new Promise((resolve, reject) => { + resolveForeground = resolve + rejectForeground = reject + }) + const runtime = new CurrentLaunchRuntime(null, undefined, { + getAgentProviderSessionRowsForPane: (paneKey) => + hookServer.getStatusSnapshotForPane(paneKey), + retireAgentHookCompatibilityAuthority: (paneKey) => hookServer.retirePaneAuthority(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + hookServer.attestCompatibilityAuthority(candidate) + }) + runtime.setOrchestrationDb(harness.db) + runtime.setPtyController({ + spawn: async () => ({ id: PTY_ID }), + write: () => true, + kill: () => true, + getForegroundProcess: () => foreground + }) + const proof = currentEvidence('coordinator') + runtime.bindLaunch(proof.launchToken!) + hookServer.ingestRemote( + { + paneKey: CURRENT_COORDINATOR_PANE, + payload: { state: 'done', agentType: 'omp' } + }, + null + ) + if (path === 'bytes') { + runtime.onPtyData(PTY_ID, '\x1b]133;D;0\x07', Date.now()) + } else { + runtime.emitDaemonPtyTransientFact(PTY_ID, { kind: 'command-finished', exitCode: 0 }) + } + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const params = { + id: harness.adoptedRunId, + from: CURRENT_COORDINATOR_HANDLE, + takeoverLegacy: true + } + const before = harness.db.getRun(harness.adoptedRunId) + const rejected = await dispatcher.dispatch( + request('orchestration.runUse', params, proof, 'marker-pending') + ) + expect(rejected).toMatchObject({ + ok: false, + error: { code: 'legacy_read_only', data: { effectsApplied: false } } + }) + expect(harness.db.getRun(harness.adoptedRunId)).toEqual(before) + if (foregroundResult === 'failed') { + rejectForeground?.(new Error('execution host unavailable')) + } else { + resolveForeground?.(foregroundResult) + } + await new Promise((resolve) => setImmediate(resolve)) + const rejectedAfterRead = await dispatcher.dispatch( + request('orchestration.runUse', params, proof, 'marker-non-shell') + ) + expect(rejectedAfterRead).toMatchObject({ + ok: false, + error: { code: 'legacy_read_only', data: { effectsApplied: false } } + }) + expect(harness.db.getRun(harness.adoptedRunId)).toEqual(before) + expect(hookServer.getStatusSnapshotForPane(CURRENT_COORDINATOR_PANE)[0]?.state).toBe('done') + expect( + runtime.getOrchestrationDispatchAuthority(CURRENT_COORDINATOR_HANDLE)?.launchTokenHash + ).toBe(createHash('sha256').update(proof.launchToken!).digest('hex')) + runtime.bindLaunch(proof.launchToken!) + expect( + runtime.verifyOrchestrationCompatibilityCaller(proof, { + currentRuntimeLaunchSufficient: true + }) + ).toBeNull() + hookServer.ingestRemote( + { + paneKey: CURRENT_COORDINATOR_PANE, + launchToken: proof.launchToken, + payload: { state: 'working', agentType: 'omp' } + }, + null + ) + const accepted = await dispatcher.dispatch( + request('orchestration.runUse', params, proof, 'marker-with-hook') + ) + expect(accepted).toMatchObject({ + ok: true, + result: { + run: { id: harness.adoptedRunId, coordinator_handle: CURRENT_COORDINATOR_HANDLE } + } + }) + runtime.endLaunch() + runtime.bindLaunch('fresh-launch-token') + const freshProof = { ...proof, launchToken: 'fresh-launch-token' } + expect(runtime.verifyOrchestrationCompatibilityCaller(freshProof)).toBeNull() + expect( + runtime.verifyOrchestrationCompatibilityCaller(freshProof, { + currentRuntimeLaunchSufficient: true + }) + ).toMatchObject({ + terminalHandle: CURRENT_COORDINATOR_HANDLE, + paneKey: CURRENT_COORDINATOR_PANE + }) + } + ) + it('accepts a fresh current agent before its first hook observation', async () => { const harness = createHarness() const hookServer = new AgentHookServer() diff --git a/src/main/runtime/runtime-agent-hook-retirement-fence.ts b/src/main/runtime/runtime-agent-hook-retirement-fence.ts new file mode 100644 index 000000000000..da4c5b5108d6 --- /dev/null +++ b/src/main/runtime/runtime-agent-hook-retirement-fence.ts @@ -0,0 +1,50 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' + +function sameAgentHookObservation( + before: AgentStatusIpcPayload, + after: AgentStatusIpcPayload +): boolean { + return ( + before.paneKey === after.paneKey && + before.terminalHandle === after.terminalHandle && + before.launchToken === after.launchToken && + before.providerSession?.key === after.providerSession?.key && + before.providerSession?.id === after.providerSession?.id && + before.receivedAt === after.receivedAt && + before.evidenceObservedAt === after.evidenceObservedAt && + before.state === after.state && + before.observation?.authorityId === after.observation?.authorityId && + before.observation?.incarnation === after.observation?.incarnation && + before.observation?.revision === after.observation?.revision + ) +} + +function readAgentHookRowsForPaneKeys( + paneKeys: ReadonlySet, + readRowsForPane: ((paneKey: string) => AgentStatusIpcPayload[]) | undefined, + readSnapshot: (() => AgentStatusIpcPayload[]) | undefined +): AgentStatusIpcPayload[] { + if (readRowsForPane) { + const rows: AgentStatusIpcPayload[] = [] + for (const paneKey of paneKeys) { + rows.push(...readRowsForPane(paneKey)) + } + return rows + } + return (readSnapshot?.() ?? []).filter((row) => paneKeys.has(row.paneKey)) +} + +export function captureAgentHookRetirementFence( + paneKeys: ReadonlySet, + readRowsForPane: ((paneKey: string) => AgentStatusIpcPayload[]) | undefined, + readSnapshot: (() => AgentStatusIpcPayload[]) | undefined +): () => boolean { + const before = readAgentHookRowsForPaneKeys(paneKeys, readRowsForPane, readSnapshot) + return () => { + const after = readAgentHookRowsForPaneKeys(paneKeys, readRowsForPane, readSnapshot) + return ( + before.length === after.length && + before.every((row) => after.some((current) => sameAgentHookObservation(row, current))) + ) + } +} diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index a89fb3c366d1..5d62d648a05a 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -65,6 +65,7 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { launchConfig: SleepingAgentLaunchConfig | null launchToken: string | null launchIncarnationId: PtyIncarnationId | null + launchNeedsHookAttestation: boolean launchAgent: TuiAgent | null agentSessionOwners: AgentSessionOwnerBinding[] foregroundAgent: TuiAgent | null From e9ed8c4227de0bf529ea85d241bb378e01a256f9 Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Wed, 7 Oct 2026 13:50:25 +0530 Subject: [PATCH 09/10] Keep upstream formatting outside the OMP status fix --- src/main/codex/codex-structured-turn-steer.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index dfde08edafd1..3e82c80b81c3 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -214,9 +214,9 @@ describe('a Codex send made after Codex answered an earlier one, before it opene const sending = rig.send('client-2') expect(await settledWithin(sending)).toBe('held') const methods = () => - rig.codex.connections[0]!.calls.map(({ method }) => method).filter( - (method) => method !== 'model/list' && method !== 'config/read' - ) + rig.codex.connections[0]!.calls + .map(({ method }) => method) + .filter((method) => method !== 'model/list' && method !== 'config/read') expect(methods()).toEqual(['thread/start', 'turn/start']) return { ...rig, sending, methods } } From 7f77bd66dc86dc2b11ad32f3d4c752b281612ef6 Mon Sep 17 00:00:00 2001 From: Saurav M Hiremath Date: Thu, 15 Oct 2026 17:30:02 +0530 Subject: [PATCH 10/10] docs: record completed OMP agent status reproduction --- .../omp-completed-agent-stale-status.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/bug-reproductions/omp-completed-agent-stale-status.md diff --git a/docs/bug-reproductions/omp-completed-agent-stale-status.md b/docs/bug-reproductions/omp-completed-agent-stale-status.md new file mode 100644 index 000000000000..b31df55b2cef --- /dev/null +++ b/docs/bug-reproductions/omp-completed-agent-stale-status.md @@ -0,0 +1,78 @@ +# Completed OMP agents still show active in Orca + +Reported: 2026-10-01 +Status: Confirmed worker status mismatch; cause not established. No fix applied. +Context: Live status checks described as PR 24196 in this session. That PR number could not be resolved in the local fork, so no PR URL is claimed. +Build observed during the test: `1.4.214-local.1790834225853.65731454c1c6`. +Runtime: `696fd1e3-312f-4b8d-8d92-35f422dbe46e`. + +## User-visible failure + +The user supplied two screenshots and reported that the main agent and a worker still showed in progress after both had completed. The screenshots are in the conversation, not copied into this repository. They must remain part of the issue evidence; this file does not claim to attach them. + +Expected: After a turn and its background jobs complete, the agent status must show done. Sidebar and tab status must agree. Keeping an OMP process and terminal open must not by itself mean the agent is working. + +## Live evidence + +Run: `run_429bdaf8c24e`. + +| Worker | Task | Dispatch | Result | Live status | +| --- | --- | --- | --- | --- | +| A | `task_5b92fcefe26b` | `ctx_0fdb3ba46a37` | `succeeded`; dispatch `completed` | `unavailable`; liveness `unverifiable`, reason `missing_status`; activity `unknown` | +| B | `task_b8afdc8d5ccc` | `ctx_1320df23f4dc` | `succeeded`; dispatch `completed` | `fresh`; activity `done`; main-agent outcome `success` | + +Worker A terminal: `term_a2263d24-1d7b-4f28-bc6e-b41f22abfa2c`. +Worker B terminal: `term_0a4c7fb2-76e0-470c-bd48-e7ffe7703e2b`. + +Worker A's terminal screen showed: + +- `LIVE_READY_PASS` after `sleep 20` completed. +- `BACKGROUND_JOB_PENDING` after an asynchronous `sleep 60` started. +- The background job completion notice. +- `BACKGROUND_JOB_FINISHED`, followed by the input prompt. + +Thus, the worker had completed its later live probes as well as its original task. Terminal `status: running` means the OMP process remains open, not that it is still performing the task. + +`orca worktree ps --json` exposed only worker B in the worktree's `agents` list, with `state: done`. The main agent and worker A were absent. The worktree reported `status: working` and `workspaceStatus: in-progress`. + +Important limit: The main agent was running this investigation when that snapshot was taken. Its current working state is expected. That snapshot does not independently prove that the main agent was idle at the time of the user's screenshots. The reported stale main-agent status remains a user-observed failure; worker A's missing live status is independently confirmed. + +## Earlier hook-delivery probe + +A separate, now-closed diagnostic terminal completed a normal turn. Its canonical status-cache row was absent. `/new` restored its ready row, but the row was absent again after the next completed turn. The diagnostic extension reported HTTP `204` for `before_agent_start`, `agent_start`, `message_end`, and `agent_end`. + +This shows that an HTTP acknowledgement does not prove that Orca retained and displayed the status row. It does not identify the exact path that removed or rejected the row. + +## Source checks and cause limits + +- `src/main/runtime/runtime-hook-agent-row-selection.ts`: fresh explicit status selection excludes restored-unconfirmed, identity-only, and stale observations. +- `src/main/runtime/runtime-worktree-agent-rows.ts`: fresh non-done rows contribute active status; completed worker B alone does not account for an active agent row. +- `src/main/agent-hooks/server/server-status-disposition.ts`: pane retirement and launch authority can suppress or restart status admission. +- `src/main/agent-hooks/server/server-cleanup.ts`: status rows can be removed by dismissal or pane cleanup. + +[INFERENCE] The absent explicit status may leave another status source visible in the UI. Neither the removal path nor the UI fallback responsible for the screenshots has been established. Do not label this a renderer-only bug or a failed task. + +## Reproduction and verification commands + +1. Start two OMP workers through Orca. Complete both tasks and leave their terminals open. +2. In worker A, run a normal turn, then a background `sleep 60` turn. Let the job complete and the worker return to its input prompt. +3. Compare the terminal screen, worker result, live status, sidebar, and tab indicator. +4. Check whether a session reset restores the status row only until the next turn completes. + +Commands exercised during this investigation: + +```text +orca terminal read --terminal term_a2263d24-1d7b-4f28-bc6e-b41f22abfa2c --screen --json +orca orchestration worker-list --run run_429bdaf8c24e --json +orca worktree ps --json +``` + +No status was changed by hand. Both worker terminals remain retained for inspection. No implementation changes or test runs were made in this investigation. Earlier automated test passes do not disprove this live failure. + +## Fix acceptance checks + +- Both retained, completed workers expose explicit done status. +- The main agent shows done after its response ends; check it from a separate terminal to avoid starting a new main-agent turn. +- Sidebar and tab indicators agree with the explicit status. +- Normal turns, session reset, and background-job completion do not lose the live status row. +- Actual active work still shows working, and an open input dialog still shows waiting.