mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
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:
@@ -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. */
|
||||
|
||||
+4
-103
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user