mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(agent-status): keep a running Codex turn on paired and mobile views while /side is shown (#33308)
* fix(agent-status): keep a running Codex turn on paired and mobile views while /side is shown Codex's terminal title follows the thread it displays, so opening /side idles the title while the main turn runs. The paired/mobile session projection read that idle title, newer than the main turn's last hook, as the turn ending and published a title-only done row with no prompt. A Codex row whose hooks report the main turn running now owns the title interval; the turn's end still arrives by hook or rollout. Co-Authored-By: Claude <noreply@anthropic.com> * fix(agent-status): keep a Codex main turn waiting on approval through /side on paired views Co-Authored-By: Claude <noreply@anthropic.com> * fix(agent-status): leave restored Codex rows to the existing title rules Co-Authored-By: Claude <noreply@anthropic.com> * fix(agent-status): stop a running Codex turn at a PTY process replacement A replacement process on the same PTY id invalidates its predecessor's title-interval evidence; record when that happened so a predecessor's running Codex hook cannot outlive it. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,24 +12,48 @@ import { terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-statu
|
||||
import type { HookLiveAgentRow } from './runtime-terminal-contracts'
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
|
||||
type HookTurnFacts = Pick<
|
||||
AgentStatusEntry,
|
||||
'state' | 'agentType' | 'mainAgent' | 'restoredUnconfirmed'
|
||||
>
|
||||
|
||||
function workingHookOwnsIdleTitleInterval(
|
||||
state: AgentStatusEntry['state'],
|
||||
status: HookTurnFacts,
|
||||
observedAt: number,
|
||||
pty: RuntimePtyWorktreeRecord | null
|
||||
): boolean {
|
||||
return (
|
||||
state === 'working' &&
|
||||
status.state === 'working' &&
|
||||
pty?.lastAgentStatus === 'idle' &&
|
||||
!terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) &&
|
||||
observedAt >
|
||||
Math.max(
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs ?? 0,
|
||||
pty.lastAgentStatusStartedAtEpochMs ?? 0
|
||||
) &&
|
||||
(codexHookReportsRunningTurn(status, observedAt, pty) ||
|
||||
observedAt >
|
||||
Math.max(
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs ?? 0,
|
||||
pty.lastAgentStatusStartedAtEpochMs ?? 0
|
||||
)) &&
|
||||
Date.now() - observedAt <= AGENT_STATUS_STALE_AFTER_MS
|
||||
)
|
||||
}
|
||||
|
||||
// Why: Codex's title follows the thread it displays, so a /side chat goes idle while the main
|
||||
// turn runs; its end arrives by hook or host rollout, and the stale bound covers older hosts.
|
||||
// A hook from before the PTY's current process describes that process's predecessor.
|
||||
function codexHookReportsRunningTurn(
|
||||
status: HookTurnFacts,
|
||||
observedAt: number,
|
||||
pty: RuntimePtyWorktreeRecord | null
|
||||
): boolean {
|
||||
const mainState = status.mainAgent?.state
|
||||
return (
|
||||
status.agentType === 'codex' &&
|
||||
status.restoredUnconfirmed !== true &&
|
||||
mainState !== undefined &&
|
||||
mainState !== 'done' &&
|
||||
observedAt > (pty?.providerGenerationResetAtEpochMs ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: only a completed turn's reply is final; mid-turn hook snapshots carry tool stdout.
|
||||
function completedTurnFields(
|
||||
status: AgentStatusEntry
|
||||
@@ -95,6 +119,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
return status
|
||||
}
|
||||
const richStatusCanOwnTitleInterval =
|
||||
codexHookReportsRunningTurn(status, agentStatusAuthorityObservedAt(status), pty) ||
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs === null ||
|
||||
agentStatusAuthorityObservedAt(status) > pty.lastAgentStatusRichInvalidatedAtEpochMs
|
||||
const titleEvidenceAt = pty.lastOscTitleEpochMs
|
||||
@@ -126,9 +151,7 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
(status.state === 'blocked' || status.state === 'waiting'))
|
||||
if (!titleConfirmsState) {
|
||||
// A repaint of the idle interval preceding this hook is not a new turn boundary.
|
||||
if (
|
||||
workingHookOwnsIdleTitleInterval(status.state, agentStatusAuthorityObservedAt(status), pty)
|
||||
) {
|
||||
if (workingHookOwnsIdleTitleInterval(status, agentStatusAuthorityObservedAt(status), pty)) {
|
||||
return status
|
||||
}
|
||||
if (
|
||||
@@ -277,9 +300,7 @@ export function resolveRuntimeHookLiveAgentRow(
|
||||
if (live.payload.interactivePrompt != null) {
|
||||
return live
|
||||
}
|
||||
if (
|
||||
workingHookOwnsIdleTitleInterval(live.payload.state, agentStatusAuthorityObservedAt(live), pty)
|
||||
) {
|
||||
if (workingHookOwnsIdleTitleInterval(live.payload, agentStatusAuthorityObservedAt(live), pty)) {
|
||||
return live
|
||||
}
|
||||
// This is the pane's only wall-clock title timestamp comparable to when the hook evidence
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../shared/agent-status-types'
|
||||
import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection'
|
||||
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import { makeTuiIdlePty } from './tui-idle-wait-test-harness'
|
||||
|
||||
// Shape seen on a paired host with Codex 0.164: the main turn's last hook (PreToolUse for a long
|
||||
// exec), then `/side` shows an idle side chat, so the title loses its spinner after the hook.
|
||||
function runningExec(hookAt: number, agentType: string): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'main task please',
|
||||
updatedAt: hookAt,
|
||||
stateStartedAt: hookAt,
|
||||
agentType,
|
||||
toolName: 'Bash',
|
||||
toolInput: 'sleep 60',
|
||||
mainAgent: { state: 'working', stateStartedAt: hookAt },
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
|
||||
function sideChatIdlePty(hookAt: number): RuntimePtyWorktreeRecord {
|
||||
return makeTuiIdlePty({
|
||||
worktreeId: 'wt-1',
|
||||
lastOscTitle: 'repo',
|
||||
lastOscTitleEpochMs: hookAt + 8_000,
|
||||
lastAgentStatus: 'idle',
|
||||
lastAgentStatusStartedAtEpochMs: hookAt + 8_000,
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: hookAt + 8_000
|
||||
})
|
||||
}
|
||||
|
||||
describe('paired/mobile Codex status while a /side chat is shown', () => {
|
||||
it('keeps the running main turn when the side chat idles the title', () => {
|
||||
const hookAt = Date.now() - 10_000
|
||||
const out = renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
runningExec(hookAt, 'codex'),
|
||||
sideChatIdlePty(hookAt)
|
||||
)
|
||||
expect(out).toMatchObject({ state: 'working', prompt: 'main task please' })
|
||||
})
|
||||
|
||||
it('keeps the main turn prompt once the title shows main working again', () => {
|
||||
const hookAt = Date.now() - 20_000
|
||||
const out = renewRuntimeMobileAgentStatusFromPtyTitle(runningExec(hookAt, 'codex'), {
|
||||
...sideChatIdlePty(hookAt),
|
||||
lastOscTitle: '⠋ repo',
|
||||
lastOscTitleEpochMs: hookAt + 15_000,
|
||||
lastAgentStatus: 'working',
|
||||
lastAgentStatusStartedAtEpochMs: hookAt + 15_000,
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: hookAt + 15_000
|
||||
})
|
||||
expect(out).toMatchObject({ state: 'working', prompt: 'main task please' })
|
||||
})
|
||||
|
||||
it('keeps a main turn waiting on approval and its prompt once the side chat closes', () => {
|
||||
const hookAt = Date.now() - 20_000
|
||||
const out = renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
{
|
||||
...runningExec(hookAt, 'codex'),
|
||||
state: 'waiting',
|
||||
mainAgent: { state: 'waiting', stateStartedAt: hookAt }
|
||||
},
|
||||
{
|
||||
...sideChatIdlePty(hookAt),
|
||||
lastOscTitle: '✋ repo',
|
||||
lastOscTitleEpochMs: hookAt + 15_000,
|
||||
lastAgentStatus: 'permission',
|
||||
lastAgentStatusStartedAtEpochMs: hookAt + 15_000,
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: hookAt + 15_000
|
||||
}
|
||||
)
|
||||
expect(out).toMatchObject({ state: 'waiting', prompt: 'main task please' })
|
||||
})
|
||||
|
||||
it('does not carry a running turn across a replacement process on the same PTY', () => {
|
||||
const hookAt = Date.now() - 20_000
|
||||
const out = renewRuntimeMobileAgentStatusFromPtyTitle(runningExec(hookAt, 'codex'), {
|
||||
...sideChatIdlePty(hookAt),
|
||||
lastOscTitle: null,
|
||||
lastOscTitleEpochMs: null,
|
||||
lastAgentStatus: null,
|
||||
lastAgentStatusStartedAtEpochMs: null,
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: hookAt + 5_000,
|
||||
providerGenerationResetAtEpochMs: hookAt + 5_000
|
||||
})
|
||||
expect(out).toBeNull()
|
||||
})
|
||||
|
||||
it('still lets an idle title end the turn of an agent whose title follows that turn', () => {
|
||||
const hookAt = Date.now() - 10_000
|
||||
const out = renewRuntimeMobileAgentStatusFromPtyTitle(
|
||||
runningExec(hookAt, 'claude'),
|
||||
sideChatIdlePty(hookAt)
|
||||
)
|
||||
expect(out?.state).toBe('done')
|
||||
})
|
||||
})
|
||||
@@ -102,6 +102,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
|
||||
} | null
|
||||
lastAgentStatusStartedAtEpochMs: number | null
|
||||
lastAgentStatusRichInvalidatedAtEpochMs: number | null
|
||||
/** When a replacement process took over this PTY id; earlier hooks describe its predecessor. */
|
||||
providerGenerationResetAtEpochMs?: number
|
||||
lastOscTitle: string | null
|
||||
lastOscTitleAt: number | null
|
||||
lastOscTitleEpochMs: number | null
|
||||
|
||||
@@ -328,6 +328,7 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit
|
||||
pty.lastAgentStatusObservedLive = false
|
||||
pty.lastAgentStatusStartedAtEpochMs = null
|
||||
pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now()
|
||||
pty.providerGenerationResetAtEpochMs = pty.lastAgentStatusRichInvalidatedAtEpochMs
|
||||
pty.managementTitle = null
|
||||
pty.managementTitleAt = null
|
||||
pty.waitBlockedAt = null
|
||||
|
||||
Reference in New Issue
Block a user