mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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' }] })
|
||||
|
||||
Reference in New Issue
Block a user