fix(orchestration): point a structured session with the PTY lane's exact text

A chat or structured worker is now told what a terminal agent is told: the pointer is
formatMessagePointer with the CLI name the PTY lane resolves for a local terminal (orca, or
orca-dev in a dev build), with no shell-specific invocation and no ack lesson. The lane still
excludes the batch a reader holds unacknowledged and points newer mail; that stays host-side,
and the reader's own check replays the held batch and names its ack as it does for a terminal.
This commit is contained in:
Brennan Benson
2026-09-28 15:26:38 -07:00
parent 73e5d12d2c
commit f8a94d9e86
9 changed files with 37 additions and 107 deletions
@@ -18,7 +18,7 @@ import {
resolveTerminalOrchestrationCliCommand,
type OrchestrationCliCommand
} from './orchestration/cli-command'
import { getAppEnvironment } from '../../shared/app-environment'
import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment'
import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence'
import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot'
import { resolveStructuredWorkerAuthority } from './structured-worker-authority'
@@ -264,8 +264,7 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim
connectionId: pty.connectionId,
isWsl: pty.isWsl,
worktreeId: pty.worktreeId,
// Dev builds run the CLI as `orca-dev`; a packaged app must not advertise it.
runtimeCliCommand: getAppEnvironment().isPackaged() ? undefined : 'orca-dev',
runtimeCliCommand: runtimeOrchestrationCliCommand(),
projectRuntime: this.store
? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId)
: undefined
@@ -305,4 +304,14 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim
}
return null
}
/** What a local, non-WSL terminal is told to run; a structured session is always one. */
getLocalOrchestrationCliCommand(): OrchestrationCliCommand {
return runtimeOrchestrationCliCommand() ?? 'orca'
}
}
/** Dev builds run the CLI as `orca-dev`; a packaged app, or a process with no app, must not advertise it. */
function runtimeOrchestrationCliCommand(): OrchestrationCliCommand | undefined {
return hasAppEnvironment() && !getAppEnvironment().isPackaged() ? 'orca-dev' : undefined
}
@@ -221,6 +221,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId
getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle),
resolveStructuredTarget: (mailboxHandle) =>
this.resolveStructuredMailboxTarget(mailboxHandle),
getCliCommand: () => this.getLocalOrchestrationCliCommand(),
host: createStructuredMailboxPointerHost()
})
@@ -4,28 +4,6 @@ import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id'
export type OrchestrationCliCommand = 'orca' | 'orca-dev' | 'orca-ide'
/** The CLI invocation text a structured session's shell can run, one form per shell family. */
export type StructuredSessionCliInvocation = '"$ORCA_CLI_COMMAND"' | '& $env:ORCA_CLI_COMMAND'
/**
* How text addressed to a structured session invokes this app's CLI, in the shell that session's
* commands actually run in. The env carries the absolute launcher in `ORCA_CLI_COMMAND`; a bare
* `orca` can resolve elsewhere once a profile-loading shell rebuilds PATH ahead of Orca's entry.
*
* - Codex on Windows runs PowerShell (pwsh, else Windows PowerShell) and loads its profile, so the
* env var is read as `$env:…` and invoked with `&`. Codex falls back to cmd only when no
* PowerShell exists at all, which Orca cannot see from here.
* - Claude on Windows runs its commands in Git Bash; macOS and Linux shells are POSIX for both.
*/
export function structuredSessionCliInvocation(session: {
platform: NodeJS.Platform
provider: 'claude' | 'codex'
}): StructuredSessionCliInvocation {
return session.platform === 'win32' && session.provider === 'codex'
? '& $env:ORCA_CLI_COMMAND'
: '"$ORCA_CLI_COMMAND"'
}
export function resolveTerminalOrchestrationCliCommand(args: {
connectionId: string | null
isWsl: boolean | null | undefined
+3 -6
View File
@@ -1,6 +1,6 @@
import type { MessageRow } from './types'
import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract'
import type { OrchestrationCliCommand, StructuredSessionCliInvocation } from './cli-command'
import type { OrchestrationCliCommand } from './cli-command'
const BANNER_WIDTH = 60
const SEPARATOR = '─'.repeat(BANNER_WIDTH)
@@ -112,14 +112,11 @@ export function formatMessagesForInjection(messages: MessageRow[]): string {
export function formatMessagePointer(
count: number,
mailboxHandle?: string,
cliCommand: OrchestrationCliCommand | StructuredSessionCliInvocation = 'orca',
/** The unacknowledged batch the reader holds; `check` replays it until acked. */
ackDeliveryId?: string
cliCommand: OrchestrationCliCommand = 'orca'
): string {
const noun = count === 1 ? 'message' : 'messages'
const runFlag = mailboxHandle?.startsWith('run:')
? ` --run ${mailboxHandle.slice('run:'.length)}`
: ''
const ackFlag = ackDeliveryId ? ` --ack ${ackDeliveryId}` : ''
return `\nYou have ${count} ${ackDeliveryId ? 'new ' : ''}orchestration ${noun}. Run \`${cliCommand} orchestration check${runFlag}${ackFlag}\`.\n`
return `\nYou have ${count} orchestration ${noun}. Run \`${cliCommand} orchestration check${runFlag}\`.\n`
}
@@ -5,6 +5,7 @@ import {
type StructuredMailboxPointerHost,
type StructuredPointerSettlement
} from './structured-mailbox-pointer-delivery'
import { formatMessagePointer } from './formatter'
import { structuredSessionGateFacts } from './structured-session-pointer-delivery'
import type { StructuredWorkerIdentity } from '../structured-worker-identity'
@@ -139,10 +140,10 @@ function harness(options: {
getMessageWaiters: () => undefined,
resolveStructuredTarget: (mailboxHandle) =>
mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null,
getCliCommand: () => 'orca-dev',
host: {
readGateFacts: async () => (journal === null ? null : structuredSessionGateFacts(journal)),
currentFence: () => 4,
cliInvocation: () => '"$ORCA_CLI_COMMAND"',
send,
...('wakeTo' in options ? { wake } : {})
}
@@ -297,10 +298,9 @@ describe('structured mailbox pointer delivery', () => {
expect(send).not.toHaveBeenCalled()
})
it('points newer mail while the reader holds an unacknowledged batch, naming its ack', async () => {
it('points newer mail while the reader holds an unacknowledged batch, in the PTY pointer text', async () => {
// The strand this pins: a chat reads a result, ends its turn without acking, and the gate on
// "an unacknowledged batch exists" silenced every later result. `check` replays that batch
// until acked, so the pointer must name the ack to reach the new mail.
// "an unacknowledged batch exists" silenced every later result. The text stays the PTY lane's.
const { delivery, send, markAsDelivered } = harness({
journal: idleJournal(),
outstandingOwnDelivery: true,
@@ -310,7 +310,7 @@ describe('structured mailbox pointer delivery', () => {
await flush()
expect(send).toHaveBeenCalledTimes(1)
expect(send.mock.calls[0]![0].body.blocks[0]).toMatchObject({
text: expect.stringMatching(/You have 1 new orchestration message\. .*--ack delivery_held`/)
text: formatMessagePointer(1, 'dispatch:d1', 'orca-dev').trim()
})
expect(markAsDelivered).toHaveBeenCalledWith(['m2'])
})
@@ -485,10 +485,10 @@ describe('forgetting one settled worker', () => {
? { sessionId, dispatchId: mailboxHandle.slice('dispatch:'.length) }
: null
},
getCliCommand: () => 'orca',
host: {
readGateFacts: async () => structuredSessionGateFacts(journal),
currentFence: () => 4,
cliInvocation: () => '"$ORCA_CLI_COMMAND"',
send
}
})
@@ -15,7 +15,7 @@
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
import type { OrchestrationDb } from './db'
import { formatMessagePointer } from './formatter'
import type { StructuredSessionCliInvocation } from './cli-command'
import type { OrchestrationCliCommand } from './cli-command'
import {
selectOrchestrationPointerBatch,
type OrchestrationMessageWaiter
@@ -66,8 +66,6 @@ export type StructuredMailboxPointerHost = {
}) => Promise<StructuredPointerSendOutcome>
/** Current lease fence; `null` when no record backs the session any more. */
currentFence: (sessionId: string) => number | null
/** How this session's shell invokes this app's CLI; see `structuredSessionCliInvocation`. */
cliInvocation: (sessionId: string) => StructuredSessionCliInvocation
/**
* Holds the session for one attempt, resuming its provider child if the host evicted it; the
* returned release hands it back to the host's release clock. Null when it cannot be resumed.
@@ -85,6 +83,8 @@ type StructuredPointerDeliveryDependencies<TWaiter extends OrchestrationMessageW
* agents mail each other outside a dispatch, and no other lane can serve it.
*/
resolveStructuredTarget: (mailboxHandle: string) => StructuredPointerTarget | null
/** The CLI name the PTY lane types for a local agent, so both lanes send the same pointer. */
getCliCommand: () => OrchestrationCliCommand
host: StructuredMailboxPointerHost
onRetain?: (input: {
mailboxHandle: string
@@ -165,8 +165,7 @@ export class OrchestrationStructuredMailboxPointerDelivery<
}
// Eligibility is "not yet pointed" (`delivered_at`), never "has the consumer acked": a chat that
// reads a batch and ends its turn without acking must still be pointed at the NEXT result. The
// batch it holds is excluded, and the pointer names the `--ack` that releases it, since `check`
// replays an unacknowledged batch instead of returning newer mail.
// batch it holds is excluded; its own `check` replays that batch and names its ack.
const outstanding = db.getOutstandingMailboxDelivery?.(mailboxHandle)
const unread = selectOrchestrationPointerBatch({
db,
@@ -179,7 +178,7 @@ export class OrchestrationStructuredMailboxPointerDelivery<
}
this.inFlight.add(mailboxHandle)
try {
await this.attempt(db, mailboxHandle, target, unread, reservedTypes, outstanding?.id)
await this.attempt(db, mailboxHandle, target, unread, reservedTypes)
} finally {
this.inFlight.delete(mailboxHandle)
}
@@ -190,12 +189,11 @@ export class OrchestrationStructuredMailboxPointerDelivery<
mailboxHandle: string,
target: StructuredPointerTarget,
unread: readonly { id: string; type: string; sequence: number }[],
reservedTypes: ReadonlySet<string> | undefined,
ackDeliveryId: string | undefined
reservedTypes: ReadonlySet<string> | undefined
): Promise<void> {
const release = await this.deps.host.wake?.(target.sessionId)
try {
await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes, ackDeliveryId)
await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes)
} finally {
release?.()
}
@@ -206,8 +204,7 @@ export class OrchestrationStructuredMailboxPointerDelivery<
mailboxHandle: string,
target: StructuredPointerTarget,
unread: readonly { id: string; type: string; sequence: number }[],
reservedTypes: ReadonlySet<string> | undefined,
ackDeliveryId: string | undefined
reservedTypes: ReadonlySet<string> | undefined
): Promise<void> {
const sessionId = target.sessionId
const session = await this.deps.host.readGateFacts(sessionId)
@@ -227,12 +224,7 @@ export class OrchestrationStructuredMailboxPointerDelivery<
blocks: [
{
type: 'text',
text: formatMessagePointer(
unread.length,
mailboxHandle,
this.deps.host.cliInvocation(sessionId),
ackDeliveryId
).trim()
text: formatMessagePointer(unread.length, mailboxHandle, this.deps.getCliCommand()).trim()
}
]
}
@@ -13,7 +13,6 @@ import type {
StructuredMailboxPointerHost,
StructuredPointerSettlement
} from './structured-mailbox-pointer-delivery'
import { structuredSessionCliInvocation } from './cli-command'
import {
structuredSessionGateFacts,
type StructuredSessionGateFacts
@@ -92,14 +91,6 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo
return readStructuredSessionGateFacts(sessionId)
},
cliInvocation(sessionId) {
const provider = getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.provider
return structuredSessionCliInvocation({
platform: process.platform,
provider: provider ?? 'claude'
})
},
currentFence(sessionId) {
return (
getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null
@@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest'
import { structuredSessionCliInvocation } from './cli-command'
import { formatMessagePointer } from './formatter'
describe('how a structured session invokes this app’s CLI, per shell', () => {
it.each([
['darwin', 'claude', '"$ORCA_CLI_COMMAND"'],
['darwin', 'codex', '"$ORCA_CLI_COMMAND"'],
['linux', 'claude', '"$ORCA_CLI_COMMAND"'],
['linux', 'codex', '"$ORCA_CLI_COMMAND"'],
// Claude's command tool runs in Git Bash on Windows: still a POSIX expansion.
['win32', 'claude', '"$ORCA_CLI_COMMAND"'],
// Codex's defaults to PowerShell on Windows, where "$X" is a PS variable and a quoted string
// followed by arguments does not parse; the env var is `$env:X`, invoked with `&`.
['win32', 'codex', '& $env:ORCA_CLI_COMMAND']
] as const)('%s / %s → %s', (platform, provider, invocation) => {
expect(structuredSessionCliInvocation({ platform, provider })).toBe(invocation)
})
it('renders a pointer each shell can run as written', () => {
expect(
formatMessagePointer(
1,
'run:run_1',
structuredSessionCliInvocation({ platform: 'win32', provider: 'codex' })
).trim()
).toBe(
'You have 1 orchestration message. Run `& $env:ORCA_CLI_COMMAND orchestration check --run run_1`.'
)
expect(
formatMessagePointer(
1,
'session:s1',
structuredSessionCliInvocation({ platform: 'darwin', provider: 'codex' })
).trim()
).toBe('You have 1 orchestration message. Run `"$ORCA_CLI_COMMAND" orchestration check`.')
})
})
@@ -20,6 +20,7 @@ import { attachFingerprintFields } from '../native-chat/agent-session-wire/struc
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { OrcaRuntimeService } from './orca-runtime'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagePointer } from './orchestration/formatter'
import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session'
import type { RpcRequest } from './rpc/core'
import { RpcDispatcher } from './rpc/dispatcher'
@@ -347,12 +348,12 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
// The session's own CLI by its env var: a bare `orca` can resolve elsewhere in a login shell.
// Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run.
const WAIT = { timeout: 10_000 }
// The text the PTY lane types into a terminal coordinator, byte for byte.
const POINTER =
/You have 1 orchestration message\. Run `\\?"\$ORCA_CLI_COMMAND\\?" orchestration check --run run_/
/^You have 1 orchestration message\. Run `orca orchestration check --run run_\w+`\.$/
describe('a worker result reaches the structured chat that coordinates it', () => {
it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => {
@@ -414,8 +415,8 @@ describe('a worker result reaches the structured chat that coordinates it', () =
)
await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE })
await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT)
expect(chat.turns[1]!.text).toContain('1 new orchestration message')
expect(chat.turns[1]!.text).toContain(`--ack ${heldDelivery}`)
// The PTY lane's text: `check` itself replays the held batch and names its ack.
expect(chat.turns[1]!.text).toBe(formatMessagePointer(1, `run:${runId}`).trim())
await settleTurn(COORDINATOR, 1)
// Exactly once per new message: a retry and the idle edge point nothing further.
@@ -644,8 +645,7 @@ describe('any live session is addressable by its id', () => {
await vi.waitFor(() => expect(peer.turns).toHaveLength(1), WAIT)
// Direct mail is not in a Run, so the pointer names no `--run`.
expect(peer.turns[0]!.text).toContain('orchestration check`.')
expect(peer.turns[0]!.text).toContain('$ORCA_CLI_COMMAND')
expect(peer.turns[0]!.text).toBe(formatMessagePointer(1, `session:${PEER_CHAT}`).trim())
await settleTurn(PEER_CHAT, 0)
const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT })
expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] })