fix(orchestration): deliver the wake to a working coordinator; agents queue mid-turn input

The wake was typed only into a live idle pane, so a Claude lead whose program
status stayed working (teammate alive) never got it. Agents queue or steer text
typed mid-turn, so a pane takes mail when live idle or working; dialogs,
permission prompts, exited agents, unsettled panes and cold-parked Enter still
hold it. Drops the held-batch Enter deferral and the per-turn-end hook logic.
This commit is contained in:
Jinwoo-H
2026-10-28 04:01:54 -04:00
parent 7090a2b2cf
commit e0cb9a68d2
24 changed files with 148 additions and 340 deletions
@@ -64,14 +64,7 @@ export abstract class OrcaRuntimeWithAgentTransitions extends OrcaRuntimeWithMig
constructor(...args: ConstructorParameters<typeof OrcaRuntimeWithMigrationCatalog>) {
super(...args)
this.agentTransitions.subscribe((change) => {
this.terminalAgentPhonePush.observe(change)
// A hook row can end or reopen a turn under an unchanged program `working` report.
const ptyId = this.getPtyRecordForPaneKey(change.paneKey)?.ptyId
if (ptyId && this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity === 'working') {
this.reclassifyPtyStatus(ptyId)
}
})
this.agentTransitions.subscribe((change) => this.terminalAgentPhonePush.observe(change))
}
/** Attach after higher layers subscribe; also drive generated tab titles. */
@@ -77,7 +77,6 @@ describe('OrcaRuntimeService', () => {
coordinator_handle: terminal.handle,
coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}`
})
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
const message = db.insertMessage({
from: 'term_worker',
to: 'run:run_mailbox',
@@ -182,104 +181,6 @@ describe('OrcaRuntimeService', () => {
}
})
it('repoints on the retry edge when live idle won the send race', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const write = vi.fn().mockReturnValue(true)
setInMemoryOrchestrationMessages(runtime, db)
runtime.setPtyController({
write,
writeWithSettlement: settledWriteStub(write),
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
bindSinglePtyRun(db, terminal.handle)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
db.insertMessage({
from: 'term_worker',
to: terminal.handle,
subject: 'settled at idle edge',
type: 'worker_done'
})
runtime.notifyMessageArrived(terminal.handle, 'worker_done')
await Promise.resolve()
expect(write).not.toHaveBeenCalled()
const leaf = [
...(
runtime as unknown as {
leaves: Map<
string,
{
lastAgentStatus: string | null
lastAgentStatusObservedLive: boolean
}
>
}
).leaves.values()
][0]
leaf.lastAgentStatus = 'idle'
leaf.lastAgentStatusObservedLive = true
await vi.advanceTimersByTimeAsync(2_000)
expect(write).toHaveBeenCalledWith(
'pty-1',
expect.stringContaining('You have 1 orchestration message')
)
db.close()
} finally {
vi.useRealTimers()
}
})
it('leaves later delivery to the idle edge instead of polling a working mailbox', async () => {
vi.useFakeTimers()
try {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const pendingReads = vi.spyOn(db, 'getUndeliveredUnreadMessages')
const write = vi.fn().mockReturnValue(true)
setInMemoryOrchestrationMessages(runtime, db)
runtime.setPtyController({
write,
writeWithSettlement: settledWriteStub(write),
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
bindSinglePtyRun(db, terminal.handle)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
db.insertMessage({
from: 'term_worker',
to: terminal.handle,
subject: 'wait for idle'
})
runtime.notifyMessageArrived(terminal.handle, 'status')
await Promise.resolve()
await vi.advanceTimersByTimeAsync(20_000)
expect(pendingReads).not.toHaveBeenCalled()
expect(pendingMailPointerRepoints(runtime)).toBe(0)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
expect(pendingReads).toHaveBeenCalledTimes(1)
expect(write).toHaveBeenCalledWith(
'pty-1',
expect.stringContaining('You have 1 orchestration message')
)
db.close()
} finally {
vi.useRealTimers()
}
})
it('points restored mail when a live-idle PTY remounts after the repair edge', async () => {
vi.useFakeTimers()
try {
@@ -477,7 +378,7 @@ describe('OrcaRuntimeService', () => {
db.close()
})
it('does not inject pending mail on notify when the recipient is still working', async () => {
it('does not inject pending mail on notify when the recipient awaits permission', async () => {
const runtime = new OrcaRuntimeService(store)
const db = new InMemoryOrchestrationMessages()
const write = vi.fn().mockReturnValue(true)
@@ -492,11 +393,11 @@ describe('OrcaRuntimeService', () => {
const [terminal] = (await runtime.listTerminals()).terminals
bindSinglePtyRun(db, terminal.handle)
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', '\x1b]0;Codex waiting for permission\x07', 100)
const message = db.insertMessage({
from: 'sender',
to: terminal.handle,
subject: 'while working'
subject: 'while asking'
})
write.mockClear()
@@ -504,7 +405,7 @@ describe('OrcaRuntimeService', () => {
await Promise.resolve()
expect(write).not.toHaveBeenCalled()
// Busy rows must remain undelivered for later idle; stamping without writing would lose delivery (#12584).
// Held rows must remain undelivered for a later edge; stamping without writing would lose delivery (#12584).
expect(message.delivered_at).toBeNull()
db.close()
})
@@ -72,17 +72,12 @@ describe('headless orchestration mailbox pointer', () => {
db.close()
})
it('points a coordinator at mail that arrived while it worked once it goes idle', async () => {
it('points a coordinator at mail that arrives mid-turn', async () => {
const { db, runtime, write, sendRunMail } = await coordinator()
runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1)
await runtime.serializeMainTerminalBuffer(PTY_ID)
const message = sendRunMail()
await vi.advanceTimersByTimeAsync(0)
expect(pointerCount(write)).toBe(0)
runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2)
await runtime.serializeMainTerminalBuffer(PTY_ID)
await vi.advanceTimersByTimeAsync(0)
expect(pointerCount(write)).toBe(1)
await vi.advanceTimersByTimeAsync(500)
expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String))
@@ -92,8 +87,7 @@ describe('headless orchestration mailbox pointer', () => {
it('waits for a name-only idle pane to go quiet before typing the pointer', async () => {
const { db, runtime, write, sendRunMail } = await coordinator()
sendRunMail()
runtime.onPtyData(PTY_ID, '\x1b]0;⠋ Codex\x07working\n', Date.now())
runtime.onPtyData(PTY_ID, '\x1b]0;Codex\x07still working\n', Date.now())
runtime.onPtyData(PTY_ID, '\x1b]0;Codex\x07output\n', Date.now())
await runtime.serializeMainTerminalBuffer(PTY_ID)
expect(pointerCount(write)).toBe(0)
@@ -102,6 +96,23 @@ describe('headless orchestration mailbox pointer', () => {
db.close()
})
// A quiet retry armed before the prompt opened must not type into it on the retained working report.
it('does not type into a pane that turned to a permission prompt after a retry was armed', async () => {
const { db, runtime, write, sendRunMail } = await coordinator()
runtime.onPtyData(PTY_ID, '\x1b]0;Codex\x07output\n', Date.now())
runtime.onPtyData(
PTY_ID,
'\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex waiting for permission\x07',
Date.now()
)
await runtime.serializeMainTerminalBuffer(PTY_ID)
sendRunMail()
await vi.advanceTimersByTimeAsync(4_000)
expect(write).not.toHaveBeenCalled()
db.close()
})
it('types into no PTY while two live PTYs claim the coordinator pane', async () => {
const { db, runtime, write, sendRunMail } = await coordinator()
runtime.registerPty('pty-mailbox-replacement', WORKTREE_ID, null, {
@@ -148,13 +148,13 @@ describe('orchestration detached mailbox routing', () => {
db.close()
})
it('uses bounded ownership lookup for direct arrivals while the agent is working', async () => {
it('uses bounded ownership lookup for direct arrivals while the agent cannot take input', async () => {
vi.useFakeTimers()
const db = createDatabase('orca-mailbox-bounded-')
const harness = createRuntime(db)
const run = createBoundRun(db, 'Working Run')
const run = createBoundRun(db, 'Permission Run')
await harness.runtime.listTerminals()
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1)
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex needs permission\x07', 1)
const fullMailboxScan = vi.spyOn(db, 'getUndeliveredUnreadMessages')
const message = insertDirectRunMessage(db, run.id, 'Direct coordinator status')
@@ -856,14 +856,10 @@ describe('orchestration notification mailbox consistency', () => {
`after delivery ${firstDelivery.deliveryId}, which you have not acknowledged. If you no longer have it, run \`${localOrchestrationCliCommand()} orchestration check --run ${run.id}\` to see it again; once it is handled, run \`${localOrchestrationCliCommand()} orchestration check --run ${run.id} --ack ${firstDelivery.deliveryId}\``
)
// A turn that starts inside the Enter delay keeps the reminder until it ends.
// A turn that starts inside the Enter delay still takes the reminder; the agent queues it.
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 3)
await harness.runtime.serializeMainTerminalBuffer(PTY_ID)
await vi.advanceTimersByTimeAsync(500)
expect(enters()).toBe(0)
harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 4)
await harness.runtime.serializeMainTerminalBuffer(PTY_ID)
await vi.advanceTimersByTimeAsync(500)
expect(enters()).toBe(1)
expect(db.getMessageById(newerMessage.id)?.delivered_at).toEqual(expect.any(String))
@@ -245,9 +245,8 @@ export function registerSecondPane(
export async function driveToLiveIdle(runtime: OrcaRuntimeService): Promise<void> {
await runtime.listTerminals()
runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1)
runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2)
// Await the model's write chain so both chunks are parsed.
runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 1)
// Await the model's write chain so the chunk is parsed.
await runtime.serializeMainTerminalBuffer(PTY_ID)
}
@@ -112,9 +112,8 @@ function createRuntime(
async function driveToLiveIdle(runtime: OrcaRuntimeService): Promise<void> {
await runtime.listTerminals()
runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1)
runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2)
// Await the model's write chain so both chunks are parsed.
runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 1)
// Await the model's write chain so the chunk is parsed.
await runtime.serializeMainTerminalBuffer(PTY_ID)
}
@@ -12,6 +12,17 @@ export type OrchestrationMailboxLeaf = {
paneTitle?: string | null
}
/** An agent at rest or mid-turn takes typed mail: agents queue or steer text sent during a turn. */
export function agentStatusTakesTypedInput(status: AgentStatus | null): boolean {
return status === 'idle' || status === 'working'
}
export function isMailboxLeafTakingInput(
leaf: Pick<OrchestrationMailboxLeaf, 'lastAgentStatus' | 'lastAgentStatusObservedLive'>
): boolean {
return leaf.lastAgentStatusObservedLive && agentStatusTakesTypedInput(leaf.lastAgentStatus)
}
export type RoutedOrchestrationMailbox = {
mailboxHandle: string
types: string[]
@@ -22,7 +22,7 @@ export type PointerDeliveryDependencies<TWaiter extends OrchestrationMessageWait
getLeaf: (leafKey: string) => OrchestrationMailboxLeaf | undefined
getLeafKey: (tabId: string, leafId: string) => string
getLiveLeafForHandle: (handle: string) => OrchestrationMailboxLeaf
/** Whether the pane is settled enough to type the pointer plus Enter into it. */
/** Whether the pane may take the pointer plus Enter now: at rest, or mid-turn (input queues). */
isAgentSettledForDelivery: (leaf: OrchestrationMailboxLeaf) => boolean
isPtyAwaitingUserInput: (ptyId: string) => boolean
getMessageWaiters: (mailboxHandle: string) => ReadonlySet<TWaiter> | undefined
@@ -4,7 +4,7 @@ import {
selectOrchestrationPointerBatch,
type OrchestrationMessageWaiter
} from './mailbox-pointer-eligibility'
import type { OrchestrationMailboxLeaf } from './mailbox-owner'
import { isMailboxLeafTakingInput, type OrchestrationMailboxLeaf } from './mailbox-owner'
import {
OrchestrationMailboxPointerState,
type OrchestrationMailboxDeliveryFlight
@@ -39,7 +39,8 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
}
try {
const leaf = this.deps.getLiveLeafForHandle(terminalHandle)
if (leaf.lastAgentStatus !== 'idle' || !leaf.lastAgentStatusObservedLive) {
// Skip ownership resolution, and the reconciliation it schedules, for a pane deliver() would refuse.
if (!isMailboxLeafTakingInput(leaf)) {
return
}
const mailboxHandle = this.deps.mailboxOwner.resolve(leaf, handle)
@@ -67,13 +68,17 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
if (!this.deps.getTerminalHandleForLeafKey(this.leafKey(leaf))) {
return
}
// Gate settled-agent input at this shared commit point; refusal parks for recheck instead of dropping mail.
if (!this.deps.isAgentSettledForDelivery(leaf)) {
// Gate live, settled input at this shared commit point; refusal parks for recheck instead of dropping mail.
if (!isMailboxLeafTakingInput(leaf) || !this.deps.isAgentSettledForDelivery(leaf)) {
this.parkRedelivery(mailboxHandle, options.reservedTypes)
return
}
if (leaf.ptyId) {
const deferredEnter = this.state.takeDeferredEnter(leaf.ptyId)
// As in observeAgentWorking: a cold-parked pane's deferred Enter waits for rest.
const deferredEnter =
this.coldParkedPtys.has(leaf.ptyId) && leaf.lastAgentStatus !== 'idle'
? null
: this.state.takeDeferredEnter(leaf.ptyId)
if (deferredEnter) {
this.state.parkDelivery(leaf.ptyId, mailboxHandle, leaf, options.reservedTypes)
deferredEnter()
@@ -224,11 +229,7 @@ export class OrchestrationMailboxPointerDelivery<TWaiter extends OrchestrationMe
mailboxHandle: string
): void {
const currentLeaf = this.deps.getLeaf(this.leafKey(leaf))
if (
currentLeaf?.ptyId === ptyId &&
currentLeaf.lastAgentStatus === 'idle' &&
currentLeaf.lastAgentStatusObservedLive
) {
if (currentLeaf?.ptyId === ptyId) {
this.deliver(currentLeaf, { mailboxHandle, skipAbsenceProbe: true })
}
}
@@ -27,7 +27,7 @@ export function hasUnfilteredOrchestrationWaiter(
export type OrchestrationPointerBatch = {
messages: MessageRow[]
/** The consumer's unacked batch; set means the nudge waits for its idle edge and names it. */
/** The consumer's unacked batch, named in the reminder. */
heldDeliveryId: string | undefined
}
@@ -165,7 +165,6 @@ function finishPointerWriteAndStageEnter<TWaiter extends OrchestrationMessageWai
leaf: args.leaf,
mailboxHandle: args.mailboxHandle,
messages: args.messages,
heldDeliveryId: args.heldDeliveryId,
newestSequence: args.newestSequence,
ptyId,
flight,
@@ -7,7 +7,11 @@ import {
shouldReleaseOrchestrationPointer,
type OrchestrationMessageWaiter
} from './mailbox-pointer-eligibility'
import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner'
import {
isMailboxLeafTakingInput,
type OrchestrationMailboxLeaf,
type OrchestrationMailboxOwner
} from './mailbox-owner'
import type {
OrchestrationMailboxDeliveryFlight,
OrchestrationMailboxPointerState
@@ -42,7 +46,6 @@ export function submitOrchestrationMailboxPointer<TWaiter extends OrchestrationM
leaf: OrchestrationMailboxLeaf
mailboxHandle: string
messages: readonly { id: string; type: string }[]
heldDeliveryId?: string
newestSequence: number
ptyId: string
flight: OrchestrationMailboxDeliveryFlight
@@ -84,18 +87,13 @@ export function submitOrchestrationMailboxPointer<TWaiter extends OrchestrationM
deps.mailboxOwner.resolve(exactTarget.leaf, undefined, {
terminalHandle: exactTarget.terminalHandle
}) === input.mailboxHandle
const queueSafe =
exactTarget?.leaf.lastAgentStatusObservedLive === true &&
(exactTarget.leaf.lastAgentStatus === 'idle' ||
exactTarget.leaf.lastAgentStatus === 'working')
const queueSafe = !!exactTarget && isMailboxLeafTakingInput(exactTarget.leaf)
if (!exactTarget?.leaf.writable || !sameMailbox) {
clearAndRedrive = true
} else if (
(exactTarget.leaf.lastAgentStatusObservedLive &&
exactTarget.leaf.lastAgentStatus === null) ||
deps.isPtyAwaitingUserInput(input.ptyId) ||
// As in chat: a mid-turn Enter would spend a held batch's newer mail's only nudge.
(input.heldDeliveryId !== undefined && exactTarget.leaf.lastAgentStatus === 'working')
deps.isPtyAwaitingUserInput(input.ptyId)
) {
// A neutral title can outlive the foreground check, and an open dialog would take the
// Enter as its answer; no Enter has been attempted yet.
@@ -9,6 +9,7 @@ import { HEADLESS_RUNTIME_WINDOW_ID } from '../../../shared/runtime-types'
import type { RuntimeLeafRecord } from '../runtime-terminal-state-records'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { isMailboxLeafTakingInput } from '../orchestration/mailbox-owner'
type Graph = RuntimeSyncWindowGraph
@@ -302,15 +303,13 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta
const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (
this._orchestrationDb &&
leaf.lastAgentStatus === 'idle' &&
leaf.lastAgentStatusObservedLive &&
isMailboxLeafTakingInput(leaf) &&
this.checkDeliverySettledAndArmRecheck(leaf) &&
leaf.writable &&
(!graphWasReady ||
previousLeaf?.ptyId !== leaf.ptyId ||
!previousLeaf.writable ||
previousLeaf.lastAgentStatus !== 'idle' ||
!previousLeaf.lastAgentStatusObservedLive)
!isMailboxLeafTakingInput(previousLeaf))
) {
this.deliverPendingMessagesForLeaf(leaf)
}
@@ -22,7 +22,7 @@ describe('stale-working title clear and completion recovery', () => {
})
// Synthetic: Codex's done hook, then a final native spinner that only the timer clears (#19243).
it('delivers mail once the clear confirms a live Codex behind a fresh done hook', async () => {
it('delivers mail to a live Codex behind a fresh done hook and a stale spinner', async () => {
vi.useFakeTimers()
const db = createDatabase('orca-stale-title-completion-')
const hook: AgentStatusIpcPayload = {
@@ -50,9 +50,7 @@ describe('stale-working title clear and completion recovery', () => {
runtime.onPtyData(PTY_ID, 'composer repaint without a title', 2)
insertDirectRunMessage(db, run.id, 'Worker progress')
runtime.notifyMessageArrived(`run:${run.id}`, 'status')
await vi.advanceTimersByTimeAsync(1_000)
expect(write).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(4_000)
await vi.advanceTimersByTimeAsync(5_000)
expect(write.mock.calls.map(([, data]) => data)).toEqual([
expect.stringContaining('You have 1 orchestration message'),
'\r'
@@ -551,7 +551,11 @@ describe('terminal wait ownership', () => {
})
it('rechecks current leaf evidence before delivery and coalesces repeated refusals', async () => {
let leaf = makeTuiIdleLeaf({ lastOutputAt: Date.now(), lastAgentStatus: 'working' })
let leaf = makeTuiIdleLeaf({
lastOutputAt: Date.now(),
lastAgentStatus: 'idle',
lastOscTitle: 'Codex'
})
const deliver = vi.fn()
const runtime = new OrcaRuntimeService()
const key = runtime['getLeafKey'](leaf.tabId, leaf.leafId)
@@ -569,7 +573,11 @@ describe('terminal wait ownership', () => {
})
it('cancels only the delivery recheck belonging to its runtime', async () => {
const leaf = makeTuiIdleLeaf({ lastOutputAt: Date.now(), lastAgentStatus: 'working' })
const leaf = makeTuiIdleLeaf({
lastOutputAt: Date.now(),
lastAgentStatus: 'idle',
lastOscTitle: 'Codex'
})
const first = new OrcaRuntimeService()
const second = new OrcaRuntimeService()
const key = first['getLeafKey'](leaf.tabId, leaf.leafId)
@@ -1,4 +1,4 @@
import { OrcaRuntimeWithApplyPtyProgramActivity } from './terminal-program-activity'
import { OrcaRuntimeWithApplyTrackedPtyTitle } from './terminal-title-observation'
import type {
RuntimePtyTitleTrackerEntry,
RuntimePtyWorktreeRecord
@@ -28,7 +28,7 @@ type TerminalAgentStatusTarget = Pick<
'source' | 'paneKey' | 'tabId' | 'worktreeId' | 'connectionId' | 'terminalHandle'
>
export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyPtyProgramActivity {
export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle {
abstract getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined
/** Each live OSC 7501 program's latest row payload; renewed while its PTY keeps a live facet. */
@@ -1,39 +0,0 @@
import type { AgentStatus } from '../../../../shared/agent-detection'
import { OrcaRuntimeWithApplyTrackedPtyTitle } from './terminal-title-observation'
/** The program's OSC 7501 activity, and re-classifying the PTY's status when it or a hook row moves. */
export abstract class OrcaRuntimeWithApplyPtyProgramActivity extends OrcaRuntimeWithApplyTrackedPtyTitle {
/** Re-classifies the PTY under new program activity; on null the title's own glyph decides again. */
protected applyPtyProgramActivity(ptyId: string, activity: AgentStatus | null): void {
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
if (!entry) {
return
}
entry.programActivity = activity
this.observeClaudeTerminalEvidence(ptyId, { kind: 'activity', status: activity })
const pty = this.ptysById.get(ptyId)
if (activity === null && pty?.lastExplicitAgentStatus?.programAtRest) {
// The at-rest claim was the program's; a later agent in this pane must earn its own.
const { programAtRest: _ended, ...status } = pty.lastExplicitAgentStatus
pty.lastExplicitAgentStatus = status
}
this.reclassifyPtyStatus(ptyId)
}
/** Re-offers the last title's status, e.g. when a hook row ends a turn under program `working`. */
protected reclassifyPtyStatus(ptyId: string): void {
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
if (!entry?.lastTitle) {
return
}
// A stale-spinner clear stands for the title it replaced, so a dead spinner is not revived.
const raw = this.ptysById.get(ptyId)?.titleDisplayClear?.title ?? entry.lastTitle.raw
const changed = this.applyTrackedPtyTitle(ptyId, raw, entry.lastTitle.normalized, undefined, {
statusOnly: true
})
// The chunk's session-tab fan-out carries the new status to mobile.
if (changed && entry.applyingChunk) {
entry.chunkTouchedSessionTabs = true
}
}
}
@@ -10,6 +10,7 @@ import { terminalTitleBlocksExplicitAgentStatus } from '../../runtime-worktree-s
import type { ClaudeTerminalEvidence } from '../../../../shared/claude-terminal-interrupt'
import type { OrchestrationMailboxLeaf } from '../../orchestration/mailbox-owner'
import type { PtyLaneMailboxTarget } from '../query/pty-terminal-summary'
import { agentStatusTakesTypedInput } from '../../orchestration/mailbox-owner'
export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnpersistedTrackedTitleForPty {
protected abstract collectPaneKeysForPty(ptyId: string): Set<string>
@@ -60,15 +61,39 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit
}
/** The PTY's agent status under this title: the program's own OSC 7501 activity while it
* reports one, else what the title glyph says. A turn end that outlasts a `working` report is
* rest: the program then reports work beyond the turn, such as live teammates. */
* reports one, else what the title glyph says. */
protected classifyPtyTitleStatus(ptyId: string, rawTitle: string): AgentStatus | null {
const activity = this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity
const agent = activity === 'working' ? this.getPaneAgentForTuiIdle(ptyId) : null
if (agent && this.readTuiIdleHookTurnForPty(ptyId, agent)?.endedUnderProgramWork) {
return 'idle'
return (
this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity ??
detectAgentStatusFromTitle(rawTitle)
)
}
/** Re-classifies the PTY under new program activity; on null the title's own glyph decides again. */
protected applyPtyProgramActivity(ptyId: string, activity: AgentStatus | null): void {
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
if (!entry) {
return
}
entry.programActivity = activity
this.observeClaudeTerminalEvidence(ptyId, { kind: 'activity', status: activity })
const pty = this.ptysById.get(ptyId)
if (activity === null && pty?.lastExplicitAgentStatus?.programAtRest) {
// The at-rest claim was the program's; a later agent in this pane must earn its own.
const { programAtRest: _ended, ...status } = pty.lastExplicitAgentStatus
pty.lastExplicitAgentStatus = status
}
if (entry.lastTitle) {
// A stale-spinner clear stands for the title it replaced, so a dead spinner is not revived.
const raw = pty?.titleDisplayClear?.title ?? entry.lastTitle.raw
const changed = this.applyTrackedPtyTitle(ptyId, raw, entry.lastTitle.normalized, undefined, {
statusOnly: true
})
// The chunk's session-tab fan-out carries the new status to mobile.
if (changed && entry.applyingChunk) {
entry.chunkTouchedSessionTabs = true
}
}
return activity ?? detectAgentStatusFromTitle(rawTitle)
}
/** Apply one observed OSC title (raw form) to the PTY and leaf records.
@@ -98,17 +123,17 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit
// Why before retiring the clear: the lifecycle counts a turn from the status the clear showed.
this.recordAgentPromptLifecycleState(ptyId, agentStatus)
let ptyRecordChanged = false
let ptyIdleEdge = false
let ptyInputEdge = false
const pty = this.ptysById.get(ptyId)
if (pty) {
const prevStatus = pty.lastAgentStatus
const prevTitle = pty.lastOscTitle
// Same delivery edge as the leaf branch below: live idle, or an idle title that changed.
ptyIdleEdge =
agentStatus === 'idle' &&
(prevStatus !== 'idle' ||
// Same delivery edge as the leaf branch below: live input-taking status, or an idle title that changed.
ptyInputEdge =
agentStatusTakesTypedInput(agentStatus) &&
(prevStatus !== agentStatus ||
!pty.lastAgentStatusObservedLive ||
(!statusOnly && prevTitle !== recordedTitle))
(agentStatus === 'idle' && !statusOnly && prevTitle !== recordedTitle))
const observedAt = this.nextTitleObservationSequence()
const observedAtEpochMs = identityOnlyTitle ? null : Date.now()
if (!statusOnly) {
@@ -189,17 +214,20 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit
) {
this.resolveTuiIdleWaiters(leaf)
}
// First live idle after seed/respawn authorizes delivery; later explicit-ready titles must also re-offer idle-to-idle upgrades (#12536).
// Mail goes to a pane taking input, at rest or mid-turn, so offer it when the pane starts taking
// input (first live status after seed/respawn included); idle titles re-offer upgrades (#12536).
if (
agentStatus === 'idle' &&
(prevStatus !== 'idle' || !prevObservedLive || leafTitleChanged) &&
agentStatusTakesTypedInput(agentStatus) &&
(prevStatus !== agentStatus ||
!prevObservedLive ||
(agentStatus === 'idle' && leafTitleChanged)) &&
this.checkDeliverySettledAndArmRecheck(leaf)
) {
this.deliverPendingMessagesForLeaf(leaf)
}
}
// A pane no window publishes (headless serve) has no leaf above; its PTY is the mailbox target.
const lane = ptyIdleEdge ? this.getPtyLaneMailboxTargetForPty(pty) : null
const lane = ptyInputEdge ? this.getPtyLaneMailboxTargetForPty(pty) : null
if (lane && this.checkDeliverySettledAndArmRecheck(lane.leaf)) {
this.deliverPendingMessagesForLeaf(lane.leaf)
}
@@ -200,7 +200,6 @@ export abstract class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission
launchToken: pty.launchToken,
titleObservedAtEpochMs: pty.lastOscTitleEpochMs,
hasExplicitIdleTitle: hasExplicitIdleTitle(pty),
programWorking: this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity === 'working',
respawnedAt: this.agentPromptExplicitStatusFloorByPtyId.get(ptyId),
lastInputAt: this.terminalRunFacts.readLastInputAt(ptyId),
tailIncludes: (text) => retainedTailIncludes(pty.tailBuffer, pty.tailPartialLine, text),
@@ -187,7 +187,9 @@ export abstract class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithB
this.deliveryRecheckTimersByLeafKey.set(leafKey, timer)
}
/** Pointer plus Enter requires the same readiness ranking as waits; name-only titles can occur mid-turn (#6011). Callers separately require live status evidence. */
/** Pointer plus Enter takes the waits' readiness ranking, plus a working verdict: agents queue or
* steer text sent mid-turn, so only dialogs, drafts and unsettled panes hold mail. Callers
* separately require live status evidence. */
protected isAgentSettledForDelivery(leaf: { tabId: string; leafId: string }): boolean {
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
const live = this.leaves.get(leafKey)
@@ -208,9 +210,11 @@ export abstract class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithB
}
// Why no blocked reader: a refusal here re-arms a recheck that a lingering tail prompt
// would spin, so delivery keeps its own, blocked-blind, reading of the same ranking.
return isTuiIdleReadyVerdict(
evaluateTuiIdle({ ...evidence, readTailBlockedReason: () => null })
)
const verdict = evaluateTuiIdle({ ...evidence, readTailBlockedReason: () => null })
// The ranking vetoes overlays only on ready verdicts; an overlay refuses input mid-turn too.
return verdict.kind === 'working'
? evidence.readScreenInputVeto() !== true
: isTuiIdleReadyVerdict(verdict)
}
/** An unsent draft would be submitted with the injected text; the cap bounds an abandoned or misread one. */
@@ -16,8 +16,6 @@ const PTY_ID = 'pty-followups'
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
const osc = (title: string) => `${ESC}]0;${title}${BEL}`
const agentStatus = (state: string, agentType: string) =>
`${ESC}]9999;{"state":"${state}","agentType":"${agentType}"}${BEL}`
const GRAPH: RuntimeSyncWindowGraph = {
tabs: [
@@ -73,30 +71,26 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => {
vi.useRealTimers()
})
it('does not deliver into a pane that is only showing its agent name mid-turn', async () => {
// Agents queue or steer text typed mid-turn, so a busy pane takes mail without waiting for rest.
it('delivers into a pane mid-turn', async () => {
const { runtime } = await makeRuntime('codex')
const deliver = watchDelivery(runtime)
runtime.onPtyData(PTY_ID, `${osc('⠋ Codex')}working\n`, Date.now())
expect(deliver).not.toHaveBeenCalled()
// Name-only Codex means idle for display even mid-turn; delivery must not type into that evidence alone.
runtime.onPtyData(PTY_ID, `${osc('Codex')}still working\n`, Date.now())
expect(deliver).not.toHaveBeenCalled()
expect(deliver).toHaveBeenCalled()
})
it('delivers once the agent states it is done', async () => {
it('does not deliver into a name-only pane that has not gone quiet', async () => {
const { runtime } = await makeRuntime('codex')
const deliver = watchDelivery(runtime)
runtime.onPtyData(PTY_ID, `${osc('⠋ Codex')}working\n`, Date.now())
runtime.onPtyData(PTY_ID, `${osc('Codex ready')}done\n`, Date.now())
expect(deliver).toHaveBeenCalled()
// Name-only Codex is weak idle evidence, which a shell auto-title can also write before the TUI mounts.
runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now())
expect(deliver).not.toHaveBeenCalled()
})
// Time can lift weak-evidence refusal without any further edge; delivery needs a retry to avoid stranded messages.
it('retries a refused delivery once the pane falls quiet', async () => {
const { runtime } = await makeRuntime('codex')
const deliver = watchDelivery(runtime)
runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now())
runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now())
expect(deliver).not.toHaveBeenCalled()
@@ -117,11 +111,10 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => {
expect(deliver).not.toHaveBeenCalled()
})
// A name-only frame consumes working-to-idle; later explicit readiness must still trigger delivery.
// A name-only frame consumes the idle edge; later explicit readiness must still trigger delivery.
it('delivers when the ready title arrives after a name-only frame', async () => {
const { runtime } = await makeRuntime('codex')
const deliver = watchDelivery(runtime)
runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now())
runtime.onPtyData(PTY_ID, `${osc('Codex')}out\n`, Date.now())
expect(deliver).not.toHaveBeenCalled()
@@ -131,27 +124,9 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => {
expect(deliver).toHaveBeenCalled()
})
// First-party done must re-offer delivery after working vetoed idle, without waiting for status expiry.
it('delivers when a done status lands after the idle title was vetoed', async () => {
const { runtime } = await makeRuntime('claude')
const deliver = watchDelivery(runtime)
runtime.onPtyData(
PTY_ID,
`${agentStatus('working', 'claude')}${osc('\u280b Claude')}w\n`,
Date.now()
)
runtime.onPtyData(PTY_ID, `${osc('claude')}out\n`, Date.now())
expect(deliver).not.toHaveBeenCalled()
runtime.onPtyData(PTY_ID, agentStatus('done', 'claude'), Date.now())
await vi.advanceTimersByTimeAsync(4_500)
expect(deliver).toHaveBeenCalled()
})
it('still delivers for an agent whose name is its only rest signal', async () => {
const { runtime } = await makeRuntime('grok', 'grok')
const deliver = watchDelivery(runtime)
runtime.onPtyData(PTY_ID, `${osc('⠋ Grok')}working\n`, Date.now())
runtime.onPtyData(PTY_ID, `${osc('grok')}banner\n`, Date.now())
expect(deliver).toHaveBeenCalled()
})
@@ -178,35 +178,6 @@ describe('readTuiIdleHookTurn', () => {
).toEqual({ state: 'permission', blockedReason: null })
})
it("marks a live turn end under program work, and joins Agent Teams to claude's row", () => {
const transition = (kind: 'transition' | 'snapshot', launchToken = 'launch-1') =>
row({
agentType: 'claude',
launchToken,
observation: {
origin: 'hook',
authorityId: 'local',
incarnation: 1,
revision: 1,
observedAt: 1,
kind
}
})
const read = (hookRow: AgentStatusIpcPayload, programWorking = true) =>
readTuiIdleHookTurn({
...base,
agent: 'claude-agent-teams',
launchToken: 'launch-1',
hookRows: [hookRow],
programWorking
})
expect(read(transition('transition'))).toEqual({ ...DONE, endedUnderProgramWork: true })
// A replayed done restates an old turn; another launch's done speaks for its predecessor.
expect(read(transition('snapshot'))).toEqual(DONE)
expect(read(transition('transition', 'launch-0'))).toEqual(DONE)
expect(read(transition('transition'), false)).toEqual(DONE)
})
it('takes the newest joined row', () => {
const now = Date.now()
expect(
@@ -356,20 +327,6 @@ describe('evaluateTuiIdle hook lane', () => {
})
})
describe('evaluateTuiIdle claude turn end under program work', () => {
it("settles over Claude's working report once its turn end outlasts it (live teammates)", () => {
expect(
evaluateTuiIdle(
input({
agent: 'claude',
record: record({ lastAgentStatus: 'working' }),
readHookTurn: () => ({ ...DONE, endedUnderProgramWork: true })
})
)
).toEqual({ kind: 'ready-strong' })
})
})
describe('evaluateTuiIdle turn-end hook lane (codex)', () => {
it('settles at once on a done turn, with no title and a streaming pane (headless)', () => {
expect(evaluateTuiIdle(input({ agent: 'codex', readHookTurn: () => DONE }))).toEqual({
+7 -36
View File
@@ -14,9 +14,6 @@ export type TuiIdleHookTurn = {
taskWakeupPending?: true
/** When a fresh `done`'s turn has had time to paint its last frame. */
finalFrameAt?: number
/** A live turn end while the program still reports `working`, which then covers work beyond
* the lead's turn (e.g. live teammates) rather than the turn itself. */
endedUnderProgramWork?: true
}
// Why: Codex runs its Stop hook before it paints the turn's last frame, which can be its plan menu.
@@ -24,12 +21,6 @@ const TURN_END_FINAL_FRAME_MS = 750
// Why: Codex opens its plan menu only after a turn that rendered a proposed plan; no hook field says so.
const CODEX_PROPOSED_PLAN_HEADER = 'Proposed Plan'
/** The agent whose hooks report for a pane running `agent`. */
function hookReportingAgent(agent: TuiAgent | null | undefined): TuiAgent | null | undefined {
// Why: Agent Teams runs the same Claude binary, so its hooks report as `claude`.
return agent === 'claude-agent-teams' ? 'claude' : agent
}
/** Scans newest-first so the common hit (this turn's plan) returns early. */
export function retainedTailIncludes(
lines: readonly string[],
@@ -79,8 +70,6 @@ export type TuiIdleHookTurnRead = {
* the turn that input may have started (or the agent it restarted), whose first hook can still
* be in flight. */
lastInputAt?: number | null
/** Whether the program currently reports OSC 7501 `working`. */
programWorking?: boolean
/** Whether the pane's retained output holds `text`; read only while a fresh `done` may precede a menu. */
tailIncludes?: (text: string) => boolean
/** The existing permission arbiter, given the turn as the pane's explicit status. */
@@ -93,8 +82,7 @@ export type TuiIdleHookTurnRead = {
/** Read the freshest pane/handle-joined hook row for this agent and process; unjoinable, stale or foreign rows leave other lanes to decide. */
export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn | null {
const row = selectFreshExplicitAgentStatusRow(read)
const agent = hookReportingAgent(read.agent)
if (!row || row.agentType !== agent || row.receivedAt < (read.respawnedAt ?? -1)) {
if (!row || row.agentType !== read.agent || row.receivedAt < (read.respawnedAt ?? -1)) {
return null
}
const state = hookLeadTurnState(row)
@@ -103,15 +91,10 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn
return null
}
const blockedReason = read.resolveBlockedText(state, row)
// Why: an older host or launch's late hook speaks for its predecessor, not this process.
const ownHook =
row.observation?.origin === 'hook' &&
terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) &&
(!read.launchToken || row.launchToken === read.launchToken)
// Why the hook alone blocks: a question or custom modal paints no dialog text the arbiter knows,
// and these hooks report its answer. Input since may have answered it before the hook arrived.
const taskWakeupPending =
agent === 'claude' &&
read.agent === 'claude' &&
row.state !== 'done' &&
(row.claudeTaskWakeupPending === 'notification' ||
(row.claudeTaskWakeupPending === 'finishing-turn' &&
@@ -120,28 +103,20 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn
typeof read.titleObservedAtEpochMs === 'number' &&
read.titleObservedAtEpochMs > (row.turnStartedAt ?? row.receivedAt)
))) &&
ownHook &&
row.providerSession?.key === 'session_id'
row.observation?.origin === 'hook' &&
row.providerSession?.key === 'session_id' &&
terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) &&
(!read.launchToken || row.launchToken === read.launchToken)
const finalFrameAt = row.receivedAt + TURN_END_FINAL_FRAME_MS
const finalFramePending =
state === 'done' &&
hookAuthority(read.agent) === 'turn-end' &&
Date.now() < finalFrameAt &&
read.tailIncludes?.(CODEX_PROPOSED_PLAN_HEADER) === true
// Why a transition: a replay or snapshot restates an old turn end and cannot prove this one ended.
const endedUnderProgramWork =
state === 'done' &&
read.programWorking === true &&
ownHook &&
row.observation?.kind === 'transition' &&
!taskWakeupPending &&
!finalFramePending &&
blockedReason === null
return {
state,
...(taskWakeupPending ? { taskWakeupPending: true as const } : {}),
...(finalFramePending ? { finalFrameAt } : {}),
...(endedUnderProgramWork ? { endedUnderProgramWork: true as const } : {}),
blockedReason:
blockedReason ??
(state === 'permission' && !predatesInput ? 'agent-interactive-prompt' : null)
@@ -161,12 +136,8 @@ export function evaluateHookTurn(
readHookTurn: () => TuiIdleHookTurn | null
): TuiIdleHookVerdict | null {
// A ready composer can still owe a task wake-up; only that owner-produced fact vetoes Claude rest.
// A turn end that outlasts Claude's `working` report is rest: Claude keeps reporting working while teammates live.
if (hookReportingAgent(agent) === 'claude') {
if (agent === 'claude') {
const turn = readHookTurn()
if (turn?.endedUnderProgramWork) {
return { kind: 'ready-strong' }
}
return turn?.taskWakeupPending
? turn.blockedReason
? { kind: 'blocked', reason: turn.blockedReason }