mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(orchestration): preserve active workers across updates (#11271)
* fix(orchestration): preserve active workers across updates * test(ssh): model absent legacy adoption * test(orchestration): align compatibility contracts * fix(windows): escape updater PowerShell booleans * fix(windows): restore stock uninstall process check * fix(orchestration): keep recovery off renderer startup barrier * fix(orchestration): harden legacy recovery migration * fix(orchestration): close recovery review gaps * fix(orchestration): complete legacy worker cutover recovery * fix(orchestration): preserve legacy workers across updates --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
committed by
Neil
co-authored by
OrcaWin
parent
3240c26bce
commit
959e8d63b0
File diff suppressed because one or more lines are too long
@@ -71,4 +71,24 @@ describe('orchestration check identity', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the pinned legacy --inject check signature', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_legacy_worker'
|
||||
|
||||
await invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['unread', true],
|
||||
['inject', true]
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.check',
|
||||
expect.objectContaining({
|
||||
terminal: 'term_legacy_worker',
|
||||
unread: true,
|
||||
inject: true
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,6 +179,7 @@ describe('legacy orchestration CLI inspection', () => {
|
||||
id: 'msg_current',
|
||||
run_id: 'run_current',
|
||||
from_handle: 'term_current',
|
||||
to_handle: 'run:run_current',
|
||||
subject: 'current question',
|
||||
type: 'question',
|
||||
body: 'May I continue?',
|
||||
@@ -217,7 +218,8 @@ describe('legacy orchestration CLI inspection', () => {
|
||||
expect(response.result.formatted).not.toContain('RUNTIME_SENTINEL')
|
||||
expect(
|
||||
response.result.formatted.split('\n').filter((line) => line.startsWith('[Reply:'))
|
||||
).toEqual(['[Reply: orca orchestration reply --id msg_current --from term_coord --body "..."]'])
|
||||
).toEqual(['[Reply: orca orchestration reply --id msg_current --body "..."]'])
|
||||
expect(response.result.formatted).not.toContain('--from run:run_current')
|
||||
})
|
||||
|
||||
it('preserves runtime formatting when every message belongs to a current Run', async () => {
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('orchestration CLI migration recovery', () => {
|
||||
it('redirects worker_done without an outcome before resolving or calling the runtime', async () => {
|
||||
const call = vi.fn()
|
||||
const originalPaneKey = process.env.ORCA_PANE_KEY
|
||||
|
||||
await expect(
|
||||
ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'Done'],
|
||||
['type', 'worker_done']
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
data: {
|
||||
effectsApplied: false,
|
||||
nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
|
||||
}
|
||||
afterEach(() => {
|
||||
if (originalPaneKey === undefined) {
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
} else {
|
||||
process.env.ORCA_PANE_KEY = originalPaneKey
|
||||
}
|
||||
})
|
||||
|
||||
describe('orchestration CLI migration recovery', () => {
|
||||
it('forwards legacy worker_done without inventing a completion outcome', async () => {
|
||||
process.env.ORCA_PANE_KEY = 'tab-worker:leaf-worker'
|
||||
const call = vi.fn().mockResolvedValue({ result: { message: { id: 'msg_done' } } })
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'Done'],
|
||||
['type', 'worker_done']
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(call).toHaveBeenCalledWith('orchestration.send', {
|
||||
from: 'term_worker',
|
||||
to: undefined,
|
||||
run: undefined,
|
||||
subject: 'Done',
|
||||
body: undefined,
|
||||
type: 'worker_done',
|
||||
priority: undefined,
|
||||
threadId: undefined,
|
||||
payload: undefined,
|
||||
senderPaneKey: 'tab-worker:leaf-worker',
|
||||
devMode: false
|
||||
})
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,28 @@ describe('lightweight Run CLI handlers', () => {
|
||||
from: 'term_coord'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes explicit legacy takeover only when requested', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { run: { id: 'run_adopted', objective: 'Recovered work' } }
|
||||
})
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-use']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['id', 'run_adopted'],
|
||||
['from', 'term_current'],
|
||||
['takeover-legacy', true]
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runUse', {
|
||||
id: 'run_adopted',
|
||||
from: 'term_current',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration reset CLI handler', () => {
|
||||
|
||||
@@ -64,11 +64,13 @@ describe('orchestration timeout flag validation', () => {
|
||||
// Why: unread:false makes pre-peek runtimes fall back to non-consuming all mode.
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
terminalPaneKey: undefined,
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
@@ -182,7 +184,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
@@ -209,7 +213,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: 'msg_question',
|
||||
options: undefined,
|
||||
timeoutMs: undefined,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 605_000, orchestrationCapability: undefined }
|
||||
)
|
||||
|
||||
@@ -86,11 +86,15 @@ describe('orchestration timeout flag validation', () => {
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
terminalPaneKey: undefined,
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
inject: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
timeoutMs: 250
|
||||
})
|
||||
@@ -273,12 +277,16 @@ describe('orchestration timeout flag validation', () => {
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: 'term_coord',
|
||||
run: undefined,
|
||||
question: 'Proceed?',
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123 }
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const originalCliCommand = process.env.ORCA_CLI_COMMAND
|
||||
const originalPackagedLauncher = process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
const originalExitCode = process.exitCode
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('packaged Windows legacy ask protocol', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER = '1'
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = originalExitCode
|
||||
restoreEnv('ORCA_CLI_COMMAND', originalCliCommand)
|
||||
restoreEnv('ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER', originalPackagedLauncher)
|
||||
restoreEnv('ORCA_TERMINAL_HANDLE', originalTerminalHandle)
|
||||
})
|
||||
|
||||
it.each(['orca', 'orca-ide'] as const)(
|
||||
'commits with the %s launcher and exits 75 before resume',
|
||||
async (command) => {
|
||||
process.env.ORCA_CLI_COMMAND = command
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: null,
|
||||
messageId: 'msg_question',
|
||||
threadId: 'msg_question',
|
||||
timedOut: false,
|
||||
legacyCompatibility: {
|
||||
resumeRequired: true,
|
||||
resumeCommand: `${command} orchestration ask --resume msg_question`
|
||||
}
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(new Map([['question', 'Proceed?']]))
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
expect.objectContaining({
|
||||
question: 'Proceed?',
|
||||
resume: undefined,
|
||||
compatibilityWindowsCommand: command
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(log.mock.calls.map(([line]) => line)).toEqual([
|
||||
'Question msg_question committed.',
|
||||
`Resume with: ${command} orchestration ask --resume msg_question`
|
||||
])
|
||||
expect(process.exitCode).toBe(75)
|
||||
}
|
||||
)
|
||||
|
||||
it('resumes the committed question without another exit-75 handoff', async () => {
|
||||
process.env.ORCA_CLI_COMMAND = 'orca'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: 'yes',
|
||||
messageId: 'msg_question',
|
||||
threadId: 'msg_question',
|
||||
timedOut: false
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(new Map([['resume', 'msg_question']]))
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
expect.objectContaining({
|
||||
question: undefined,
|
||||
resume: 'msg_question',
|
||||
compatibilityWindowsCommand: 'orca'
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(log).toHaveBeenCalledWith('yes')
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
function invokeAsk(flags: Map<string, string | boolean>): Promise<void> {
|
||||
return ORCHESTRATION_HANDLERS['orchestration ask']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
@@ -692,6 +692,7 @@ describe('orchestration timeout flag validation', () => {
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
@@ -819,7 +820,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
@@ -848,7 +851,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: 'msg_question',
|
||||
options: undefined,
|
||||
timeoutMs: undefined,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 605_000, orchestrationCapability: undefined }
|
||||
)
|
||||
|
||||
@@ -21,11 +21,14 @@ import type {
|
||||
} from '../../shared/orchestration-worker-output'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
|
||||
import {
|
||||
ORCHESTRATION_LEGACY_RUN_ID,
|
||||
orchestrationMigrationData,
|
||||
orchestrationSkillRecoveryData
|
||||
} from '../../shared/orchestration-rpc-contract'
|
||||
formatMessageReadOnlyTag,
|
||||
formatOrchestrationCheckText,
|
||||
prepareOrchestrationCheckOutput,
|
||||
type LegacyCompatibilityResult,
|
||||
type OrchestrationMessageSummary as MessageSummary
|
||||
} from '../../shared/orchestration-check-output'
|
||||
|
||||
// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
@@ -75,81 +78,6 @@ const TASK_STATUS_VALUES = [
|
||||
'blocked'
|
||||
] as const
|
||||
|
||||
type MessageSummary = {
|
||||
id: string
|
||||
run_id?: string
|
||||
from_handle: string
|
||||
to_handle?: string
|
||||
subject?: string
|
||||
type?: string
|
||||
body?: string
|
||||
payload?: string | null
|
||||
priority?: string
|
||||
read?: number
|
||||
}
|
||||
|
||||
function formatMessageReadOnlyTag(message: MessageSummary): string {
|
||||
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID ? ' [legacy, read-only]' : ''
|
||||
}
|
||||
|
||||
function isLegacyReadOnlyMessage(message: MessageSummary): boolean {
|
||||
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID
|
||||
}
|
||||
|
||||
function formatMessagePriorityTag(message: MessageSummary): string {
|
||||
return message.priority === 'urgent' ? ' [URGENT]' : message.priority === 'high' ? ' [HIGH]' : ''
|
||||
}
|
||||
|
||||
function escapeTerminalControlCharacters(value: string): string {
|
||||
return [...value]
|
||||
.map((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
if (character === '\n' || (code >= 0x20 && code < 0x7f) || code > 0x9f) {
|
||||
return character
|
||||
}
|
||||
return `\\x${code.toString(16).padStart(2, '0')}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function formatQuotedMessageField(label: string, value?: string): string {
|
||||
return `[${label}]\n${escapeTerminalControlCharacters(value ?? '')
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')}`
|
||||
}
|
||||
|
||||
function formatLegacyAwareCheckMessages(
|
||||
messages: MessageSummary[],
|
||||
checkedTerminal: string
|
||||
): string {
|
||||
return messages
|
||||
.map((message) => {
|
||||
const legacyReadOnly = isLegacyReadOnlyMessage(message)
|
||||
const lines = [
|
||||
`${message.id}${formatMessageReadOnlyTag(message)}${formatMessagePriorityTag(message)} [${message.type ?? 'status'}] from=${message.from_handle}`,
|
||||
formatQuotedMessageField('subject', message.subject)
|
||||
]
|
||||
if (legacyReadOnly) {
|
||||
lines.push('[Inspection only: reply and acknowledgment are unavailable.]')
|
||||
}
|
||||
if (message.body) {
|
||||
lines.push(formatQuotedMessageField('body', message.body))
|
||||
}
|
||||
if (message.payload) {
|
||||
lines.push(formatQuotedMessageField('payload', message.payload))
|
||||
}
|
||||
if (!legacyReadOnly) {
|
||||
const replyFrom = message.to_handle ?? checkedTerminal
|
||||
lines.push(
|
||||
`[Reply: orca orchestration reply --id ${message.id} --from ${replyFrom} --body "..."]`
|
||||
)
|
||||
}
|
||||
return lines.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
type LifecycleSendRejection = {
|
||||
action: 'rejected'
|
||||
code: string
|
||||
@@ -170,6 +98,40 @@ type OrchestrationSendResult =
|
||||
lifecycle?: { action: 'completed' | 'failed' }
|
||||
}
|
||||
|
||||
function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' {
|
||||
const configured = process.env.ORCA_CLI_COMMAND
|
||||
if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') {
|
||||
return configured
|
||||
}
|
||||
return process.platform === 'linux' ? 'orca-ide' : 'orca'
|
||||
}
|
||||
|
||||
function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined {
|
||||
if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') {
|
||||
return undefined
|
||||
}
|
||||
const command = process.env.ORCA_CLI_COMMAND
|
||||
if (command === 'orca' || command === 'orca-ide') {
|
||||
return command
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'The packaged Orca launcher did not provide a valid resume command. No question was created.'
|
||||
)
|
||||
}
|
||||
|
||||
async function flushStdout(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write('', (error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getOptionalStructuredMessagePayload(
|
||||
flags: Map<string, string | boolean>
|
||||
): string | undefined {
|
||||
@@ -485,7 +447,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
run: { id: string; objective: string; consumer_generation: number }
|
||||
}>(client, flags, 'orchestration.runUse', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
from
|
||||
from,
|
||||
...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {})
|
||||
})
|
||||
printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`)
|
||||
},
|
||||
@@ -541,13 +504,6 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
rejectLifecycleGroupRecipient(type, to)
|
||||
}
|
||||
const outcome = getOptionalStringFlag(flags, 'outcome')
|
||||
if (type === 'worker_done' && outcome === undefined && !flags.has('payload')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'worker_done requires --outcome succeeded or --outcome failed. No effects were applied.',
|
||||
orchestrationSkillRecoveryData()
|
||||
)
|
||||
}
|
||||
if (type !== 'worker_done' && outcome !== undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
@@ -634,6 +590,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
timedOut?: boolean
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
legacyCompatibility?: LegacyCompatibilityResult
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof client.call<CheckResult>>>
|
||||
try {
|
||||
@@ -646,6 +603,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
all: flags.has('all') ? true : undefined,
|
||||
types: getOptionalStringFlag(flags, 'types'),
|
||||
format: flags.has('format') ? true : undefined,
|
||||
inject: flags.has('inject') ? true : undefined,
|
||||
compatibilityCliCommand: resolveCompatibilityCliCommand(),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
ack: getOptionalStringFlag(flags, 'ack'),
|
||||
wait: wait ? true : undefined,
|
||||
@@ -682,39 +641,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (flags.has('format') && result.result.messages.some(isLegacyReadOnlyMessage)) {
|
||||
// Why: formatted is one opaque batch with untrusted bodies, so selective banner parsing cannot safely remove legacy actions.
|
||||
result = {
|
||||
...result,
|
||||
result: {
|
||||
...result.result,
|
||||
formatted: formatLegacyAwareCheckMessages(result.result.messages, terminal)
|
||||
}
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format'))
|
||||
}
|
||||
printResult(result, json, (r) => formatOrchestrationCheckText(r, terminal))
|
||||
const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds
|
||||
if (compatibilityAck && compatibilityAck.length > 0) {
|
||||
await flushStdout()
|
||||
await client.call('orchestration.check', {
|
||||
terminal,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: compatibilityAck,
|
||||
types: getOptionalStringFlag(flags, 'types')
|
||||
?.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
})
|
||||
}
|
||||
printResult(result, json, (r) => {
|
||||
if (r.formatted) {
|
||||
return r.formatted
|
||||
}
|
||||
if (r.count === 0) {
|
||||
if (r.timedOut) {
|
||||
return 'Wait timed out; no messages were consumed.'
|
||||
}
|
||||
if (r.cancelled) {
|
||||
return r.connectionLost
|
||||
? 'Wait cancelled because the connection closed; no messages were consumed.'
|
||||
: 'Wait cancelled; no messages were consumed.'
|
||||
}
|
||||
return 'No messages.'
|
||||
}
|
||||
const rendered = r.messages
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.id}${formatMessageReadOnlyTag(m)} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`
|
||||
)
|
||||
.join('\n')
|
||||
return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration reply': async ({ flags, client, cwd, json }) => {
|
||||
@@ -1034,6 +979,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
timeoutMs?: number
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
answerMessageId?: string | null
|
||||
legacyCompatibility?: LegacyCompatibilityResult
|
||||
}>(
|
||||
client,
|
||||
flags,
|
||||
@@ -1045,7 +992,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
resume,
|
||||
options: getOptionalStringFlag(flags, 'options'),
|
||||
timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs,
|
||||
from
|
||||
from,
|
||||
compatibilityCliCommand: resolveCompatibilityCliCommand(),
|
||||
compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand()
|
||||
},
|
||||
// Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves.
|
||||
{
|
||||
@@ -1056,9 +1005,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
// Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs.
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result.result))
|
||||
} else if (result.result.legacyCompatibility?.resumeRequired) {
|
||||
console.log(`Question ${result.result.messageId} committed.`)
|
||||
console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`)
|
||||
} else if (result.result.answer !== null) {
|
||||
console.log(result.result.answer)
|
||||
}
|
||||
if (result.result.legacyCompatibility?.resumeRequired) {
|
||||
await flushStdout()
|
||||
process.exitCode = 75
|
||||
return
|
||||
}
|
||||
const answerAck = result.result.legacyCompatibility?.answerAcknowledgement
|
||||
if (answerAck && result.result.answer !== null) {
|
||||
await flushStdout()
|
||||
await client.call('orchestration.check', {
|
||||
terminal: from,
|
||||
compatibilityQuestionAck: JSON.stringify(answerAck)
|
||||
})
|
||||
}
|
||||
if (result.result.timedOut) {
|
||||
if (!json) {
|
||||
// Why: report the server's effective budget — it clamps large values, so the requested one would overstate the wait.
|
||||
|
||||
@@ -103,20 +103,40 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const priorLaunchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN
|
||||
process.env.ORCA_AGENT_LAUNCH_TOKEN = 'launch-secret'
|
||||
const client = new RuntimeClient(userDataPath, 500)
|
||||
await client.call(
|
||||
'orchestration.send',
|
||||
{ subject: 'hello' },
|
||||
{
|
||||
orchestrationRequestId: 'mutation_explicit'
|
||||
try {
|
||||
await client.call(
|
||||
'orchestration.send',
|
||||
{ subject: 'hello' },
|
||||
{
|
||||
orchestrationRequestId: 'mutation_explicit'
|
||||
}
|
||||
)
|
||||
await client.call('orchestration.taskList', {})
|
||||
const secondClient = new RuntimeClient(userDataPath, 500)
|
||||
await secondClient.call('orchestration.taskList', {})
|
||||
} finally {
|
||||
if (priorLaunchToken === undefined) {
|
||||
delete process.env.ORCA_AGENT_LAUNCH_TOKEN
|
||||
} else {
|
||||
process.env.ORCA_AGENT_LAUNCH_TOKEN = priorLaunchToken
|
||||
}
|
||||
)
|
||||
await client.call('orchestration.taskList', {})
|
||||
}
|
||||
|
||||
expect(requests[0]?.method).toBe('status.get')
|
||||
expect(requests[0]?.compatibilityInvocationId).toBeUndefined()
|
||||
expect(requests[1]?.orchestrationRequestId).toBe('mutation_explicit')
|
||||
expect(requests[1]?.orchestrationContractVersion).toBe(1)
|
||||
expect(requests[1]?.compatibilityInvocationId).toBe('mutation_explicit')
|
||||
expect(requests[1]?.orchestrationCompatibilityEvidence).toMatchObject({
|
||||
launchToken: 'launch-secret'
|
||||
})
|
||||
expect(requests[2]?.orchestrationRequestId).toBeUndefined()
|
||||
expect(requests[2]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
|
||||
expect(requests[3]?.method).toBe('orchestration.taskList')
|
||||
expect(requests[3]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
|
||||
})
|
||||
|
||||
it('rejects an old local runtime before sending an orchestration mutation', async () => {
|
||||
|
||||
+14
-17
@@ -20,6 +20,8 @@ import {
|
||||
ORCHESTRATION_CONTRACT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
|
||||
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
|
||||
|
||||
// Why: for long-poll methods the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
@@ -43,6 +45,9 @@ export class RuntimeClient {
|
||||
private readonly environmentSelector: string | null
|
||||
private remoteCompatChecked = false
|
||||
private orchestrationContractCheck: Promise<void> | null = null
|
||||
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
|
||||
process.env
|
||||
)
|
||||
|
||||
// Why: browser commands trigger first-time session init (agent-browser connect +
|
||||
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
|
||||
@@ -76,12 +81,20 @@ export class RuntimeClient {
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const compatibilityEnvelope = method.startsWith('orchestration.')
|
||||
? {
|
||||
...this.orchestrationCompatibility,
|
||||
compatibilityInvocationId:
|
||||
orchestrationRequestId ?? this.orchestrationCompatibility.compatibilityInvocationId
|
||||
}
|
||||
: {}
|
||||
const envelope = {
|
||||
orchestrationCapability: options?.orchestrationCapability,
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
: undefined,
|
||||
orchestrationRequestId
|
||||
orchestrationRequestId,
|
||||
...compatibilityEnvelope
|
||||
}
|
||||
if (this.remotePairing) {
|
||||
if (method !== 'status.get') {
|
||||
@@ -327,19 +340,3 @@ function resolveRemotePairing(
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isWaitingCheck(params: unknown): boolean {
|
||||
return (
|
||||
typeof params === 'object' &&
|
||||
params !== null &&
|
||||
'wait' in params &&
|
||||
(params as { wait: unknown }).wait === true
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeoutMsParam(params: unknown): unknown {
|
||||
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
|
||||
return undefined
|
||||
}
|
||||
return (params as { timeoutMs?: unknown }).timeoutMs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
|
||||
export function createOrchestrationCompatibilityEnvelope(
|
||||
env: NodeJS.ProcessEnv
|
||||
): RuntimeOrchestrationEnvelope {
|
||||
return {
|
||||
compatibilityInvocationId: randomUUID(),
|
||||
orchestrationCompatibilityEvidence: readOrchestrationCompatibilityEvidence(env)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function isWaitingCheck(params: unknown): boolean {
|
||||
return (
|
||||
typeof params === 'object' &&
|
||||
params !== null &&
|
||||
'wait' in params &&
|
||||
(params as { wait: unknown }).wait === true
|
||||
)
|
||||
}
|
||||
|
||||
export function getTimeoutMsParam(params: unknown): unknown {
|
||||
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
|
||||
return undefined
|
||||
}
|
||||
return (params as { timeoutMs?: unknown }).timeoutMs
|
||||
}
|
||||
@@ -188,7 +188,9 @@ export async function sendRequest<TResult>(
|
||||
params,
|
||||
orchestrationCapability: envelope?.orchestrationCapability,
|
||||
orchestrationContractVersion: envelope?.orchestrationContractVersion,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId,
|
||||
compatibilityInvocationId: envelope?.compatibilityInvocationId,
|
||||
orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RuntimeRpcFailureError } from './types'
|
||||
|
||||
describe('RuntimeRpcFailureError compatibility redaction', () => {
|
||||
it('removes compatibility evidence from CLI JSON errors', () => {
|
||||
const error = new RuntimeRpcFailureError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'compatibility_rejected',
|
||||
message: 'Rejected',
|
||||
data: {
|
||||
orchestrationCompatibilityEvidence: {
|
||||
launchToken: 'launch-secret'
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
expect(error.data).toEqual({
|
||||
orchestrationCompatibilityEvidence: '[redacted]'
|
||||
})
|
||||
expect(JSON.stringify(error.response)).not.toContain('launch-secret')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
|
||||
import { redactOrchestrationCompatibilitySecrets } from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
export type {
|
||||
RuntimeRpcFailure,
|
||||
@@ -15,7 +16,7 @@ export class RuntimeClientError extends Error {
|
||||
constructor(code: string, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.data = data
|
||||
this.data = redactOrchestrationCompatibilitySecrets(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +26,12 @@ export class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
constructor(response: RuntimeRpcFailure) {
|
||||
// Why: all client errors expose recovery through the same inherited channel.
|
||||
super(response.error.code, response.error.message, response.error.data)
|
||||
this.response = response
|
||||
this.response = {
|
||||
...response,
|
||||
error: {
|
||||
...response.error,
|
||||
...(response.error.data === undefined ? {} : { data: this.data })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,12 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'run-use'],
|
||||
summary: 'Bind this coordinator terminal to an existing Run',
|
||||
usage: 'orca orchestration run-use --id <run_id> [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'retry-request']
|
||||
usage:
|
||||
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'],
|
||||
notes: [
|
||||
'--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-current'],
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentHookEventPayload } from '../../shared/agent-hook-listener'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { AgentHookServer } from './server'
|
||||
|
||||
const PANE_KEY = makePaneKey('tab-authority', '11111111-1111-4111-8111-111111111111')
|
||||
const SECOND_PANE_KEY = makePaneKey('tab-authority-2', '22222222-2222-4222-8222-222222222222')
|
||||
|
||||
describe('AgentHookServer authority evidence', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
})
|
||||
|
||||
it('freezes pre-listen commitments separately from current-runtime observations', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
const hydrated = {
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
payload: { state: 'working', prompt: 'before', agentType: 'codex' },
|
||||
receivedAt: 100,
|
||||
stateStartedAt: 100
|
||||
} satisfies AgentHookEventPayload & { receivedAt: number; stateStartedAt: number }
|
||||
server._getStateForTests().lastStatusByPaneKey.set(PANE_KEY, hydrated)
|
||||
|
||||
await server.start()
|
||||
const commitments = server.getHydratedAuthorityCommitments()
|
||||
|
||||
expect(commitments).toEqual([
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
observedAt: 100
|
||||
}
|
||||
])
|
||||
expect(Object.isFrozen(commitments)).toBe(true)
|
||||
expect(Object.isFrozen(commitments[0])).toBe(true)
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([])
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'hydrated_commitment' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-after-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::after',
|
||||
payload: { state: 'working', prompt: 'after', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(JSON.stringify(commitments)).not.toContain('launch-before-restart')
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
connectionId: 'ssh-target',
|
||||
worktreeId: 'repo::after'
|
||||
})
|
||||
])
|
||||
expect(JSON.stringify(server.getCurrentAuthorityObservations())).not.toContain(
|
||||
'launch-after-restart'
|
||||
)
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-after-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-after-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::current',
|
||||
payload: { state: 'working', prompt: 'current', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority-2',
|
||||
payload: { state: 'working', prompt: 'duplicate', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
server.clearStatusEntriesForConnection('ssh-target')
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([])
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'hydrated_commitment' })
|
||||
|
||||
server.clearPaneState(PANE_KEY)
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves hydrated authority when stale alias cleanup does not own the stable pane', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
const launchToken = 'launch-before-restart'
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
const hydrated = {
|
||||
paneKey: PANE_KEY,
|
||||
launchToken,
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
payload: { state: 'working', prompt: 'before', agentType: 'codex' },
|
||||
receivedAt: 100,
|
||||
stateStartedAt: 100
|
||||
} satisfies AgentHookEventPayload & { receivedAt: number; stateStartedAt: number }
|
||||
server._getStateForTests().lastStatusByPaneKey.set(PANE_KEY, hydrated)
|
||||
server.registerPaneKeyAlias('tab-authority:0', PANE_KEY, 'old-pty')
|
||||
await server.start()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken,
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::current',
|
||||
payload: { state: 'working', prompt: 'current', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false })
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: this suite exercises the full hook HTTP surface (Claude/Codex/Gemini parsing, transcript chunked scan, paneKey dispatch) and keeping the scenarios co-located avoids fixture drift across files. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -57,6 +58,7 @@ const TAB_A_PANE = makePaneKey('tab-A', LEAF_5)
|
||||
|
||||
type Body = {
|
||||
paneKey: string
|
||||
launchToken?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
env?: string
|
||||
@@ -6254,7 +6256,10 @@ describe('Last-status persistence', () => {
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' })
|
||||
buildBody(
|
||||
{ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' },
|
||||
{ launchToken: 'launch-bearer-must-not-persist' }
|
||||
)
|
||||
)
|
||||
// Synchronous flush via stop() captures the trailing-debounced write.
|
||||
server.flushStatusPersistSync()
|
||||
@@ -6269,11 +6274,148 @@ describe('Last-status persistence', () => {
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload: expect.objectContaining({ state: 'working', prompt: 'persist me' })
|
||||
})
|
||||
expect(file.entries[PANE].launchToken).toBeUndefined()
|
||||
expect(file.entries[PANE].launchTokenHash).toBe(
|
||||
createHash('sha256').update('launch-bearer-must-not-persist').digest('hex')
|
||||
)
|
||||
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('scrubs a legacy persisted launch bearer while retaining its authority commitment', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = recentTs()
|
||||
const launchToken = 'legacy-launch-bearer'
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
launchToken,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'legacy worker',
|
||||
agentType: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
expect(server.getHydratedAuthorityCommitments()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, launchTokenHash })
|
||||
])
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
|
||||
|
||||
const persisted = readFileSync(lastStatusPath(), 'utf8')
|
||||
expect(persisted).not.toContain(launchToken)
|
||||
expect(JSON.parse(persisted).entries[PANE]).toMatchObject({ launchTokenHash })
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
|
||||
const restartedServer = new AgentHookServer()
|
||||
await restartedServer.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(restartedServer.getHydratedAuthorityCommitments()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
launchTokenHash: createHash('sha256').update(launchToken).digest('hex')
|
||||
})
|
||||
])
|
||||
expect(restartedServer.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
|
||||
} finally {
|
||||
restartedServer.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists SSH authority through transient clears until explicit retirement', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = recentTs()
|
||||
const launchToken = 'retained-ssh-launch-bearer'
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'ssh-target',
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'retained SSH worker',
|
||||
agentType: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const first = new AgentHookServer()
|
||||
await first.start({ env: 'production', userDataPath })
|
||||
first.clearStatusEntriesForConnection('ssh-target')
|
||||
first.flushStatusPersistSync()
|
||||
first.stop()
|
||||
|
||||
const afterClear = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
|
||||
expect(afterClear.entries).toEqual({})
|
||||
expect(afterClear.authorityCommitments[PANE]).toMatchObject({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target'
|
||||
})
|
||||
|
||||
const restored = new AgentHookServer()
|
||||
await restored.start({ env: 'production', userDataPath })
|
||||
expect(
|
||||
restored.attestCompatibilityAuthority({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE, source: 'hydrated_commitment' })
|
||||
restored.retirePaneAuthority(PANE)
|
||||
restored.flushStatusPersistSync()
|
||||
restored.stop()
|
||||
|
||||
const retired = new AgentHookServer()
|
||||
await retired.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(
|
||||
retired.attestCompatibilityAuthority({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
} finally {
|
||||
retired.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists and hydrates Pi session identity without creating status telemetry', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
const firstRendererListener = vi.fn()
|
||||
|
||||
+364
-18
@@ -79,6 +79,22 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
||||
stateStartedAt: number
|
||||
}
|
||||
|
||||
type PersistedAgentHookEventPayload = Omit<
|
||||
EnrichedAgentHookEventPayload,
|
||||
'launchToken' | 'promptInteractionKey'
|
||||
> & {
|
||||
launchTokenHash?: string
|
||||
}
|
||||
|
||||
type PersistedAgentHookAuthorityCommitment = {
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
observedAt: number
|
||||
}
|
||||
|
||||
export type AgentHookStatusChangeEntry = {
|
||||
state: AgentStatusState
|
||||
receivedAt: number
|
||||
@@ -92,6 +108,20 @@ export type AgentHookProviderSessionIdentity = {
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export type AgentHookAuthorityEvidence = Readonly<{
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
observedAt: number
|
||||
}>
|
||||
|
||||
export type AgentHookAuthorityAttestation = Readonly<{
|
||||
paneKey: string
|
||||
source: 'current_hook' | 'hydrated_commitment'
|
||||
}>
|
||||
|
||||
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
|
||||
type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void
|
||||
type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void
|
||||
@@ -128,7 +158,8 @@ export const PANE_KEY_ALIASES_MAX = 1024
|
||||
|
||||
type LastStatusFile = {
|
||||
version: number
|
||||
entries: Record<string, EnrichedAgentHookEventPayload>
|
||||
entries: Record<string, PersistedAgentHookEventPayload>
|
||||
authorityCommitments?: Record<string, PersistedAgentHookAuthorityCommitment>
|
||||
}
|
||||
|
||||
type AgentPromptSentDedupeEntry = {
|
||||
@@ -251,7 +282,6 @@ function sanitizeHydratedEntry(
|
||||
}
|
||||
return {
|
||||
paneKey,
|
||||
launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined,
|
||||
tabId: typeof tabId === 'string' ? tabId : undefined,
|
||||
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
|
||||
connectionId,
|
||||
@@ -268,6 +298,63 @@ function sanitizeHydratedEntry(
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedLaunchTokenHash(rawEntry: unknown): string | null {
|
||||
if (typeof rawEntry !== 'object' || rawEntry === null) {
|
||||
return null
|
||||
}
|
||||
const record = rawEntry as Record<string, unknown>
|
||||
const launchTokenHash =
|
||||
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
|
||||
if (/^[a-f0-9]{64}$/.test(launchTokenHash)) {
|
||||
return launchTokenHash
|
||||
}
|
||||
const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : ''
|
||||
return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null
|
||||
}
|
||||
|
||||
function sanitizePersistedAuthorityCommitment(
|
||||
paneKey: string,
|
||||
value: unknown
|
||||
): AgentHookAuthorityEvidence | null {
|
||||
if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) {
|
||||
return null
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const launchTokenHash =
|
||||
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
|
||||
const connectionId = record.connectionId
|
||||
const observedAt = record.observedAt
|
||||
if (
|
||||
!/^[a-f0-9]{64}$/.test(launchTokenHash) ||
|
||||
(connectionId !== null && typeof connectionId !== 'string') ||
|
||||
typeof observedAt !== 'number' ||
|
||||
!Number.isFinite(observedAt)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({
|
||||
paneKey,
|
||||
launchTokenHash,
|
||||
connectionId,
|
||||
...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}),
|
||||
...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}),
|
||||
observedAt
|
||||
})
|
||||
}
|
||||
|
||||
function authorityCommitmentsMatch(
|
||||
left: AgentHookAuthorityEvidence,
|
||||
right: AgentHookAuthorityEvidence
|
||||
): boolean {
|
||||
return (
|
||||
left.paneKey === right.paneKey &&
|
||||
left.launchTokenHash === right.launchTokenHash &&
|
||||
left.connectionId === right.connectionId &&
|
||||
left.tabId === right.tabId &&
|
||||
left.worktreeId === right.worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload {
|
||||
return {
|
||||
paneKey: entry.paneKey,
|
||||
@@ -479,6 +566,11 @@ export class AgentHookServer {
|
||||
private state: HookListenerState = createHookListenerState()
|
||||
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
|
||||
private runtimeObservedStatusPaneKeys = new Set<string>()
|
||||
private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
|
||||
private hydratedLaunchTokenHashByPaneKey = new Map<string, string>()
|
||||
private persistedAuthorityCommitmentsByPaneKey = new Map<string, AgentHookAuthorityEvidence>()
|
||||
private revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
|
||||
private currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
|
||||
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
|
||||
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
|
||||
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
|
||||
@@ -562,6 +654,53 @@ export class AgentHookServer {
|
||||
return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : []
|
||||
}
|
||||
|
||||
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
|
||||
return this.hydratedAuthorityCommitments
|
||||
}
|
||||
|
||||
getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] {
|
||||
return Object.freeze(
|
||||
Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry }))
|
||||
)
|
||||
}
|
||||
|
||||
attestCompatibilityAuthority(candidate: {
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
terminalProvenance: 'current_runtime' | 'restored'
|
||||
}): AgentHookAuthorityAttestation | null {
|
||||
const paneKey = this.resolvePaneKeyAlias(candidate.paneKey)
|
||||
const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean =>
|
||||
entry.launchTokenHash === candidate.launchTokenHash &&
|
||||
entry.connectionId === candidate.connectionId
|
||||
const commitments = this.hydratedAuthorityCommitments.filter(
|
||||
(entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry)
|
||||
)
|
||||
const current = Array.from(this.currentAuthorityObservations.values())
|
||||
const observations = current.filter(matchesCandidate)
|
||||
const paneObservations = current.filter(
|
||||
(entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey
|
||||
)
|
||||
const hasUniqueCurrentObservation =
|
||||
observations.length === 1 &&
|
||||
paneObservations.length === 1 &&
|
||||
this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey
|
||||
if (candidate.terminalProvenance === 'current_runtime') {
|
||||
return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null
|
||||
}
|
||||
if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) {
|
||||
return null
|
||||
}
|
||||
if (observations.length === 0 && paneObservations.length === 0) {
|
||||
return Object.freeze({ paneKey, source: 'hydrated_commitment' })
|
||||
}
|
||||
if (!hasUniqueCurrentObservation) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({ paneKey, source: 'current_hook' })
|
||||
}
|
||||
|
||||
inferInterrupt(request: AgentInterruptInferenceRequest): boolean {
|
||||
if (!isValidPaneKey(request.paneKey)) {
|
||||
return false
|
||||
@@ -1301,9 +1440,40 @@ export class AgentHookServer {
|
||||
tabId: owner?.tabId
|
||||
})
|
||||
}
|
||||
const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey)
|
||||
if (hydratedLaunchTokenHash) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash)
|
||||
}
|
||||
const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey)
|
||||
if (persistedAuthority) {
|
||||
const owner = parsePaneKey(toPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(
|
||||
toPaneKey,
|
||||
Object.freeze({
|
||||
...persistedAuthority,
|
||||
paneKey: toPaneKey,
|
||||
...(owner?.tabId ? { tabId: owner.tabId } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
|
||||
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
|
||||
}
|
||||
const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey)
|
||||
if (authorityObservation) {
|
||||
const owner = parsePaneKey(toPaneKey)
|
||||
this.currentAuthorityObservations.delete(previousOwnerPaneKey)
|
||||
this.currentAuthorityObservations.set(
|
||||
toPaneKey,
|
||||
Object.freeze({
|
||||
...authorityObservation,
|
||||
paneKey: toPaneKey,
|
||||
tabId: owner?.tabId
|
||||
})
|
||||
)
|
||||
}
|
||||
const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey)
|
||||
if (promptDedupe !== undefined) {
|
||||
this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey)
|
||||
@@ -1321,7 +1491,7 @@ export class AgentHookServer {
|
||||
this.boundPaneKeyAliases()
|
||||
this.closedAgentStatusPaneKeys.delete(toPaneKey)
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
if (hadStatus) {
|
||||
if (hadStatus || persistedAuthority) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1339,6 +1509,7 @@ export class AgentHookServer {
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
|
||||
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
|
||||
for (const key of paneKeys) {
|
||||
this.markPaneClosedForAgentStatus(key)
|
||||
@@ -1346,12 +1517,13 @@ export class AgentHookServer {
|
||||
this.clearCodexSubagentPoll(key)
|
||||
clearPaneCacheState(this.state, key)
|
||||
this.runtimeObservedStatusPaneKeys.delete(key)
|
||||
this.currentAuthorityObservations.delete(key)
|
||||
this.promptSentDedupeByPaneKey.delete(key)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (hadStatus) {
|
||||
if (hadStatus || authorityChanged) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1366,11 +1538,19 @@ export class AgentHookServer {
|
||||
const clearedStatusPaneKeys = new Set<string>()
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (entry.ptyId === ptyId) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
const shouldClearStablePaneKey =
|
||||
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
|
||||
const revokedPaneKeys = new Set([legacyPaneKey])
|
||||
if (shouldClearStablePaneKey) {
|
||||
revokedPaneKeys.add(entry.stablePaneKey)
|
||||
}
|
||||
if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) {
|
||||
statusChanged = true
|
||||
}
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
|
||||
statusChanged = true
|
||||
clearedStatusPaneKeys.add(entry.stablePaneKey)
|
||||
@@ -1379,6 +1559,7 @@ export class AgentHookServer {
|
||||
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
|
||||
clearPaneCacheState(this.state, entry.stablePaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
|
||||
this.currentAuthorityObservations.delete(entry.stablePaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
|
||||
}
|
||||
aliasChanged = true
|
||||
@@ -1400,6 +1581,27 @@ export class AgentHookServer {
|
||||
return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey
|
||||
}
|
||||
|
||||
private revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet<string>): boolean {
|
||||
let changed = false
|
||||
for (const commitment of this.hydratedAuthorityCommitments) {
|
||||
if (
|
||||
paneKeys.has(commitment.paneKey) ||
|
||||
paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey))
|
||||
) {
|
||||
this.revokedHydratedAuthorityCommitments.add(commitment)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const paneKey of paneKeys) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed
|
||||
changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed
|
||||
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed
|
||||
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return body
|
||||
@@ -1602,6 +1804,7 @@ export class AgentHookServer {
|
||||
isReplay: envelope.isReplay === true ? true : undefined,
|
||||
payload: normalizedPayload
|
||||
}
|
||||
this.recordCurrentAuthorityObservation(event)
|
||||
this.applyNormalizedStatus(event)
|
||||
}
|
||||
|
||||
@@ -1632,6 +1835,7 @@ export class AgentHookServer {
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(404)
|
||||
@@ -1673,6 +1877,7 @@ export class AgentHookServer {
|
||||
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
|
||||
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
|
||||
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
|
||||
this.recordCurrentAuthorityObservation(normalized)
|
||||
const enriched = this.applyNormalizedStatus(normalized)
|
||||
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
||||
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
|
||||
@@ -1739,6 +1944,11 @@ export class AgentHookServer {
|
||||
this.lastStatusFilePath = null
|
||||
this.lastWrittenJson = null
|
||||
this.runtimeObservedStatusPaneKeys.clear()
|
||||
this.hydratedAuthorityCommitments = Object.freeze([])
|
||||
this.hydratedLaunchTokenHashByPaneKey.clear()
|
||||
this.persistedAuthorityCommitmentsByPaneKey.clear()
|
||||
this.revokedHydratedAuthorityCommitments = new WeakSet()
|
||||
this.currentAuthorityObservations.clear()
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
@@ -1750,7 +1960,7 @@ export class AgentHookServer {
|
||||
|
||||
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
|
||||
dropStatusEntry(paneKey: string): void {
|
||||
if (!this.deleteStatusEntry(paneKey)) {
|
||||
if (!this.deleteStatusEntry(paneKey, { preserveAuthority: true })) {
|
||||
return
|
||||
}
|
||||
this.scheduleStatusPersist()
|
||||
@@ -1775,7 +1985,7 @@ export class AgentHookServer {
|
||||
if (entry.connectionId !== normalizedConnectionId) {
|
||||
continue
|
||||
}
|
||||
const deleted = this.deleteStatusEntry(paneKey)
|
||||
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
|
||||
if (deleted) {
|
||||
statusChanged = true
|
||||
if (deleted.payload.agentType === 'codex') {
|
||||
@@ -1785,6 +1995,11 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [paneKey, evidence] of this.currentAuthorityObservations) {
|
||||
if (evidence.connectionId === normalizedConnectionId) {
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
}
|
||||
}
|
||||
if (statusChanged) {
|
||||
// Why: persist/notify once — one disconnect can own many panes.
|
||||
this.scheduleStatusPersist()
|
||||
@@ -1798,7 +2013,10 @@ export class AgentHookServer {
|
||||
})
|
||||
}
|
||||
|
||||
private deleteStatusEntry(paneKey: string): EnrichedAgentHookEventPayload | null {
|
||||
private deleteStatusEntry(
|
||||
paneKey: string,
|
||||
options?: { preserveAuthority?: boolean }
|
||||
): EnrichedAgentHookEventPayload | null {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
@@ -1807,9 +2025,14 @@ export class AgentHookServer {
|
||||
return null
|
||||
}
|
||||
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
||||
if (!options?.preserveAuthority) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
if (existing.payload.state === 'done') {
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
@@ -1854,6 +2077,11 @@ export class AgentHookServer {
|
||||
paneKeysToClear.add(paneKey)
|
||||
}
|
||||
}
|
||||
for (const commitment of this.hydratedAuthorityCommitments) {
|
||||
if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) {
|
||||
paneKeysToClear.add(commitment.paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
let aliasChanged = false
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
@@ -1867,6 +2095,7 @@ export class AgentHookServer {
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear)
|
||||
|
||||
let statusChanged = false
|
||||
for (const paneKey of paneKeysToClear) {
|
||||
@@ -1877,12 +2106,13 @@ export class AgentHookServer {
|
||||
this.clearCodexSubagentPoll(paneKey)
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (statusChanged) {
|
||||
if (statusChanged || authorityChanged) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1890,25 +2120,31 @@ export class AgentHookServer {
|
||||
|
||||
clearPaneState(paneKey: string): void {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const paneKeys = new Set([paneKey, resolvedPaneKey])
|
||||
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
|
||||
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
clearPaneCacheState(this.state, resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
let clearedAlias = false
|
||||
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
|
||||
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
paneKeys.add(legacyPaneKey)
|
||||
paneKeys.add(stablePaneKey.stablePaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
clearedAlias = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
|
||||
if (clearedAlias) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (hadStatus) {
|
||||
if (hadStatus || authorityChanged) {
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
@@ -1963,6 +2199,8 @@ export class AgentHookServer {
|
||||
}
|
||||
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
|
||||
this.state.lastStatusByPaneKey.clear()
|
||||
this.hydratedLaunchTokenHashByPaneKey.clear()
|
||||
this.persistedAuthorityCommitmentsByPaneKey.clear()
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(this.lastStatusFilePath, 'utf8')
|
||||
@@ -2001,6 +2239,7 @@ export class AgentHookServer {
|
||||
let hydrated = 0
|
||||
let dropped = 0
|
||||
let prunedLegacyClaudeSubagents = 0
|
||||
let scrubbedLegacyLaunchTokens = 0
|
||||
// Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
|
||||
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
|
||||
for (const [paneKey, rawEntry] of Object.entries(entries)) {
|
||||
@@ -2011,6 +2250,21 @@ export class AgentHookServer {
|
||||
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
|
||||
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
|
||||
if (entry && entry.receivedAt >= ttlCutoff) {
|
||||
const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry)
|
||||
if (launchTokenHash) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash)
|
||||
const evidence = this.toAuthorityEvidence(entry, launchTokenHash)
|
||||
if (evidence) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence)
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof rawResolvedEntry === 'object' &&
|
||||
rawResolvedEntry !== null &&
|
||||
typeof (rawResolvedEntry as Record<string, unknown>).launchToken === 'string'
|
||||
) {
|
||||
scrubbedLegacyLaunchTokens += 1
|
||||
}
|
||||
const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload)
|
||||
if (hydratedPayload !== entry.payload) {
|
||||
prunedLegacyClaudeSubagents +=
|
||||
@@ -2041,13 +2295,30 @@ export class AgentHookServer {
|
||||
dropped += 1
|
||||
}
|
||||
}
|
||||
for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment)
|
||||
if (!commitment || commitment.observedAt < ttlCutoff) {
|
||||
dropped += 1
|
||||
continue
|
||||
}
|
||||
const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey)
|
||||
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
dropped += 1
|
||||
continue
|
||||
}
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash)
|
||||
}
|
||||
if (dropped > 0) {
|
||||
console.warn(
|
||||
`[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})`
|
||||
)
|
||||
}
|
||||
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
|
||||
// Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch.
|
||||
if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) {
|
||||
// Why: persist load-time pruning and bearer scrubbing once.
|
||||
this.runStatusPersist()
|
||||
} else if (hydrated > 0) {
|
||||
// Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
|
||||
@@ -2055,17 +2326,92 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
private captureHydratedAuthorityCommitments(): void {
|
||||
this.revokedHydratedAuthorityCommitments = new WeakSet()
|
||||
for (const entry of this.state.lastStatusByPaneKey.values()) {
|
||||
const evidence = this.toAuthorityEvidence(
|
||||
entry as EnrichedAgentHookEventPayload,
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey)
|
||||
)
|
||||
if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence)
|
||||
}
|
||||
}
|
||||
this.hydratedAuthorityCommitments = Object.freeze(
|
||||
Array.from(this.persistedAuthorityCommitmentsByPaneKey.values())
|
||||
)
|
||||
}
|
||||
|
||||
private recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void {
|
||||
const evidence = this.toAuthorityEvidence(payload)
|
||||
if (evidence) {
|
||||
this.currentAuthorityObservations.set(evidence.paneKey, evidence)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash)
|
||||
}
|
||||
}
|
||||
|
||||
private toAuthorityEvidence(
|
||||
payload: AgentHookEventPayload | EnrichedAgentHookEventPayload,
|
||||
launchTokenHashOverride?: string
|
||||
): AgentHookAuthorityEvidence | null {
|
||||
const launchToken = payload.launchToken?.trim()
|
||||
const launchTokenHash =
|
||||
launchTokenHashOverride ??
|
||||
(launchToken ? createHash('sha256').update(launchToken).digest('hex') : null)
|
||||
if (!launchTokenHash) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({
|
||||
paneKey: payload.paneKey,
|
||||
launchTokenHash,
|
||||
connectionId: payload.connectionId,
|
||||
...(payload.tabId ? { tabId: payload.tabId } : {}),
|
||||
...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}),
|
||||
observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
private serializeStatusFile(): string {
|
||||
const entries: Record<string, EnrichedAgentHookEventPayload> = {}
|
||||
const entries: Record<string, PersistedAgentHookEventPayload> = {}
|
||||
const authorityCommitments: Record<string, PersistedAgentHookAuthorityCommitment> = {}
|
||||
const conflictedCommitments = new Set<string>()
|
||||
for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) {
|
||||
authorityCommitments[paneKey] = { ...commitment }
|
||||
}
|
||||
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
|
||||
// Why: never persist invalid keys (matches the hydrate-path invariant).
|
||||
if (!isValidPaneKey(paneKey)) {
|
||||
continue
|
||||
}
|
||||
const { promptInteractionKey: _promptInteractionKey, ...persistedPayload } = payload
|
||||
entries[paneKey] = persistedPayload as EnrichedAgentHookEventPayload
|
||||
const {
|
||||
promptInteractionKey: _promptInteractionKey,
|
||||
launchToken,
|
||||
...persistedPayload
|
||||
} = payload as EnrichedAgentHookEventPayload
|
||||
const launchTokenHash = launchToken?.trim()
|
||||
? createHash('sha256').update(launchToken.trim()).digest('hex')
|
||||
: this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
|
||||
entries[paneKey] = {
|
||||
...persistedPayload,
|
||||
...(launchTokenHash ? { launchTokenHash } : {})
|
||||
}
|
||||
const commitment = this.toAuthorityEvidence(payload, launchTokenHash)
|
||||
if (commitment && !conflictedCommitments.has(paneKey)) {
|
||||
const existing = authorityCommitments[paneKey]
|
||||
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
|
||||
delete authorityCommitments[paneKey]
|
||||
conflictedCommitments.add(paneKey)
|
||||
} else {
|
||||
authorityCommitments[paneKey] = { ...commitment }
|
||||
}
|
||||
}
|
||||
}
|
||||
const file: LastStatusFile = {
|
||||
version: LAST_STATUS_FILE_VERSION,
|
||||
entries,
|
||||
authorityCommitments
|
||||
}
|
||||
const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries }
|
||||
return JSON.stringify(file)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,41 @@ describe('packaged Windows CLI launcher asset', () => {
|
||||
expect(launcher).toContain('orca.cmd cannot safely forward orchestration message bodies')
|
||||
expect(launcher).not.toContain('"%ELECTRON%" "%CLI%" %*')
|
||||
})
|
||||
|
||||
it('marks the packaged child and propagates its exact exit status', () => {
|
||||
const sourcePath = join(process.cwd(), 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
|
||||
const source = readFileSync(sourcePath, 'utf8')
|
||||
|
||||
expect(source).toContain(
|
||||
'startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";'
|
||||
)
|
||||
expect(source).toContain('Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"')
|
||||
expect(source).toContain('? "orca-ide"')
|
||||
expect(source).toContain(': "orca";')
|
||||
expect(source).toContain('child.WaitForExit();')
|
||||
expect(source).toContain('return child.ExitCode;')
|
||||
})
|
||||
|
||||
it('requires exact updater process and executable release proof', () => {
|
||||
const includePath = join(process.cwd(), 'config', 'nsis', 'daemon-host-uninstall.nsh')
|
||||
const source = readFileSync(includePath, 'utf8')
|
||||
|
||||
expect(source).toContain('!macro customCheckAppRunning')
|
||||
expect(source).toContain('!include "getProcessInfo.nsh"')
|
||||
expect(source).toContain('Var pid')
|
||||
expect(source).toContain('[String]::Equals')
|
||||
expect(source).toContain('"$INSTDIR\\Orca.exe" "$INSTDIR\\resources\\bin\\orca.exe"')
|
||||
expect(source).toContain('CreationDate=[string]$$parent.CreationDate')
|
||||
expect(source).toContain("'ProcessId=' + $$proof.ProcessId")
|
||||
expect(source).toContain('[IO.FileShare]::None')
|
||||
expect(source).toContain('$$released=$$false')
|
||||
expect(source).toContain('$$released=$$true')
|
||||
expect(source).not.toMatch(/(?<!\$)(?:\$\$)*\$(?:false|true)\b/)
|
||||
expect(source).toContain(
|
||||
' ${else}\n !insertmacro IS_POWERSHELL_AVAILABLE\n !insertmacro _CHECK_APP_RUNNING\n ${endIf}'
|
||||
)
|
||||
expect(source).toContain('SetErrorLevel 2')
|
||||
expect(source).toContain('Quit')
|
||||
expect(source).not.toContain('taskkill /IM Orca.exe')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -299,6 +299,16 @@ export class HeadlessEmulator {
|
||||
return lines
|
||||
}
|
||||
|
||||
getBufferTailLines(limit: number): string[] {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const start = Math.max(0, buffer.length - Math.max(0, Math.floor(limit)))
|
||||
const lines: string[] = []
|
||||
for (let row = start; row < buffer.length; row += 1) {
|
||||
lines.push(buffer.getLine(row)?.translateToString(true) ?? '')
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
getCwd(): string | null {
|
||||
return this.oscText.cwd
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WslModule from '../wsl'
|
||||
|
||||
vi.mock('../wsl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslModule>()),
|
||||
getDefaultWslDistro: () => 'Ubuntu'
|
||||
}))
|
||||
|
||||
import { TerminalHost } from './terminal-host'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { resolveWslSessionContext } from './wsl-session-context'
|
||||
@@ -60,6 +67,40 @@ describe('TerminalHost WSL context', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('persists the resolved default distro across daemon attaches', async () => {
|
||||
const spawnSubprocess = vi.fn(() => createSubprocess())
|
||||
host = new TerminalHost({ spawnSubprocess })
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
const created = await host.createOrAttach({
|
||||
sessionId: 'session-default-wsl',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null,
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
const attached = await host.createOrAttach({
|
||||
sessionId: 'session-default-wsl',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian',
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
|
||||
expect(created.wslDistro).toBe('Ubuntu')
|
||||
expect(attached.wslDistro).toBe('Ubuntu')
|
||||
expect(spawnSubprocess).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns authoritative null when a native session is attached with a WSL preference', async () => {
|
||||
const spawnSubprocess = vi.fn(() => createSubprocess())
|
||||
host = new TerminalHost({ spawnSubprocess })
|
||||
@@ -118,6 +159,13 @@ describe('TerminalHost WSL context', () => {
|
||||
terminalWindowsWslDistro: ' Ubuntu '
|
||||
})
|
||||
).toEqual({ distro: 'Ubuntu', treatPosixCwdAsWsl: true })
|
||||
expect(
|
||||
resolveWslSessionContext({
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null
|
||||
})
|
||||
).toEqual({ distro: 'Ubuntu', treatPosixCwdAsWsl: true })
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import { isWslShellName } from '../../shared/local-windows-terminal-runtime'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { getDefaultWslDistro, parseWslPath } from '../wsl'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
|
||||
@@ -41,7 +41,8 @@ export function resolveWslSessionContext(args: {
|
||||
return (
|
||||
(args.sessionId ? getWslContextFromSessionId(args.sessionId) : undefined) ??
|
||||
(isWslShellName(args.shellOverride)
|
||||
? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro)
|
||||
? (getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) ??
|
||||
getWslContextFromPreferredDistro(getDefaultWslDistro()))
|
||||
: undefined)
|
||||
)
|
||||
}
|
||||
|
||||
+30
-2
@@ -30,7 +30,12 @@ import {
|
||||
getSshPtyProvider,
|
||||
registerHeadlessPtyRuntime
|
||||
} from './ipc/pty'
|
||||
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
|
||||
import {
|
||||
initDaemonPtyProvider,
|
||||
disconnectDaemon,
|
||||
getDaemonProvider,
|
||||
shutdownDaemon
|
||||
} from './daemon/daemon-init'
|
||||
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
|
||||
import { registerCoreHandlers } from './ipc/register-core-handlers'
|
||||
@@ -132,6 +137,7 @@ import {
|
||||
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
|
||||
import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect'
|
||||
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
|
||||
import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery'
|
||||
import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
|
||||
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
|
||||
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
|
||||
@@ -724,10 +730,24 @@ if (hasSingleInstanceLock) {
|
||||
}
|
||||
|
||||
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
|
||||
// Why: restored WSL terminals get a bounded chance to receive launcher repairs before window rendering proceeds.
|
||||
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
|
||||
})
|
||||
|
||||
ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () =>
|
||||
recoverLegacyWorkerTerminalsForRendererStartup({
|
||||
firstWindowStartupServicesReady,
|
||||
managedWslCliStartupBarrierReady,
|
||||
localPtyProviderStartupReady,
|
||||
reconcile: async () => {
|
||||
await runtime?.refreshRestoredOrchestrationAuthority()
|
||||
return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })
|
||||
},
|
||||
onDeferredRecoveryError: (error) => {
|
||||
console.warn('[orchestration] legacy worker provider-ready recovery failed', error)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost.
|
||||
ipcMain.handle('ui:consumePendingOpenSettings', (event) =>
|
||||
pendingOpenSettings.matches(event.sender.id, { consume: true })
|
||||
@@ -2207,6 +2227,11 @@ void app.whenReady().then(async () => {
|
||||
getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(),
|
||||
getAgentProviderSessionRowsForPane: (paneKey) =>
|
||||
agentHookServer.getStatusSnapshotForPane(paneKey),
|
||||
attestAgentHookCompatibilityAuthority: (candidate) =>
|
||||
agentHookServer.attestCompatibilityAuthority(candidate),
|
||||
retireAgentHookCompatibilityAuthority: (paneKey) =>
|
||||
agentHookServer.retirePaneAuthority(paneKey),
|
||||
canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null,
|
||||
// Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only.
|
||||
getAdditionalAiVaultCodexHomePaths: () =>
|
||||
codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [],
|
||||
@@ -2222,6 +2247,7 @@ void app.whenReady().then(async () => {
|
||||
orchestrationEnvironmentTransport
|
||||
})
|
||||
runtime = runtimeService
|
||||
runtimeService.prepareLegacyWorkerTerminalRecovery()
|
||||
publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities())
|
||||
browserManager.setBrowserGuestStateChangedListener((worktreeId) => {
|
||||
runtimeService.notifyMobileSessionTabsChanged(worktreeId)
|
||||
@@ -2664,6 +2690,8 @@ void app.whenReady().then(async () => {
|
||||
store,
|
||||
prepareCodexSessionResumeForLaunch
|
||||
)
|
||||
await runtime.refreshRestoredOrchestrationAuthority()
|
||||
await runtime.reconcileLegacyWorkerTerminals()
|
||||
// Why: headless servers can't mount <webview> panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest.
|
||||
if (headlessBrowserDisplayAvailable) {
|
||||
runtime.setOffscreenBrowserBackend(new OffscreenBrowserBackend(browserManager))
|
||||
|
||||
@@ -3599,6 +3599,64 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves default WSL authority before daemon host env and spawn metadata', async () => {
|
||||
await withWin32Platform(async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
const daemonSpawn = setupDaemonAdapter()
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn(),
|
||||
preparePtyExecutionContext: vi.fn().mockReturnValue(true),
|
||||
getOrchestrationCompatibilityHostId: vi.fn(() => 'compat-host')
|
||||
}
|
||||
const settings = {
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null,
|
||||
terminalWindowsPowerShellImplementation: 'auto'
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
(() => settings) as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
spawn(args: {
|
||||
cols: number
|
||||
rows: number
|
||||
cwd?: string
|
||||
worktreeId?: string
|
||||
env?: Record<string, string>
|
||||
}): Promise<{ id: string }>
|
||||
}
|
||||
|
||||
await controller.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo',
|
||||
worktreeId: 'repo-1::C:\\repo',
|
||||
env: {}
|
||||
})
|
||||
|
||||
const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall
|
||||
expect(spawnOptions.terminalWindowsWslDistro).toBe('Ubuntu')
|
||||
expect(spawnOptions.env).toMatchObject({
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'compat-host',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
|
||||
})
|
||||
expect(runtime.preparePtyExecutionContext).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'Ubuntu',
|
||||
expect.objectContaining({ resetIncarnation: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes an attached native context from an older daemon fallback', async () => {
|
||||
await withWin32Platform(async () => {
|
||||
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
|
||||
@@ -5374,6 +5432,37 @@ describe('registerPtyHandlers', () => {
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scopes runtime inventories to the requested provider', async () => {
|
||||
const localList = vi
|
||||
.spyOn(getLocalPtyProvider(), 'listProcesses')
|
||||
.mockResolvedValue([{ id: 'local-pty', title: 'Local', cwd: '/local' }])
|
||||
const sshAList = vi.fn(async () => [{ id: 'ssh-a-pty' }])
|
||||
const sshBList = vi.fn(async () => {
|
||||
throw new Error('ssh-b unavailable')
|
||||
})
|
||||
registerSshPtyProvider('ssh-a', { listProcesses: sshAList } as never)
|
||||
registerSshPtyProvider('ssh-b', { listProcesses: sshBList } as never)
|
||||
const runtime = { setPtyController: vi.fn() }
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
listProcesses(connectionId?: string | null): Promise<{ id: string }[]>
|
||||
}
|
||||
|
||||
await expect(controller.listProcesses(null)).resolves.toEqual([
|
||||
{ id: 'local-pty', title: 'Local', cwd: '/local' }
|
||||
])
|
||||
expect(localList).toHaveBeenCalledOnce()
|
||||
expect(sshAList).not.toHaveBeenCalled()
|
||||
expect(sshBList).not.toHaveBeenCalled()
|
||||
|
||||
await expect(controller.listProcesses('ssh-a')).resolves.toEqual([{ id: 'ssh-a-pty' }])
|
||||
expect(sshAList).toHaveBeenCalledOnce()
|
||||
expect(sshBList).not.toHaveBeenCalled()
|
||||
|
||||
await expect(controller.listProcesses()).rejects.toThrow('ssh-b unavailable')
|
||||
})
|
||||
|
||||
it('returns unavailable runtime confirmation for unsupported or missing providers', async () => {
|
||||
registerSshPtyProvider('ssh-1', {} as never)
|
||||
setPtyOwnership('unsupported-pty', 'ssh-1')
|
||||
|
||||
+72
-54
@@ -140,7 +140,7 @@ import {
|
||||
} from '../agent-hooks/migration-unsupported-pty-state'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { mergePersistedWindowsPath } from '../pty/windows-environment-path'
|
||||
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
|
||||
import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env'
|
||||
import { PtyProducerFlowController } from './pty-producer-flow-control'
|
||||
import { beginTerminalInstall } from './watcher-removal-gate'
|
||||
import {
|
||||
@@ -1787,6 +1787,11 @@ export function registerPtyHandlers(
|
||||
if (preAllocatedHandle) {
|
||||
env.ORCA_TERMINAL_HANDLE = preAllocatedHandle
|
||||
}
|
||||
stampWslOrchestrationCompatibilityHost(
|
||||
env,
|
||||
runtime?.getOrchestrationCompatibilityHostId?.(),
|
||||
ctx?.isWsl === true ? ctx.wslDistro : null
|
||||
)
|
||||
if (ctx?.isWsl === true) {
|
||||
addOrcaWslInteropEnv(env)
|
||||
}
|
||||
@@ -3362,10 +3367,35 @@ export function registerPtyHandlers(
|
||||
})
|
||||
: { shellOverride: undefined, terminalWindowsWslDistro: null }
|
||||
const daemonShellOverride = terminalRuntimeOptions.shellOverride
|
||||
const isDaemonHostSpawn =
|
||||
!args.connectionId &&
|
||||
!(provider instanceof LocalPtyProvider) &&
|
||||
!routesFreshSpawnsToLocalProvider(provider)
|
||||
const callerRequestedSessionId = args.sessionId?.trim()
|
||||
const requestedSessionId =
|
||||
callerRequestedSessionId ??
|
||||
(isDaemonHostSpawn && args.agentSessionCreateOperationId
|
||||
? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId)
|
||||
: undefined)
|
||||
const sessionId =
|
||||
requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
|
||||
const effectiveSessionRelayId =
|
||||
sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined
|
||||
const effectiveSessionAppId =
|
||||
sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined
|
||||
const isMintedSessionId = callerRequestedSessionId === undefined && isDaemonHostSpawn
|
||||
const expectedWslDistro = !args.connectionId
|
||||
? (resolveWslSessionContext({
|
||||
cwd,
|
||||
sessionId,
|
||||
shellOverride: terminalRuntimeOptions.shellOverride,
|
||||
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null)
|
||||
: null
|
||||
const codexSelectionTarget = getCodexSelectionTargetForPty(
|
||||
daemonShellOverride,
|
||||
cwd,
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
expectedWslDistro
|
||||
)
|
||||
const codexResumePreparation = prepareCodexResumeHome({
|
||||
connectionId: args.connectionId,
|
||||
@@ -3393,31 +3423,6 @@ export function registerPtyHandlers(
|
||||
)
|
||||
}
|
||||
|
||||
const isDaemonHostSpawn =
|
||||
!args.connectionId &&
|
||||
!(provider instanceof LocalPtyProvider) &&
|
||||
!routesFreshSpawnsToLocalProvider(provider)
|
||||
const callerRequestedSessionId = args.sessionId?.trim()
|
||||
const requestedSessionId =
|
||||
callerRequestedSessionId ??
|
||||
(isDaemonHostSpawn && args.agentSessionCreateOperationId
|
||||
? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId)
|
||||
: undefined)
|
||||
const sessionId =
|
||||
requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
|
||||
const effectiveSessionRelayId =
|
||||
sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined
|
||||
const effectiveSessionAppId =
|
||||
sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined
|
||||
const isMintedSessionId = callerRequestedSessionId === undefined && isDaemonHostSpawn
|
||||
const expectedWslDistro = !args.connectionId
|
||||
? (resolveWslSessionContext({
|
||||
cwd,
|
||||
sessionId,
|
||||
shellOverride: terminalRuntimeOptions.shellOverride,
|
||||
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null)
|
||||
: null
|
||||
const shouldPersistHostSessionBinding = args.persistHostSessionBinding === true
|
||||
let hostSessionBinding: {
|
||||
store: NonNullable<typeof store>
|
||||
@@ -3492,11 +3497,16 @@ export function registerPtyHandlers(
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
shellPath: daemonShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
networkProxySettings: getSettings?.(),
|
||||
deferGitConfigGuardToDaemon: provider.supportsGitCredentialGuardHost?.(sessionId) === true
|
||||
})
|
||||
stampWslOrchestrationCompatibilityHost(
|
||||
env,
|
||||
runtime?.getOrchestrationCompatibilityHostId?.(),
|
||||
codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null
|
||||
)
|
||||
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
|
||||
}
|
||||
|
||||
@@ -3596,8 +3606,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
if (process.platform === 'win32' && !args.connectionId) {
|
||||
spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride
|
||||
spawnOptions.terminalWindowsWslDistro =
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
spawnOptions.terminalWindowsWslDistro = expectedWslDistro
|
||||
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
|
||||
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||||
: undefined
|
||||
@@ -4243,7 +4252,13 @@ export function registerPtyHandlers(
|
||||
return null
|
||||
}
|
||||
},
|
||||
listProcesses: async () => {
|
||||
listProcesses: async (connectionId) => {
|
||||
if (connectionId === null) {
|
||||
return localProvider.listProcesses()
|
||||
}
|
||||
if (connectionId !== undefined) {
|
||||
return getProvider(connectionId).listProcesses()
|
||||
}
|
||||
const providerSessions = await Promise.all([
|
||||
localProvider.listProcesses(),
|
||||
...Array.from(sshProviders.values(), (provider) => provider.listProcesses())
|
||||
@@ -4450,24 +4465,6 @@ export function registerPtyHandlers(
|
||||
})
|
||||
: { shellOverride: args.shellOverride, terminalWindowsWslDistro: null }
|
||||
const initialShellOverride = terminalRuntimeOptions.shellOverride
|
||||
const initialSelectionTarget = getCodexSelectionTargetForPty(
|
||||
initialShellOverride,
|
||||
cwd,
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
)
|
||||
const claudeAuth =
|
||||
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null
|
||||
spawnTiming.mark('auth')
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
}
|
||||
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||||
throw new Error(
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
)
|
||||
}
|
||||
// Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity.
|
||||
// Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host.
|
||||
const isDaemonHostSpawn =
|
||||
!args.connectionId &&
|
||||
!(provider instanceof LocalPtyProvider) &&
|
||||
@@ -4494,6 +4491,24 @@ export function registerPtyHandlers(
|
||||
terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro
|
||||
})?.distro ?? null)
|
||||
: null
|
||||
const initialSelectionTarget = getCodexSelectionTargetForPty(
|
||||
initialShellOverride,
|
||||
cwd,
|
||||
expectedWslDistro
|
||||
)
|
||||
const claudeAuth =
|
||||
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(initialSelectionTarget) : null
|
||||
spawnTiming.mark('auth')
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
throw new Error('A Claude account switch is in progress. Try again after it finishes.')
|
||||
}
|
||||
if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) {
|
||||
throw new Error(
|
||||
'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.'
|
||||
)
|
||||
}
|
||||
// Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity.
|
||||
// Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host.
|
||||
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
|
||||
// Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route.
|
||||
const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env)
|
||||
@@ -4603,7 +4618,7 @@ export function registerPtyHandlers(
|
||||
const codexSelectionTarget = getCodexSelectionTargetForPty(
|
||||
effectiveShellOverride,
|
||||
cwd,
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
expectedWslDistro
|
||||
)
|
||||
const codexResumePreparation = prepareCodexResumeHome({
|
||||
connectionId: args.connectionId,
|
||||
@@ -4669,13 +4684,17 @@ export function registerPtyHandlers(
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
|
||||
wslDistro:
|
||||
codexSelectionTarget.runtime === 'wsl' ? codexSelectionTarget.wslDistro : null,
|
||||
wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null,
|
||||
agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()),
|
||||
networkProxySettings: getSettings?.(),
|
||||
deferGitConfigGuardToDaemon:
|
||||
provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true
|
||||
})
|
||||
stampWslOrchestrationCompatibilityHost(
|
||||
env,
|
||||
runtime?.getOrchestrationCompatibilityHostId?.(),
|
||||
codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null
|
||||
)
|
||||
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
|
||||
} catch (err) {
|
||||
// Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs.
|
||||
@@ -4762,8 +4781,7 @@ export function registerPtyHandlers(
|
||||
}
|
||||
if (process.platform === 'win32' && !args.connectionId) {
|
||||
// Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable.
|
||||
spawnOptions.terminalWindowsWslDistro =
|
||||
terminalRuntimeOptions.terminalWindowsWslDistro ?? null
|
||||
spawnOptions.terminalWindowsWslDistro = expectedWslDistro
|
||||
spawnOptions.terminalWindowsPowerShellImplementation = getSettings
|
||||
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
|
||||
: undefined
|
||||
|
||||
@@ -3691,6 +3691,21 @@ describe('Store', () => {
|
||||
expect(hostSession.lastVisitedAtByWorktreeId?.['r1::/path/wt1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists only persisted workspace-session host partitions', async () => {
|
||||
const store = await createStore()
|
||||
expect(store.getWorkspaceSessionHostIds()).toEqual(['local'])
|
||||
|
||||
store.getWorkspaceSession('ssh:not-persisted')
|
||||
store.setWorkspaceSession(getDefaultWorkspaceSession(), 'ssh:ssh-a')
|
||||
store.setWorkspaceSession(getDefaultWorkspaceSession(), 'runtime:environment-a')
|
||||
|
||||
expect(store.getWorkspaceSessionHostIds()).toEqual([
|
||||
'local',
|
||||
'ssh:ssh-a',
|
||||
'runtime:environment-a'
|
||||
])
|
||||
})
|
||||
|
||||
it('removeProject removes the derived project host setup compatibility record', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ id: 'r1' }))
|
||||
|
||||
@@ -5769,6 +5769,17 @@ export class Store {
|
||||
return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession()
|
||||
}
|
||||
|
||||
getWorkspaceSessionHostIds(): ExecutionHostId[] {
|
||||
const hostIds = new Set<ExecutionHostId>([LOCAL_EXECUTION_HOST_ID])
|
||||
for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) {
|
||||
const hostId = normalizeExecutionHostId(key)
|
||||
if (hostId) {
|
||||
hostIds.add(hostId)
|
||||
}
|
||||
}
|
||||
return [...hostIds]
|
||||
}
|
||||
|
||||
readTerminalScrollbackSnapshot(ref: string): string | null {
|
||||
return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ vi.mock('../wsl', () => ({
|
||||
toLinuxPath: (path: string) => path.replace(/^C:\\/i, '/mnt/c/').replace(/\\/g, '/'),
|
||||
toWindowsWslPath: (path: string, distro: string) =>
|
||||
`\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}`,
|
||||
getDefaultWslDistro: () => 'Ubuntu',
|
||||
isWslAvailable: () => true,
|
||||
// Why: WSL worktree validation now asks the distro; these tests use WSL UNC
|
||||
// cwds that are meant to exist, so report them present without spawning wsl.exe.
|
||||
@@ -880,6 +881,45 @@ describe('LocalPtyProvider', () => {
|
||||
expect(spawnCall[2].env.HISTFILE).toContain('terminal-history-wsl/Debian')
|
||||
})
|
||||
|
||||
it('resolves and persists the default distro for Windows cwd WSL terminals', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const buildSpawnEnv = vi.fn(
|
||||
(
|
||||
_id: string,
|
||||
env: Record<string, string>,
|
||||
_ctx?: { isWsl?: boolean; wslDistro?: string | null }
|
||||
) => env
|
||||
)
|
||||
provider.configure({ buildSpawnEnv })
|
||||
|
||||
const result = await provider.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::C:\\Users\\jin\\repo',
|
||||
cwd: 'C:\\Users\\jin\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
terminalWindowsWslDistro: null
|
||||
})
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[1]).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining("cd '/mnt/c/Users/jin/repo'")
|
||||
])
|
||||
expect(buildSpawnEnv.mock.calls[0]?.[2]).toMatchObject({
|
||||
isWsl: true,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(result.wslDistro).toBe('Ubuntu')
|
||||
expect(
|
||||
(await provider.listProcesses()).find((entry) => entry.id === result.id)?.wslDistro
|
||||
).toBe('Ubuntu')
|
||||
})
|
||||
|
||||
it('repro: keeps explicit PowerShell 7 selection when the pwsh probe is cold-false', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const pwshAvailable = vi.fn(() => false)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { buildWindowsPowerShellSpawnAttempts } from './windows-shell-fallback-ch
|
||||
import { resolveProcessCwd } from './process-cwd'
|
||||
import { existsSync } from 'node:fs'
|
||||
import * as pty from 'node-pty'
|
||||
import { parseWslPath, isWslAvailable } from '../wsl'
|
||||
import { getDefaultWslDistro, parseWslPath, isWslAvailable } from '../wsl'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import {
|
||||
injectHistoryEnv,
|
||||
@@ -547,6 +547,10 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
process.platform === 'win32'
|
||||
? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro)
|
||||
: undefined
|
||||
let launchWslContext =
|
||||
wslInfo !== null
|
||||
? getWslContextFromPreferredDistro(wslInfo.distro)
|
||||
: (worktreeWslContext ?? preferredWslContext)
|
||||
|
||||
let shellPath: string
|
||||
let shellArgs: string[]
|
||||
@@ -572,6 +576,9 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
process.env.COMSPEC ||
|
||||
'powershell.exe'
|
||||
const shellFamily = worktreeWslContext ? 'wsl.exe' : requestedShellFamily
|
||||
if (!launchWslContext && pathWin32.basename(shellFamily).toLowerCase() === 'wsl.exe') {
|
||||
launchWslContext = getWslContextFromPreferredDistro(getDefaultWslDistro())
|
||||
}
|
||||
const normalizedShellFamily = pathWin32.basename(shellFamily).toLowerCase()
|
||||
const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellFamily)
|
||||
// Why: normalize setting-value and path forms to the PowerShell family so the resolver can fall back to inbox powershell.exe.
|
||||
@@ -606,7 +613,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
shellPath,
|
||||
cwd,
|
||||
defaultCwd,
|
||||
wslContext: worktreeWslContext ?? preferredWslContext,
|
||||
wslContext: launchWslContext,
|
||||
startupCommand: args.command
|
||||
})
|
||||
const primaryAttempt = windowsFallbackAttempts[0]
|
||||
@@ -621,7 +628,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
shellPath,
|
||||
cwd,
|
||||
defaultCwd,
|
||||
worktreeWslContext ?? preferredWslContext,
|
||||
launchWslContext,
|
||||
args.command
|
||||
)
|
||||
shellArgs = resolved.shellArgs
|
||||
@@ -672,8 +679,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
}
|
||||
|
||||
const isWslShell = Boolean(wslInfo) || pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe'
|
||||
const launchWslDistro =
|
||||
wslInfo?.distro ?? worktreeWslContext?.distro ?? preferredWslContext?.distro ?? null
|
||||
const launchWslDistro = isWslShell ? (launchWslContext?.distro ?? null) : null
|
||||
const finalEnv = this.opts.buildSpawnEnv
|
||||
? this.opts.buildSpawnEnv(id, spawnEnv, {
|
||||
command: args.command,
|
||||
@@ -796,7 +802,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
let historyResult: ReturnType<typeof injectHistoryEnv> | null = null
|
||||
if (historyEnabled) {
|
||||
historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, {
|
||||
wslDistro: preferredWslContext?.distro ?? worktreeWslContext?.distro ?? null
|
||||
wslDistro: launchWslDistro
|
||||
})
|
||||
logHistoryInjection(worktreeId, historyResult)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addOrcaWslInteropEnv, addWorktreeSetupWslInteropEnv } from './wsl-orca-env'
|
||||
import {
|
||||
addOrcaWslInteropEnv,
|
||||
addWorktreeSetupWslInteropEnv,
|
||||
stampWslOrchestrationCompatibilityHost
|
||||
} from './wsl-orca-env'
|
||||
|
||||
describe('addOrcaWslInteropEnv', () => {
|
||||
it('marks the Orca terminal handle for Windows to WSL env import', () => {
|
||||
@@ -29,10 +33,14 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
ORCA_PANE_KEY: 'tab-1:leaf-1',
|
||||
ORCA_TAB_ID: 'tab-1',
|
||||
ORCA_WORKTREE_ID: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret',
|
||||
ORCA_AGENT_HOOK_PORT: '4567',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_AGENT_HOOK_ENV: 'dev',
|
||||
ORCA_AGENT_HOOK_VERSION: '1'
|
||||
ORCA_AGENT_HOOK_VERSION: '1',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
|
||||
}
|
||||
|
||||
addOrcaWslInteropEnv(env)
|
||||
@@ -44,10 +52,44 @@ describe('addOrcaWslInteropEnv', () => {
|
||||
expect(env.WSLENV).toContain('ORCA_PANE_KEY/u')
|
||||
expect(env.WSLENV).toContain('ORCA_TAB_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_WORKTREE_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_LAUNCH_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_PORT/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_TOKEN/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_ENV/u')
|
||||
expect(env.WSLENV).toContain('ORCA_AGENT_HOOK_VERSION/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u')
|
||||
expect(env.WSLENV).toContain('ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u')
|
||||
})
|
||||
|
||||
it('overwrites caller host evidence with native runtime WSL authority', () => {
|
||||
const env = {
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment'
|
||||
}
|
||||
|
||||
stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu')
|
||||
|
||||
expect(env).toEqual({
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu'
|
||||
})
|
||||
})
|
||||
|
||||
it('clears inherited host evidence outside a runtime-owned WSL scope', () => {
|
||||
const env = {
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'ssh',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'caller-host',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'caller-incarnation',
|
||||
ORCA_ORCHESTRATION_COMPATIBILITY_ATTACHMENT: 'caller-attachment'
|
||||
}
|
||||
|
||||
stampWslOrchestrationCompatibilityHost(env, 'local', null)
|
||||
|
||||
expect(env).toEqual({})
|
||||
})
|
||||
|
||||
it('path-translates a Windows hook endpoint but passes a guest-side one untouched', () => {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
|
||||
} from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
const WSLENV_ENTRY_SEPARATOR = ':'
|
||||
|
||||
function parseWslenvEntries(value: string | undefined): string[] {
|
||||
@@ -70,6 +77,9 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
'ORCA_TAB_ID/u',
|
||||
'ORCA_WORKTREE_ID/u',
|
||||
'ORCA_AGENT_LAUNCH_TOKEN/u',
|
||||
'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u',
|
||||
'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID/u',
|
||||
'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u',
|
||||
'ORCA_AGENT_HOOK_PORT/u',
|
||||
'ORCA_AGENT_HOOK_TOKEN/u',
|
||||
'ORCA_AGENT_HOOK_ENV/u',
|
||||
@@ -84,3 +94,22 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
]
|
||||
applyWslenvPassthrough(env, passthroughEntries)
|
||||
}
|
||||
|
||||
export function stampWslOrchestrationCompatibilityHost(
|
||||
env: Record<string, string>,
|
||||
hostId: string | null | undefined,
|
||||
distro: string | null | undefined
|
||||
): void {
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]
|
||||
const normalizedHostId = hostId?.trim()
|
||||
const normalizedDistro = distro?.trim()
|
||||
if (!normalizedHostId || !normalizedDistro) {
|
||||
return
|
||||
}
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'wsl'
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = normalizedHostId
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] = normalizedDistro
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1393
-101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHookServer } from '../agent-hooks/server'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const PANE_KEY = '11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222'
|
||||
const TOKEN = 'launch-secret'
|
||||
const TOKEN_HASH = createHash('sha256').update(TOKEN).digest('hex')
|
||||
|
||||
type TerminalAuthorityResolver = {
|
||||
getOrchestrationDispatchAuthority: (terminalHandle: string) => unknown
|
||||
restoredOrchestrationAuthorityByPtyId: Map<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
function createRuntime(
|
||||
hostScope:
|
||||
| { kind: 'local'; hostId: 'local' }
|
||||
| { kind: 'wsl'; hostId: 'local'; distro: string }
|
||||
| { kind: 'ssh'; targetId: string },
|
||||
launchTokenHash: string | null = TOKEN_HASH
|
||||
) {
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
attestAgentHookCompatibilityAuthority: ({ paneKey, launchTokenHash, connectionId }) =>
|
||||
paneKey === PANE_KEY &&
|
||||
launchTokenHash === TOKEN_HASH &&
|
||||
connectionId === (hostScope.kind === 'ssh' ? hostScope.targetId : null)
|
||||
? { paneKey, source: 'hydrated_commitment' }
|
||||
: null
|
||||
})
|
||||
const resolveTerminal = vi.fn(() => ({
|
||||
runtimeId: 'runtime-1',
|
||||
terminalHandle: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
processIncarnation: 'incarnation-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash,
|
||||
hostScope
|
||||
}))
|
||||
;(runtime as unknown as TerminalAuthorityResolver).getOrchestrationDispatchAuthority =
|
||||
resolveTerminal
|
||||
if (launchTokenHash === null) {
|
||||
;(runtime as unknown as TerminalAuthorityResolver).restoredOrchestrationAuthorityByPtyId.set(
|
||||
'pty-1',
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'incarnation-1',
|
||||
hostScope
|
||||
}
|
||||
)
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('orchestration compatibility runtime authority', () => {
|
||||
it('returns only attested local identity and its token hash', () => {
|
||||
const runtime = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
|
||||
const authority = runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
})
|
||||
|
||||
expect(authority).toEqual({
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
paneKey: PANE_KEY,
|
||||
terminalHandle: 'term-1',
|
||||
processIncarnation: 'incarnation-1',
|
||||
launchTokenHash: TOKEN_HASH
|
||||
})
|
||||
expect(JSON.stringify(authority)).not.toContain(TOKEN)
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'wrong'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN_HASH
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps local and WSL authority stable across app runtime generations', () => {
|
||||
const firstLocal = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
const secondLocal = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
const wsl = createRuntime({ kind: 'wsl', hostId: 'local', distro: 'Ubuntu' })
|
||||
const localEvidence = {
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
}
|
||||
const wslEvidence = {
|
||||
...localEvidence,
|
||||
host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }
|
||||
} as const
|
||||
|
||||
expect(firstLocal.verifyOrchestrationCompatibilityCaller(localEvidence)?.hostScope).toEqual(
|
||||
secondLocal.verifyOrchestrationCompatibilityCaller(localEvidence)?.hostScope
|
||||
)
|
||||
expect(wsl.verifyOrchestrationCompatibilityCaller(wslEvidence)?.hostScope).toEqual({
|
||||
kind: 'wsl',
|
||||
hostId: 'local',
|
||||
distro: 'Ubuntu'
|
||||
})
|
||||
expect(wsl.verifyOrchestrationCompatibilityCaller(localEvidence)).toBeNull()
|
||||
expect(
|
||||
wsl.verifyOrchestrationCompatibilityCaller({
|
||||
...wslEvidence,
|
||||
host: { kind: 'wsl', hostId: 'runtime-before-restart', distro: 'Ubuntu' }
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('requires a live exact terminal even when the hook proof is hydrated', () => {
|
||||
const runtime = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
;(runtime as unknown as TerminalAuthorityResolver).getOrchestrationDispatchAuthority = () =>
|
||||
null
|
||||
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the hydrated hook commitment for a restored exact PTY', () => {
|
||||
const restored = createRuntime({ kind: 'local', hostId: 'local' }, null)
|
||||
const uncommitted = new OrcaRuntimeService()
|
||||
;(uncommitted as unknown as TerminalAuthorityResolver).getOrchestrationDispatchAuthority =
|
||||
() => ({
|
||||
runtimeId: 'runtime-1',
|
||||
terminalHandle: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
processIncarnation: 'incarnation-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: null,
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
})
|
||||
const evidence = {
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
}
|
||||
|
||||
expect(restored.verifyOrchestrationCompatibilityCaller(evidence)).toMatchObject({
|
||||
processIncarnation: 'incarnation-1',
|
||||
launchTokenHash: TOKEN_HASH
|
||||
})
|
||||
expect(uncommitted.verifyOrchestrationCompatibilityCaller(evidence)).toBeNull()
|
||||
expect(
|
||||
restored.verifyOrchestrationCompatibilityCaller({
|
||||
...evidence,
|
||||
launchToken: 'wrong'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PTY', { ptyId: 'pty-other' }],
|
||||
['worktree', { worktreeId: 'repo-1::/other' }],
|
||||
['terminal handle', { terminalHandle: 'term-other' }],
|
||||
[
|
||||
'pane',
|
||||
{ paneKey: '33333333-3333-4333-8333-333333333333:44444444-4444-4444-8444-444444444444' }
|
||||
],
|
||||
['process incarnation', { processIncarnation: 'incarnation-other' }]
|
||||
])('rejects a restored receipt with mismatched %s identity', (_field, mismatch) => {
|
||||
const runtime = createRuntime({ kind: 'local', hostId: 'local' }, null)
|
||||
const internals = runtime as unknown as TerminalAuthorityResolver
|
||||
const receipt = internals.restoredOrchestrationAuthorityByPtyId.get('pty-1')!
|
||||
internals.restoredOrchestrationAuthorityByPtyId.set('pty-1', { ...receipt, ...mismatch })
|
||||
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects restored receipts from a different WSL distro or SSH target', () => {
|
||||
const wsl = createRuntime({ kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }, null)
|
||||
const wslInternals = wsl as unknown as TerminalAuthorityResolver
|
||||
const wslReceipt = wslInternals.restoredOrchestrationAuthorityByPtyId.get('pty-1')!
|
||||
wslInternals.restoredOrchestrationAuthorityByPtyId.set('pty-1', {
|
||||
...wslReceipt,
|
||||
hostScope: { kind: 'wsl', hostId: 'local', distro: 'Debian' }
|
||||
})
|
||||
expect(
|
||||
wsl.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN,
|
||||
host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' }
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
const ssh = createRuntime({ kind: 'ssh', targetId: 'saved-target' }, null)
|
||||
const sshInternals = ssh as unknown as TerminalAuthorityResolver
|
||||
const sshReceipt = sshInternals.restoredOrchestrationAuthorityByPtyId.get('pty-1')!
|
||||
sshInternals.restoredOrchestrationAuthorityByPtyId.set('pty-1', {
|
||||
...sshReceipt,
|
||||
hostScope: { kind: 'ssh', targetId: 'other-target' }
|
||||
})
|
||||
const host = ssh.registerOrchestrationCompatibilitySshAttachment('saved-target', 'connection-1')
|
||||
expect(
|
||||
ssh.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN,
|
||||
host
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fall back to a restored receipt when a fresh launch token mismatches', () => {
|
||||
const runtime = createRuntime({ kind: 'local', hostId: 'local' })
|
||||
;(runtime as unknown as TerminalAuthorityResolver).restoredOrchestrationAuthorityByPtyId.set(
|
||||
'pty-1',
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'incarnation-1',
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'wrong'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('attests current coordinators immediately without upgrading restored legacy callers', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN,
|
||||
payload: { state: 'working', prompt: 'coordinate', agentType: 'codex' }
|
||||
},
|
||||
'saved-target'
|
||||
)
|
||||
const createIntegratedRuntime = (launchTokenHash: string | null): OrcaRuntimeService => {
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
attestAgentHookCompatibilityAuthority: (candidate) =>
|
||||
server.attestCompatibilityAuthority(candidate)
|
||||
})
|
||||
;(runtime as unknown as TerminalAuthorityResolver).getOrchestrationDispatchAuthority = vi.fn(
|
||||
() => ({
|
||||
runtimeId: 'runtime-1',
|
||||
terminalHandle: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
processIncarnation: 'incarnation-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash,
|
||||
hostScope: { kind: 'ssh', targetId: 'saved-target' }
|
||||
})
|
||||
)
|
||||
if (launchTokenHash === null) {
|
||||
;(
|
||||
runtime as unknown as TerminalAuthorityResolver
|
||||
).restoredOrchestrationAuthorityByPtyId.set('pty-1', {
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'repo-1::/worktree',
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'incarnation-1',
|
||||
hostScope: { kind: 'ssh', targetId: 'saved-target' }
|
||||
})
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
const evidenceFor = (runtime: OrcaRuntimeService, launchToken = TOKEN) => ({
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken,
|
||||
host: runtime.registerOrchestrationCompatibilitySshAttachment('saved-target', 'connection-1')
|
||||
})
|
||||
|
||||
const current = createIntegratedRuntime(TOKEN_HASH)
|
||||
expect(current.verifyOrchestrationCompatibilityCaller(evidenceFor(current))).not.toBeNull()
|
||||
|
||||
const restored = createIntegratedRuntime(null)
|
||||
expect(restored.verifyOrchestrationCompatibilityCaller(evidenceFor(restored))).toBeNull()
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: '33333333-3333-4333-8333-333333333333:44444444-4444-4444-8444-444444444444',
|
||||
launchToken: TOKEN,
|
||||
payload: { state: 'working', prompt: 'duplicate', agentType: 'codex' }
|
||||
},
|
||||
'saved-target'
|
||||
)
|
||||
expect(current.verifyOrchestrationCompatibilityCaller(evidenceFor(current))).toBeNull()
|
||||
|
||||
const mismatchedToken = 'different-launch-secret'
|
||||
const mismatched = createIntegratedRuntime(
|
||||
createHash('sha256').update(mismatchedToken).digest('hex')
|
||||
)
|
||||
expect(
|
||||
mismatched.verifyOrchestrationCompatibilityCaller(evidenceFor(mismatched, mismatchedToken))
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts only a live runtime-issued SSH attachment', () => {
|
||||
const runtime = createRuntime({ kind: 'ssh', targetId: 'saved-target' })
|
||||
const host = runtime.registerOrchestrationCompatibilitySshAttachment(
|
||||
'saved-target',
|
||||
'connection-1'
|
||||
)
|
||||
const evidence = {
|
||||
terminalHandle: 'term-1',
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: TOKEN,
|
||||
host
|
||||
} as const
|
||||
|
||||
expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).not.toBeNull()
|
||||
expect(
|
||||
runtime.verifyOrchestrationCompatibilityCaller({
|
||||
...evidence,
|
||||
host: { ...host, attachmentId: 'caller-chosen' }
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
runtime.releaseOrchestrationCompatibilitySshAttachment(host.attachmentId)
|
||||
|
||||
expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -942,7 +942,7 @@ describe('OrchestrationDb', () => {
|
||||
|
||||
// v1 data preserved
|
||||
expect(d.getMessageById('msg_v1')?.subject).toBe('pre-migration')
|
||||
expect(d.getMessageById('msg_v1')?.run_id).toBe(LEGACY_RUN_ID)
|
||||
expect(d.getMessageById('msg_v1')?.run_id).toBe(d.getLegacyAdoption()?.adopted_run_id)
|
||||
expect(d.getRun(LEGACY_RUN_ID)).toMatchObject({ legacy: 1 })
|
||||
})
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,14 @@ describe('formatMessageBanner', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('lets the CLI resolve the live sender for Run and Dispatch addresses', () => {
|
||||
for (const to_handle of ['run:run_test', 'dispatch:dispatch_test']) {
|
||||
const banner = formatMessageBanner(makeMessage({ to_handle }))
|
||||
expect(banner).toContain('[Reply: orca orchestration reply --id msg_test1 --body "..."]')
|
||||
expect(banner).not.toContain(`--from ${to_handle}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('marks legacy messages read-only without reply or acknowledgment affordances', () => {
|
||||
const banner = formatMessageBanner(
|
||||
makeMessage({ id: 'msg_legacy', run_id: 'run_legacy_local' })
|
||||
@@ -88,6 +96,63 @@ describe('formatMessageBanner', () => {
|
||||
expect(banner).not.toContain('orchestration reply')
|
||||
})
|
||||
|
||||
it('renders only attested actions for legacy compatibility authority', () => {
|
||||
const banner = formatMessageBanner(makeMessage({ id: 'msg_legacy' }), {
|
||||
authority: 'legacy_compatibility',
|
||||
supportedActionHints: [
|
||||
'orca orchestration reply --id msg_legacy --from term_coord --body "..."'
|
||||
]
|
||||
})
|
||||
|
||||
expect(banner).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(banner).toContain(
|
||||
'[Supported action: orca orchestration reply --id msg_legacy --from term_coord --body "..."]'
|
||||
)
|
||||
expect(banner).not.toContain('[Reply:')
|
||||
expect(banner).not.toContain('acknowledgment')
|
||||
})
|
||||
|
||||
it('warns that a bounded legacy recovery replay may already have been seen', () => {
|
||||
const banner = formatMessageBanner(makeMessage(), {
|
||||
authority: 'legacy_recovery_replay',
|
||||
supportedActionHints: ['orca orchestration check --ack delivery_legacy']
|
||||
})
|
||||
|
||||
expect(banner).toContain('[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]')
|
||||
expect(banner).toContain('bounded recovery replay may already have been seen')
|
||||
expect(banner).toContain('[Supported action: orca orchestration check --ack delivery_legacy]')
|
||||
expect(banner).not.toContain('[Reply:')
|
||||
})
|
||||
|
||||
it('does not infer live compatibility from legacy database provenance', () => {
|
||||
const banner = formatMessageBanner(makeMessage({ run_id: 'run_legacy_local' }), {
|
||||
supportedActionHints: ['orca orchestration check --ack delivery_legacy']
|
||||
})
|
||||
|
||||
expect(banner).toContain('[LEGACY READ-ONLY]')
|
||||
expect(banner).not.toContain('Supported action:')
|
||||
expect(banner).not.toContain('check --ack')
|
||||
})
|
||||
|
||||
it('keeps adopted legacy and audit messages read-only without runtime authority', () => {
|
||||
for (const deliveryContract of ['legacy_direct', 'audit_only'] as const) {
|
||||
const banner = formatMessageBanner(
|
||||
makeMessage({ run_id: 'run_adopted', delivery_contract: deliveryContract })
|
||||
)
|
||||
|
||||
expect(banner).toContain('[LEGACY READ-ONLY]')
|
||||
expect(banner).not.toContain('[Reply:')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps current formatting unchanged when authority is explicit', () => {
|
||||
const message = makeMessage({ id: 'msg_current' })
|
||||
|
||||
expect(formatMessageBanner(message, { authority: 'current' })).toBe(
|
||||
formatMessageBanner(message)
|
||||
)
|
||||
})
|
||||
|
||||
it('ends with a separator line', () => {
|
||||
const banner = formatMessageBanner(makeMessage())
|
||||
const lines = banner.split('\n')
|
||||
|
||||
@@ -4,19 +4,77 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c
|
||||
const BANNER_WIDTH = 60
|
||||
const SEPARATOR = '─'.repeat(BANNER_WIDTH)
|
||||
|
||||
export function formatMessageBanner(msg: MessageRow): string {
|
||||
export type MessageFormattingAuthority =
|
||||
| 'current'
|
||||
| 'legacy_compatibility'
|
||||
| 'legacy_recovery_replay'
|
||||
| 'legacy_read_only'
|
||||
|
||||
export type MessageFormattingOptions = {
|
||||
authority?: MessageFormattingAuthority
|
||||
supportedActionHints?: readonly string[]
|
||||
}
|
||||
|
||||
function resolveAuthority(
|
||||
msg: MessageRow,
|
||||
authority: MessageFormattingAuthority | undefined
|
||||
): MessageFormattingAuthority {
|
||||
if (authority) {
|
||||
return authority
|
||||
}
|
||||
return msg.run_id === ORCHESTRATION_LEGACY_RUN_ID ||
|
||||
msg.delivery_contract === 'legacy_direct' ||
|
||||
msg.delivery_contract === 'audit_only'
|
||||
? 'legacy_read_only'
|
||||
: 'current'
|
||||
}
|
||||
|
||||
function appendLegacyGuidance(
|
||||
lines: string[],
|
||||
authority: MessageFormattingAuthority,
|
||||
supportedActionHints: readonly string[]
|
||||
): void {
|
||||
if (authority === 'legacy_read_only') {
|
||||
lines.push('[Inspection only: reply and acknowledgment are unavailable.]')
|
||||
return
|
||||
}
|
||||
if (authority === 'legacy_compatibility') {
|
||||
lines.push('[Use only the supported legacy action shown below.]')
|
||||
} else if (authority === 'legacy_recovery_replay') {
|
||||
lines.push(
|
||||
'[This bounded recovery replay may already have been seen. Use only the action shown below.]'
|
||||
)
|
||||
}
|
||||
for (const hint of supportedActionHints) {
|
||||
lines.push(`[Supported action: ${hint}]`)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMessageBanner(msg: MessageRow, options: MessageFormattingOptions): string
|
||||
export function formatMessageBanner(msg: MessageRow): string
|
||||
export function formatMessageBanner(
|
||||
msg: MessageRow,
|
||||
options: MessageFormattingOptions = {}
|
||||
): string {
|
||||
const priorityTag =
|
||||
msg.priority === 'urgent' ? ' [URGENT]' : msg.priority === 'high' ? ' [HIGH]' : ''
|
||||
const legacyReadOnly = msg.run_id === ORCHESTRATION_LEGACY_RUN_ID
|
||||
const authorityTag = legacyReadOnly ? ' [LEGACY READ-ONLY]' : ''
|
||||
const authority = resolveAuthority(msg, options.authority)
|
||||
const authorityTag =
|
||||
authority === 'legacy_compatibility'
|
||||
? ' [LEGACY COMPATIBILITY]'
|
||||
: authority === 'legacy_recovery_replay'
|
||||
? ' [LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]'
|
||||
: authority === 'legacy_read_only'
|
||||
? ' [LEGACY READ-ONLY]'
|
||||
: ''
|
||||
const senderName = msg.from_handle.toUpperCase()
|
||||
|
||||
const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag}${authorityTag} (${msg.type}) ────`
|
||||
|
||||
const lines: string[] = [header]
|
||||
lines.push(`Subject: ${msg.subject}`)
|
||||
if (legacyReadOnly) {
|
||||
lines.push('[Inspection only: reply and acknowledgment are unavailable.]')
|
||||
if (authority !== 'current') {
|
||||
appendLegacyGuidance(lines, authority, options.supportedActionHints ?? [])
|
||||
}
|
||||
|
||||
if (msg.body) {
|
||||
@@ -27,11 +85,12 @@ export function formatMessageBanner(msg: MessageRow): string {
|
||||
lines.push(`[Payload: ${msg.payload}]`)
|
||||
}
|
||||
|
||||
if (!legacyReadOnly) {
|
||||
// Why: older shells can lack Orca's terminal identity environment.
|
||||
lines.push(
|
||||
`[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]`
|
||||
)
|
||||
if (authority === 'current') {
|
||||
const explicitFrom =
|
||||
msg.to_handle.startsWith('run:') || msg.to_handle.startsWith('dispatch:')
|
||||
? ''
|
||||
: ` --from ${msg.to_handle}`
|
||||
lines.push(`[Reply: orca orchestration reply --id ${msg.id}${explicitFrom} --body "..."]`)
|
||||
}
|
||||
lines.push(SEPARATOR)
|
||||
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
import {
|
||||
createLegacyStorageCutoverFixture,
|
||||
type LegacyStorageCutoverFixture
|
||||
} from './orchestration-legacy-storage-test-fixture'
|
||||
|
||||
describe('OrchestrationDb legacy coordinator authority', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = undefined
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function createCutoverFixture(): LegacyStorageCutoverFixture {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
return created.fixture
|
||||
}
|
||||
|
||||
function openAdoptedFixture(): {
|
||||
fixture: LegacyStorageCutoverFixture
|
||||
adoptedRunId: string
|
||||
workerPrincipalId: string
|
||||
} {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const worker = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
dispatchId: fixture.legacyDispatchId,
|
||||
role: 'worker',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_worker',
|
||||
paneKey: 'tab_legacy:33333333-3333-4333-8333-333333333333',
|
||||
launchTokenHash: 'legacy_launch_hash',
|
||||
processIncarnation: 'process_1'
|
||||
})
|
||||
return {
|
||||
fixture,
|
||||
adoptedRunId,
|
||||
workerPrincipalId: worker.principal.id
|
||||
}
|
||||
}
|
||||
|
||||
it('records exact scheduler loss without admitting naturally settled coordinators', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
const raw = new Database(fixture.dbPath)
|
||||
raw.exec(`
|
||||
UPDATE tasks
|
||||
SET created_by_terminal_handle = 'term_manual_coord'
|
||||
WHERE run_id = '${LEGACY_RUN_ID}';
|
||||
UPDATE messages
|
||||
SET from_handle = CASE
|
||||
WHEN from_handle = 'term_legacy_coord' THEN 'term_manual_coord'
|
||||
ELSE from_handle
|
||||
END,
|
||||
to_handle = CASE
|
||||
WHEN to_handle = 'term_legacy_coord' THEN 'term_manual_coord'
|
||||
ELSE to_handle
|
||||
END
|
||||
WHERE run_id = '${LEGACY_RUN_ID}';
|
||||
INSERT INTO coordinator_runs (
|
||||
id, spec, status, coordinator_handle, created_at
|
||||
) VALUES (
|
||||
'coordinator_manual', 'manual legacy coordinator', 'running',
|
||||
'term_manual_coord', datetime('now', '-1 minute')
|
||||
);
|
||||
INSERT INTO coordinator_runs (
|
||||
id, spec, status, coordinator_handle, created_at, completed_at
|
||||
) VALUES (
|
||||
'coordinator_natural', 'naturally completed coordinator', 'completed',
|
||||
'term_natural_coord', datetime('now', '-2 minutes'),
|
||||
'2099-01-01T00:00:00.000Z'
|
||||
);
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
expect(db.getActiveCoordinatorRun()).toBeUndefined()
|
||||
const adoption = db.getLegacyAdoption()!
|
||||
const migratedCoordinator = db.getCoordinatorRun('coordinator_manual')!
|
||||
expect(migratedCoordinator).toMatchObject({
|
||||
status: 'failed',
|
||||
completed_at: adoption.adopted_at,
|
||||
scheduler_lost_at: adoption.adopted_at
|
||||
})
|
||||
expect(db.getCoordinatorRun('coordinator_natural')).toMatchObject({
|
||||
status: 'completed',
|
||||
completed_at: '2099-01-01T00:00:00.000Z',
|
||||
scheduler_lost_at: null
|
||||
})
|
||||
expect(
|
||||
db.resolveLegacyCoordinatorCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: 'term_manual_coord',
|
||||
paneKey: 'tab_manual:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toMatchObject({ terminalHandle: 'term_manual_coord' })
|
||||
expect(db.isLegacyCoordinatorHandle(adoptedRunId, 'term_natural_coord')).toBe(false)
|
||||
|
||||
db.close()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
expect(db.getLegacyAdoption()).toEqual(adoption)
|
||||
expect(db.getCoordinatorRun('coordinator_manual')).toEqual(migratedCoordinator)
|
||||
expect(
|
||||
db.resolveLegacyCoordinatorCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: 'term_manual_coord',
|
||||
paneKey: 'tab_manual:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toMatchObject({ terminalHandle: 'term_manual_coord' })
|
||||
})
|
||||
|
||||
it('fails closed when multiple pre-cutover schedulers lose authority', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
const raw = new Database(fixture.dbPath)
|
||||
raw.exec(`
|
||||
INSERT INTO coordinator_runs (
|
||||
id, spec, status, coordinator_handle, created_at
|
||||
) VALUES
|
||||
('coordinator_first', 'first', 'running', 'term_first_coord', datetime('now', '-2 minutes')),
|
||||
('coordinator_second', 'second', 'running', 'term_second_coord', datetime('now', '-1 minute'));
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoption = db.getLegacyAdoption()!
|
||||
const adoptedRunId = adoption.adopted_run_id
|
||||
expect(db.getActiveCoordinatorRun()).toBeUndefined()
|
||||
expect(db.getCoordinatorRun('coordinator_first')).toMatchObject({
|
||||
status: 'failed',
|
||||
scheduler_lost_at: adoption.adopted_at
|
||||
})
|
||||
expect(db.getCoordinatorRun('coordinator_second')).toMatchObject({
|
||||
status: 'failed',
|
||||
scheduler_lost_at: adoption.adopted_at
|
||||
})
|
||||
expect(
|
||||
db.resolveLegacyCoordinatorCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_manual:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not infer a coordinator from worker-to-worker legacy mail', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
const raw = new Database(fixture.dbPath)
|
||||
raw.exec(`
|
||||
INSERT INTO tasks (
|
||||
id, run_id, spec, status, created_by_terminal_handle
|
||||
) VALUES (
|
||||
'task_second_worker', '${LEGACY_RUN_ID}', 'second worker',
|
||||
'dispatched', 'term_legacy_coord'
|
||||
);
|
||||
INSERT INTO dispatch_contexts (
|
||||
id, run_id, task_id, contract_version, assignee_handle,
|
||||
assignee_pane_key, status, dispatched_at
|
||||
) VALUES (
|
||||
'dispatch_second_worker', '${LEGACY_RUN_ID}', 'task_second_worker', 0,
|
||||
'term_second_worker', 'tab_second:55555555-5555-4555-8555-555555555555',
|
||||
'dispatched', datetime('now')
|
||||
);
|
||||
INSERT INTO messages (
|
||||
id, run_id, from_handle, to_handle, subject
|
||||
) VALUES (
|
||||
'message_worker_to_worker', '${LEGACY_RUN_ID}', 'term_legacy_worker',
|
||||
'term_second_worker', 'peer coordination'
|
||||
);
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
expect(db.isLegacyCoordinatorHandle(adoptedRunId, 'term_second_worker')).toBe(false)
|
||||
expect(db.isLegacyCoordinatorHandle(adoptedRunId, 'term_legacy_worker')).toBe(false)
|
||||
expect(db.isLegacyCoordinatorHandle(adoptedRunId, 'term_legacy_coord')).toBe(true)
|
||||
})
|
||||
|
||||
it('promotes only the unacknowledged coordinator recovery cohort during takeover', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const unacknowledged = db.insertMessage({
|
||||
runId: adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'second recovered coordinator outcome'
|
||||
})
|
||||
db.markAsRead([unacknowledged.id])
|
||||
const coordinator = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
}).principal
|
||||
const firstPage = db.getLegacyMailPage({ principalId: coordinator.id, limit: 1 })
|
||||
expect(firstPage.recovery).toBe(true)
|
||||
db.acknowledgeLegacyMail({
|
||||
principalId: coordinator.id,
|
||||
messageIds: firstPage.messages.map((message) => message.id)
|
||||
})
|
||||
const acknowledgedId = firstPage.messages[0].id
|
||||
|
||||
db.bindRun({
|
||||
runId: adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
expect(db.getMessageById(acknowledgedId)).toMatchObject({
|
||||
to_handle: 'term_legacy_coord',
|
||||
delivery_contract: 'legacy_direct',
|
||||
read: 1
|
||||
})
|
||||
expect(db.getMessageById(unacknowledged.id)).toMatchObject({
|
||||
to_handle: `run:${adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
read: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('promotes a pre-cutover read recovery message when takeover precedes attestation', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const recoveryMessageId = fixture.legacyMessageIds[1]
|
||||
expect(db.getLegacyCoordinatorPrincipal(adoptedRunId)).toBeUndefined()
|
||||
expect(db.getMessageById(recoveryMessageId)).toMatchObject({
|
||||
to_handle: 'term_legacy_coord',
|
||||
delivery_contract: 'legacy_direct',
|
||||
read: 1
|
||||
})
|
||||
|
||||
const run = db.bindRun({
|
||||
runId: adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})!
|
||||
expect(db.getMessageById(recoveryMessageId)).toMatchObject({
|
||||
to_handle: `run:${adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
read: 0
|
||||
})
|
||||
|
||||
const first = db.getOrCreateRunDelivery({
|
||||
runId: adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})!
|
||||
const replay = db.getOrCreateRunDelivery({
|
||||
runId: adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})!
|
||||
expect(first.messages.map((message) => message.id)).toContain(recoveryMessageId)
|
||||
expect(replay).toMatchObject({ replayed: true, delivery: { id: first.delivery.id } })
|
||||
|
||||
db.acknowledgeRunDelivery({
|
||||
runId: adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation,
|
||||
deliveryId: first.delivery.id
|
||||
})
|
||||
expect(
|
||||
db
|
||||
.getOrCreateRunDelivery({
|
||||
runId: adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
?.messages.map((message) => message.id) ?? []
|
||||
).not.toContain(recoveryMessageId)
|
||||
})
|
||||
|
||||
it('promotes retained mail on an ordinary post-settlement coordinator replacement', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const coordinator = db!.commitLegacyCompatibilityPrincipal({
|
||||
runId: state.adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
}).principal
|
||||
const completion = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed before ordinary run-use',
|
||||
type: 'worker_done'
|
||||
})
|
||||
db!.settleWorkerReport({
|
||||
taskId: state.fixture.legacyTaskId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'done'
|
||||
})
|
||||
|
||||
const run = db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111'
|
||||
})!
|
||||
|
||||
expect(db!.getLegacyCompatibilityPrincipal(coordinator.id)?.status).toBe('revoked')
|
||||
expect(db!.getMessageById(completion.id)).toMatchObject({
|
||||
to_handle: `run:${state.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
read: 0
|
||||
})
|
||||
expect(
|
||||
db!
|
||||
.getOrCreateRunDelivery({
|
||||
runId: state.adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
?.messages.map((message) => message.id)
|
||||
).toContain(completion.id)
|
||||
})
|
||||
|
||||
it('acknowledges the exact current Run answer to a legacy ask after takeover', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const ask = db!.commitLegacyAskOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'ask_before_takeover',
|
||||
method: 'orchestration.ask',
|
||||
payloadHash: 'ask_before_takeover_payload',
|
||||
question: 'Continue after takeover?',
|
||||
recipientHandle: 'term_legacy_coord'
|
||||
})
|
||||
const run = db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})!
|
||||
const answered = db!.answerQuestion({
|
||||
messageId: ask.question.message_id,
|
||||
runId: state.adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation,
|
||||
body: 'continue'
|
||||
})
|
||||
expect(answered.message).toMatchObject({
|
||||
from_handle: `run:${state.adoptedRunId}`,
|
||||
to_handle: `dispatch:${state.fixture.legacyDispatchId}`,
|
||||
delivery_contract: 'current_delivery'
|
||||
})
|
||||
|
||||
const acknowledged = db!.acknowledgeLegacyQuestionAnswer({
|
||||
principalId: state.workerPrincipalId,
|
||||
questionId: ask.question.message_id,
|
||||
answerMessageId: answered.message.id
|
||||
})
|
||||
|
||||
expect(acknowledged.duplicate).toBe(false)
|
||||
expect(db!.getMessageById(answered.message.id)?.read).toBe(1)
|
||||
expect(
|
||||
db!.acknowledgeLegacyQuestionAnswer({
|
||||
principalId: state.workerPrincipalId,
|
||||
questionId: ask.question.message_id,
|
||||
answerMessageId: answered.message.id
|
||||
}).duplicate
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
import { createLegacyStorageCutoverFixture } from './orchestration-legacy-storage-test-fixture'
|
||||
|
||||
describe('OrchestrationDb legacy question migration', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function prepareTakenOverSchema19Question(readAndAcknowledge: boolean): {
|
||||
dbPath: string
|
||||
adoptedRunId: string
|
||||
questionId: string
|
||||
} {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const questionId = readAndAcknowledge ? 'msg_schema19_acknowledged' : 'msg_schema19_unread'
|
||||
db.insertMessage({
|
||||
id: questionId,
|
||||
runId: adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Question',
|
||||
body: 'Continue after takeover?',
|
||||
type: 'decision_gate',
|
||||
threadId: questionId,
|
||||
payload: JSON.stringify({
|
||||
taskId: created.fixture.legacyTaskId,
|
||||
dispatchId: created.fixture.legacyDispatchId
|
||||
})
|
||||
})
|
||||
if (readAndAcknowledge) {
|
||||
db.markAsRead([questionId])
|
||||
}
|
||||
const coordinator = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
}).principal
|
||||
if (readAndAcknowledge) {
|
||||
const recovery = db.getLegacyMailPage({ principalId: coordinator.id })
|
||||
expect(recovery.messages.map((message) => message.id)).toContain(questionId)
|
||||
db.acknowledgeLegacyMail({
|
||||
principalId: coordinator.id,
|
||||
messageIds: recovery.messages.map((message) => message.id)
|
||||
})
|
||||
}
|
||||
db.bindRun({
|
||||
runId: adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
expect(db.getMessageById(questionId)).toMatchObject(
|
||||
readAndAcknowledge
|
||||
? { delivery_contract: 'legacy_direct', read: 1 }
|
||||
: { delivery_contract: 'current_delivery', read: 0 }
|
||||
)
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const raw = new Database(created.fixture.dbPath)
|
||||
raw.exec('DROP TABLE question_threads')
|
||||
raw.pragma('user_version = 19')
|
||||
raw.close()
|
||||
return { dbPath: created.fixture.dbPath, adoptedRunId, questionId }
|
||||
}
|
||||
|
||||
it('backfills a pre-question-thread ask and its accepted answer', () => {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const raw = new Database(created.fixture.dbPath)
|
||||
raw.exec(`
|
||||
DROP TABLE question_threads;
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, type, thread_id, payload
|
||||
) VALUES (
|
||||
'msg_cutover', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_coord', 'Question',
|
||||
'Continue?', 'decision_gate', 'msg_cutover',
|
||||
'{"taskId":"${created.fixture.legacyTaskId}","dispatchId":"${created.fixture.legacyDispatchId}"}'
|
||||
);
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, thread_id
|
||||
) VALUES
|
||||
(
|
||||
'msg_cross_run_decoy', 'run_decoy', 'legacy_direct',
|
||||
'term_legacy_coord', 'term_legacy_worker',
|
||||
'Re: Question', 'cross-run decoy', 'msg_cutover'
|
||||
),
|
||||
(
|
||||
'msg_self_sent_decoy', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_worker',
|
||||
'Re: Question', 'self-sent decoy', 'msg_cutover'
|
||||
),
|
||||
(
|
||||
'msg_cutover_answer', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_coord', 'term_legacy_worker',
|
||||
'Re: Question', 'yes', 'msg_cutover'
|
||||
);
|
||||
`)
|
||||
raw.pragma('user_version = 19')
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
expect(db.getQuestion('msg_cutover')).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
dispatch_id: created.fixture.legacyDispatchId,
|
||||
asker_handle: 'term_legacy_worker',
|
||||
status: 'answered',
|
||||
answer_message_id: 'msg_cutover_answer',
|
||||
answer_body: 'yes'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a question pending when only cross-run and self-sent decoys exist', () => {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
db.close()
|
||||
db = undefined
|
||||
|
||||
const raw = new Database(created.fixture.dbPath)
|
||||
raw.exec(`
|
||||
DROP TABLE question_threads;
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, type, thread_id, payload
|
||||
) VALUES (
|
||||
'msg_pending_cutover', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_coord', 'Question',
|
||||
'Continue?', 'decision_gate', 'msg_pending_cutover',
|
||||
'{"taskId":"${created.fixture.legacyTaskId}","dispatchId":"${created.fixture.legacyDispatchId}"}'
|
||||
);
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, thread_id
|
||||
) VALUES
|
||||
(
|
||||
'msg_pending_cross_run_decoy', 'run_decoy', 'legacy_direct',
|
||||
'term_legacy_coord', 'term_legacy_worker',
|
||||
'Re: Question', 'cross-run decoy', 'msg_pending_cutover'
|
||||
),
|
||||
(
|
||||
'msg_pending_self_sent_decoy', '${adoptedRunId}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_worker',
|
||||
'Re: Question', 'self-sent decoy', 'msg_pending_cutover'
|
||||
);
|
||||
`)
|
||||
raw.pragma('user_version = 19')
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
expect(db.getQuestion('msg_pending_cutover')).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
dispatch_id: created.fixture.legacyDispatchId,
|
||||
asker_handle: 'term_legacy_worker',
|
||||
status: 'pending',
|
||||
answer_message_id: null,
|
||||
answer_body: null
|
||||
})
|
||||
})
|
||||
|
||||
it('uses task provenance to disambiguate same-handle same-second asks', () => {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
const raw = new Database(created.fixture.dbPath)
|
||||
raw.exec(`
|
||||
INSERT INTO tasks (
|
||||
id, run_id, spec, status, created_by_terminal_handle, created_at
|
||||
) VALUES
|
||||
('task_question_target', '${LEGACY_RUN_ID}', 'target', 'dispatched',
|
||||
'term_legacy_coord', '2026-01-01 00:00:00'),
|
||||
('task_question_other', '${LEGACY_RUN_ID}', 'other', 'dispatched',
|
||||
'term_legacy_coord', '2026-01-01 00:00:00');
|
||||
INSERT INTO dispatch_contexts (
|
||||
id, run_id, task_id, contract_version, assignee_handle,
|
||||
assignee_pane_key, status, dispatched_at, created_at
|
||||
) VALUES
|
||||
('dispatch_question_target', '${LEGACY_RUN_ID}', 'task_question_target', 0,
|
||||
'term_legacy_worker', 'tab_target:11111111-1111-4111-8111-111111111111',
|
||||
'dispatched', '2026-01-01 00:00:00', '2026-01-01 00:00:00'),
|
||||
('dispatch_question_other', '${LEGACY_RUN_ID}', 'task_question_other', 0,
|
||||
'term_legacy_worker', 'tab_other:22222222-2222-4222-8222-222222222222',
|
||||
'dispatched', '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||
INSERT INTO messages (
|
||||
id, run_id, delivery_contract, from_handle, to_handle,
|
||||
subject, body, type, payload, created_at
|
||||
) VALUES (
|
||||
'msg_question_task_only', '${LEGACY_RUN_ID}', 'legacy_direct',
|
||||
'term_legacy_worker', 'term_legacy_coord', 'Question', 'Continue?',
|
||||
'decision_gate', '{"taskId":"task_question_target","question":"Continue?","options":[]}',
|
||||
'2026-01-01 00:00:00'
|
||||
);
|
||||
`)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(created.fixture.dbPath)
|
||||
expect(db.getQuestion('msg_question_task_only')).toMatchObject({
|
||||
dispatch_id: 'dispatch_question_target',
|
||||
asker_handle: 'term_legacy_worker',
|
||||
status: 'pending'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['promoted unread', false],
|
||||
['acknowledged recovery', true]
|
||||
] as const)('backfills and routes a %s v19 question after takeover', (_label, acknowledged) => {
|
||||
const state = prepareTakenOverSchema19Question(acknowledged)
|
||||
|
||||
db = new OrchestrationDb(state.dbPath)
|
||||
expect(db.getQuestion(state.questionId)).toMatchObject({
|
||||
run_id: state.adoptedRunId,
|
||||
status: 'pending'
|
||||
})
|
||||
expect(db.getMessageById(state.questionId)).toMatchObject({
|
||||
to_handle: `run:${state.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
read: 0
|
||||
})
|
||||
const run = db.getRun(state.adoptedRunId)!
|
||||
expect(
|
||||
db
|
||||
.getOrCreateRunDelivery({
|
||||
runId: state.adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
?.messages.map((message) => message.id)
|
||||
).toContain(state.questionId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,751 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import {
|
||||
CURRENT_CONTRACT_VERSION,
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
LEGACY_RUN_ID,
|
||||
OrchestrationDb
|
||||
} from './db'
|
||||
import {
|
||||
createLegacyStorageCutoverFixture,
|
||||
type LegacyStorageCutoverFixture
|
||||
} from './orchestration-legacy-storage-test-fixture'
|
||||
|
||||
describe('OrchestrationDb legacy contract storage', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
let tempDir: string | undefined
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = undefined
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function createCutoverFixture(): LegacyStorageCutoverFixture {
|
||||
const created = createLegacyStorageCutoverFixture()
|
||||
tempDir = created.tempDir
|
||||
return created.fixture
|
||||
}
|
||||
|
||||
function openAdoptedFixture(): {
|
||||
fixture: LegacyStorageCutoverFixture
|
||||
adoptedRunId: string
|
||||
workerPrincipalId: string
|
||||
coordinatorPrincipalId: string
|
||||
} {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const worker = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
dispatchId: fixture.legacyDispatchId,
|
||||
role: 'worker',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_worker',
|
||||
paneKey: 'tab_legacy:33333333-3333-4333-8333-333333333333',
|
||||
launchTokenHash: 'legacy_launch_hash',
|
||||
processIncarnation: 'process_1'
|
||||
})
|
||||
const coordinator = db.commitLegacyCompatibilityPrincipal({
|
||||
runId: adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
})
|
||||
return {
|
||||
fixture,
|
||||
adoptedRunId,
|
||||
workerPrincipalId: worker.principal.id,
|
||||
coordinatorPrincipalId: coordinator.principal.id
|
||||
}
|
||||
}
|
||||
|
||||
it('atomically rehomes the full graph, fences legacy Delivery, and preserves current rows', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
const adoption = db.getLegacyAdoption()
|
||||
const adoptedRunId = adoption?.adopted_run_id as string
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
|
||||
expect(adoption).toMatchObject({
|
||||
source_run_id: LEGACY_RUN_ID,
|
||||
scheduler_state_lost: 1
|
||||
})
|
||||
expect(db.getRun(adoptedRunId)).toMatchObject({ legacy: 0, consumer_generation: 0 })
|
||||
expect(db.listTasks({ runId: LEGACY_RUN_ID })).toEqual([])
|
||||
expect(db.getDispatchContextById(fixture.legacyDispatchId)).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
contract_version: LEGACY_CONTRACT_VERSION,
|
||||
launch_token_hash: null
|
||||
})
|
||||
expect(db.getGate(fixture.legacyGateId)).toMatchObject({ run_id: adoptedRunId })
|
||||
expect(db.getQuestion(fixture.legacyQuestionId)).toMatchObject({ run_id: adoptedRunId })
|
||||
expect(db.getMessageById(fixture.legacyMessageIds[0])).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
expect(db.getMessageById(fixture.rejectionMessageId)).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'audit_only'
|
||||
})
|
||||
expect(db.getMessageById(fixture.lookalikeMessageId)).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
for (const messageId of fixture.malformedRejectionMessageIds) {
|
||||
expect(db.getMessageById(messageId)).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
}
|
||||
expect(
|
||||
sqlite.prepare('SELECT * FROM deliveries WHERE id = ?').get(fixture.legacyDeliveryId)
|
||||
).toMatchObject({ run_id: adoptedRunId, status: 'fenced' })
|
||||
expect(db.getDispatchContextById(fixture.currentDispatchId)).toMatchObject({
|
||||
run_id: fixture.currentRunId,
|
||||
contract_version: CURRENT_CONTRACT_VERSION,
|
||||
launch_token_hash: 'current_launch_hash'
|
||||
})
|
||||
|
||||
db.close()
|
||||
const partial = new Database(fixture.dbPath)
|
||||
partial
|
||||
.prepare('UPDATE tasks SET run_id = ? WHERE id = ?')
|
||||
.run(LEGACY_RUN_ID, fixture.legacyTaskId)
|
||||
partial.pragma('user_version = 19')
|
||||
partial.close()
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
expect(db.getLegacyAdoption()?.adopted_run_id).toBe(adoptedRunId)
|
||||
expect(db.getTask(fixture.legacyTaskId)?.run_id).toBe(adoptedRunId)
|
||||
expect(db.getDispatchContextById(fixture.currentDispatchId)?.contract_version).toBe(
|
||||
CURRENT_CONTRACT_VERSION
|
||||
)
|
||||
expect(db.listTasks({ runId: LEGACY_RUN_ID })).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores unrelated cross-Run anomalies while validating adopted rows', () => {
|
||||
const fixture = createCutoverFixture()
|
||||
const raw = new Database(fixture.dbPath)
|
||||
raw
|
||||
.prepare('UPDATE dispatch_contexts SET run_id = ? WHERE id = ?')
|
||||
.run(fixture.unrelatedRunId, fixture.currentDispatchId)
|
||||
raw.close()
|
||||
|
||||
db = new OrchestrationDb(fixture.dbPath)
|
||||
|
||||
expect(db.getLegacyAdoption()).toBeDefined()
|
||||
expect(db.getDispatchContextById(fixture.currentDispatchId)?.run_id).toBe(
|
||||
fixture.unrelatedRunId
|
||||
)
|
||||
})
|
||||
|
||||
it('does not synthesize an adopted Run or compatibility authority for a fresh database', () => {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
|
||||
expect(db.getLegacyAdoption()).toBeUndefined()
|
||||
expect(db.listLegacyCompatibilityPrincipals(LEGACY_RUN_ID)).toEqual([])
|
||||
expect(db.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
|
||||
})
|
||||
|
||||
it('keeps current Delivery disjoint from adopted direct and audit-only mail', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const run = db!.getRun(state.adoptedRunId) as NonNullable<ReturnType<OrchestrationDb['getRun']>>
|
||||
db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
from: 'current_worker',
|
||||
to: `run:${state.adoptedRunId}`,
|
||||
subject: 'current retry mail'
|
||||
})
|
||||
|
||||
const delivery = db!.getOrCreateRunDelivery({
|
||||
runId: state.adoptedRunId,
|
||||
consumerGeneration: run.consumer_generation
|
||||
})
|
||||
|
||||
expect(delivery?.messages.map((message) => message.subject)).toEqual(['current retry mail'])
|
||||
})
|
||||
|
||||
it('drains a durable recovery cohort in bounded replaying pages before unread mail', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const first = db!.getLegacyMailPage({ principalId: state.workerPrincipalId, limit: 1 })
|
||||
const replay = db!.getLegacyMailPage({ principalId: state.workerPrincipalId, limit: 1 })
|
||||
|
||||
expect(first.recovery).toBe(true)
|
||||
expect(replay.messages.map((message) => message.id)).toEqual(
|
||||
first.messages.map((message) => message.id)
|
||||
)
|
||||
expect(() =>
|
||||
db!.acknowledgeLegacyMail({
|
||||
principalId: state.workerPrincipalId,
|
||||
messageIds: [state.fixture.legacyMessageIds[2]]
|
||||
})
|
||||
).toThrow(/current replay page/)
|
||||
db!.acknowledgeLegacyMail({
|
||||
principalId: state.workerPrincipalId,
|
||||
messageIds: first.messages.map((message) => message.id)
|
||||
})
|
||||
const second = db!.getLegacyMailPage({ principalId: state.workerPrincipalId, limit: 1 })
|
||||
expect(second.recovery).toBe(true)
|
||||
expect(second.messages[0].id).not.toBe(first.messages[0].id)
|
||||
db!.acknowledgeLegacyMail({
|
||||
principalId: state.workerPrincipalId,
|
||||
messageIds: second.messages.map((message) => message.id)
|
||||
})
|
||||
expect(db!.getLegacyMailPage({ principalId: state.workerPrincipalId, limit: 1 }).recovery).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns complete addressed legacy history without changing read state', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const unread = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_coord',
|
||||
to: 'term_legacy_worker',
|
||||
subject: 'unread history'
|
||||
})
|
||||
db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'other',
|
||||
to: 'other',
|
||||
subject: 'not addressed'
|
||||
})
|
||||
|
||||
const history = db!.getLegacyMailHistory({ principalId: state.workerPrincipalId })
|
||||
|
||||
expect(history.recovery).toBe(false)
|
||||
expect(history.messages.map((message) => message.subject)).toEqual([
|
||||
'read worker mail',
|
||||
'second worker page',
|
||||
'unread history'
|
||||
])
|
||||
expect(db!.getMessageById(unread.id)?.read).toBe(0)
|
||||
})
|
||||
|
||||
it('resolves legacy principals and completion evidence only within exact assignments', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const taskId = db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id
|
||||
const payload = JSON.stringify({ taskId, dispatchId: state.fixture.legacyDispatchId })
|
||||
const completion = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'done',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
})
|
||||
|
||||
expect(
|
||||
db!.resolveLegacyWorkerCandidate({
|
||||
runId: state.adoptedRunId,
|
||||
terminalHandle: 'term_legacy_worker',
|
||||
paneKey: 'tab_reminted:33333333-3333-4333-8333-333333333333',
|
||||
taskId
|
||||
})
|
||||
).toMatchObject({ dispatch: { id: state.fixture.legacyDispatchId } })
|
||||
expect(
|
||||
db!.resolveLegacyWorkerCandidate({
|
||||
runId: state.adoptedRunId,
|
||||
terminalHandle: 'term_legacy_worker',
|
||||
paneKey: 'tab_wrong:99999999-9999-4999-8999-999999999999',
|
||||
taskId
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
db!.resolveLegacyCoordinatorCandidate({
|
||||
runId: state.adoptedRunId,
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toMatchObject({ terminalHandle: 'term_legacy_coord' })
|
||||
expect(
|
||||
db!.findLegacyWorkerCompletion({
|
||||
principalId: state.workerPrincipalId,
|
||||
taskId,
|
||||
recipientHandle: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'done',
|
||||
payload
|
||||
})
|
||||
).toMatchObject({ id: completion.id })
|
||||
})
|
||||
|
||||
it('prevents an unproven coordinator from taking over an active adopted Run', () => {
|
||||
const state = openAdoptedFixture()
|
||||
expect(() =>
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_other',
|
||||
coordinatorPaneKey: 'tab_other:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
).toThrow(/attested coordinator may rebind/)
|
||||
|
||||
const bound = db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_legacy_coord',
|
||||
coordinatorPaneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
legacyCoordinatorAuthority: {
|
||||
runId: state.adoptedRunId,
|
||||
principalId: state.coordinatorPrincipalId,
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
consumerGeneration: 0
|
||||
}
|
||||
})
|
||||
expect(bound).toMatchObject({ coordinator_handle: 'term_legacy_coord' })
|
||||
expect(
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_legacy_coord',
|
||||
coordinatorPaneKey: 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
).toMatchObject({ coordinator_handle: 'term_legacy_coord' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['generation', { consumerGeneration: 1 }],
|
||||
['principal', { principalId: 'legacy_principal_wrong' }],
|
||||
['handle', { terminalHandle: 'term_wrong' }],
|
||||
['pane', { paneKey: 'tab_wrong:99999999-9999-4999-8999-999999999999' }]
|
||||
] as const)('rejects stale legacy coordinator %s proof inside Run binding', (_label, patch) => {
|
||||
const state = openAdoptedFixture()
|
||||
const before = db!.getRun(state.adoptedRunId)
|
||||
|
||||
expect(() =>
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_legacy_coord',
|
||||
coordinatorPaneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
legacyCoordinatorAuthority: {
|
||||
runId: state.adoptedRunId,
|
||||
principalId: state.coordinatorPrincipalId,
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
consumerGeneration: 0,
|
||||
...patch
|
||||
}
|
||||
})
|
||||
).toThrow(/no longer has lifecycle authority/)
|
||||
expect(db!.getRun(state.adoptedRunId)).toEqual(before)
|
||||
})
|
||||
|
||||
it('revokes the legacy coordinator when a current coordinator takes over after settlement', () => {
|
||||
const state = openAdoptedFixture()
|
||||
db!.settleWorkerReport({
|
||||
taskId: state.fixture.legacyTaskId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'done'
|
||||
})
|
||||
|
||||
expect(
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
).toMatchObject({ coordinator_handle: 'term_current_coord' })
|
||||
expect(db!.getLegacyCompatibilityPrincipal(state.coordinatorPrincipalId)?.status).toBe(
|
||||
'revoked'
|
||||
)
|
||||
expect(() =>
|
||||
db!.commitLegacyCompatibilityPrincipal({
|
||||
runId: state.adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: 'local:runtime_1',
|
||||
terminalHandle: 'term_legacy_coord',
|
||||
paneKey: 'tab_coord:44444444-4444-4444-8444-444444444444',
|
||||
launchTokenHash: 'coord_launch_hash',
|
||||
processIncarnation: 'process_coord'
|
||||
})
|
||||
).toThrow(/revoked/)
|
||||
})
|
||||
|
||||
it('promotes only mail addressed to the replaced legacy coordinator', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const coordinatorMail = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'coordinator outcome'
|
||||
})
|
||||
const workerMail = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_other_worker',
|
||||
subject: 'worker-only guidance'
|
||||
})
|
||||
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
expect(db!.getMessageById(coordinatorMail.id)).toMatchObject({
|
||||
to_handle: `run:${state.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery'
|
||||
})
|
||||
expect(db!.getMessageById(workerMail.id)).toMatchObject({
|
||||
to_handle: 'term_other_worker',
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
})
|
||||
|
||||
it('commits legacy messages, lifecycle effects, and invocation receipts exactly once', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const params = {
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'invocation_1',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'payload_1',
|
||||
message: {
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'alive',
|
||||
type: 'heartbeat' as const
|
||||
},
|
||||
lifecycle: { kind: 'heartbeat' as const, at: '2026-07-28T12:00:00.000Z' }
|
||||
}
|
||||
|
||||
const first = db!.commitLegacyLifecycleOperation(params)
|
||||
const replay = db!.commitLegacyLifecycleOperation(params)
|
||||
|
||||
expect(replay).toMatchObject({
|
||||
duplicate: true,
|
||||
message: { id: first.message.id },
|
||||
receipt: { effect_id: first.message.id }
|
||||
})
|
||||
expect(db!.getDispatchContextById(state.fixture.legacyDispatchId)?.last_heartbeat_at).toBe(
|
||||
'2026-07-28T12:00:00.000Z'
|
||||
)
|
||||
expect(db!.getUnreadMessages('term_legacy_coord')).toEqual([])
|
||||
expect(db!.getUndeliveredUnreadMessages('term_legacy_coord')).toEqual([])
|
||||
expect(() =>
|
||||
db!.commitLegacyLifecycleOperation({ ...params, payloadHash: 'different' })
|
||||
).toThrow(/different input/)
|
||||
})
|
||||
|
||||
it('reconstructs a matching pre-receipt settlement without changing its persisted outcome', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const accepted = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
type: 'worker_done'
|
||||
})
|
||||
expect(
|
||||
db!.settleWorkerReport({
|
||||
taskId: db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted by A'
|
||||
})
|
||||
).toMatchObject({ action: 'settled', duplicate: false })
|
||||
|
||||
const reconstructed = db!.commitLegacyLifecycleOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: `settlement:${state.fixture.legacyDispatchId}`,
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'settlement_payload',
|
||||
message: {
|
||||
existingId: accepted.id,
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
type: 'worker_done'
|
||||
},
|
||||
lifecycle: {
|
||||
kind: 'worker_report',
|
||||
taskId: db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted by A'
|
||||
}
|
||||
})
|
||||
|
||||
expect(reconstructed).toMatchObject({
|
||||
duplicate: false,
|
||||
message: { id: accepted.id },
|
||||
settlement: { action: 'settled', outcome: 'succeeded', duplicate: true }
|
||||
})
|
||||
expect(db!.getLegacyCompatibilityPrincipal(state.workerPrincipalId)?.status).toBe('settled')
|
||||
})
|
||||
|
||||
it('reconstructs a read pre-takeover completion through its original legacy route', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const taskId = db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id
|
||||
const accepted = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted before takeover',
|
||||
type: 'worker_done'
|
||||
})
|
||||
db!.markAsRead([accepted.id])
|
||||
db!.settleWorkerReport({
|
||||
taskId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted before takeover'
|
||||
})
|
||||
db!.bindRun({
|
||||
runId: state.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
const beforeIds = db!.getInbox(100).map((message) => message.id)
|
||||
|
||||
const reconstructed = db!.commitLegacyLifecycleOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'read_completion_after_takeover',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'read_completion_after_takeover_payload',
|
||||
message: {
|
||||
existingId: accepted.id,
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted before takeover',
|
||||
type: 'worker_done'
|
||||
},
|
||||
lifecycle: {
|
||||
kind: 'worker_report',
|
||||
taskId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted before takeover'
|
||||
}
|
||||
})
|
||||
|
||||
expect(reconstructed).toMatchObject({
|
||||
duplicate: false,
|
||||
message: {
|
||||
id: accepted.id,
|
||||
to_handle: 'term_legacy_coord',
|
||||
delivery_contract: 'legacy_direct'
|
||||
},
|
||||
settlement: { action: 'settled', outcome: 'succeeded', duplicate: true }
|
||||
})
|
||||
expect(db!.getInbox(100).map((message) => message.id)).toEqual(beforeIds)
|
||||
})
|
||||
|
||||
it('rejects cross-cutover completion reconstruction for another recipient', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const taskId = db!.getDispatchContextById(state.fixture.legacyDispatchId)!.task_id
|
||||
const payload = JSON.stringify({ taskId, dispatchId: state.fixture.legacyDispatchId })
|
||||
const foreign = db!.insertMessage({
|
||||
runId: state.adoptedRunId,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_other_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted elsewhere',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
})
|
||||
db!.settleWorkerReport({
|
||||
taskId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted elsewhere'
|
||||
})
|
||||
const beforeIds = db!.getInbox(100).map((message) => message.id)
|
||||
|
||||
expect(
|
||||
db!.findLegacyWorkerCompletion({
|
||||
principalId: state.workerPrincipalId,
|
||||
taskId,
|
||||
recipientHandle: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted elsewhere',
|
||||
payload
|
||||
})
|
||||
).toBeUndefined()
|
||||
expect(() =>
|
||||
db!.commitLegacyLifecycleOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'wrong_recipient_retry',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'wrong_recipient_retry_payload',
|
||||
message: {
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted elsewhere',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
},
|
||||
lifecycle: {
|
||||
kind: 'worker_report',
|
||||
taskId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted elsewhere'
|
||||
}
|
||||
})
|
||||
).toThrow(/settled/)
|
||||
expect(() =>
|
||||
db!.commitLegacyLifecycleOperation({
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'wrong_recipient_reconstruction',
|
||||
method: 'orchestration.send',
|
||||
payloadHash: 'wrong_recipient_payload',
|
||||
message: {
|
||||
existingId: foreign.id,
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Completed',
|
||||
body: 'accepted elsewhere',
|
||||
type: 'worker_done',
|
||||
payload
|
||||
},
|
||||
lifecycle: {
|
||||
kind: 'worker_report',
|
||||
taskId,
|
||||
outcome: 'succeeded',
|
||||
result: 'accepted elsewhere'
|
||||
}
|
||||
})
|
||||
).toThrow(/does not match this principal/)
|
||||
expect(db!.getInbox(100).map((message) => message.id)).toEqual(beforeIds)
|
||||
expect(
|
||||
db!.getLegacyOperationReceipt(state.workerPrincipalId, 'wrong_recipient_reconstruction')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
db!.getLegacyOperationReceipt(state.workerPrincipalId, 'wrong_recipient_retry')
|
||||
).toBeUndefined()
|
||||
expect(db!.getTask(taskId)).toMatchObject({
|
||||
status: 'completed',
|
||||
result: 'accepted elsewhere'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses invocation identity for repeated asks and atomically conflicts divergent replies', () => {
|
||||
const state = openAdoptedFixture()
|
||||
const ask = {
|
||||
principalId: state.workerPrincipalId,
|
||||
operationKey: 'ask_invocation_1',
|
||||
method: 'orchestration.ask',
|
||||
payloadHash: 'ask_payload',
|
||||
question: 'Same text?',
|
||||
options: ['yes', 'no'],
|
||||
recipientHandle: 'term_legacy_coord'
|
||||
}
|
||||
const first = db!.commitLegacyAskOperation(ask)
|
||||
const replay = db!.commitLegacyAskOperation(ask)
|
||||
const repeated = db!.commitLegacyAskOperation({
|
||||
...ask,
|
||||
operationKey: 'ask_invocation_2'
|
||||
})
|
||||
|
||||
expect(replay).toMatchObject({ duplicate: true, question: { message_id: first.message.id } })
|
||||
expect(repeated.message.id).not.toBe(first.message.id)
|
||||
expect(db!.findPendingLegacyQuestions(ask)).toHaveLength(2)
|
||||
expect(
|
||||
db!.findPendingLegacyQuestions({
|
||||
...ask,
|
||||
question: ' Same text?\r\n',
|
||||
options: ['yes ', ' no']
|
||||
})
|
||||
).toHaveLength(2)
|
||||
|
||||
const inherited = db!.createQuestion({
|
||||
runId: state.adoptedRunId,
|
||||
dispatchId: state.fixture.legacyDispatchId,
|
||||
askerHandle: 'term_legacy_worker',
|
||||
question: 'Inherited?'
|
||||
})
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET from_handle = 'term_legacy_worker', to_handle = 'term_legacy_coord',
|
||||
delivery_contract = 'legacy_direct'
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(inherited.message.id)
|
||||
const inheritedAsk = {
|
||||
...ask,
|
||||
operationKey: 'ask_inherited_1',
|
||||
payloadHash: 'ask_inherited_payload_1',
|
||||
question: 'Inherited?',
|
||||
options: [],
|
||||
existingQuestionId: inherited.message.id
|
||||
}
|
||||
const adopted = db!.commitLegacyAskOperation(inheritedAsk)
|
||||
const distinct = db!.commitLegacyAskOperation({
|
||||
...inheritedAsk,
|
||||
operationKey: 'ask_inherited_2',
|
||||
payloadHash: 'ask_inherited_payload_2'
|
||||
})
|
||||
expect(adopted.message.id).toBe(inherited.message.id)
|
||||
expect(distinct.message.id).not.toBe(inherited.message.id)
|
||||
|
||||
const reply = {
|
||||
principalId: state.coordinatorPrincipalId,
|
||||
operationKey: 'reply_invocation_1',
|
||||
method: 'orchestration.reply',
|
||||
payloadHash: 'reply_payload',
|
||||
questionId: first.message.id,
|
||||
body: 'yes'
|
||||
}
|
||||
const answered = db!.commitLegacyReplyOperation(reply)
|
||||
const answerReplay = db!.commitLegacyReplyOperation(reply)
|
||||
expect(answerReplay).toMatchObject({
|
||||
duplicate: true,
|
||||
message: { id: answered.message.id }
|
||||
})
|
||||
expect(
|
||||
db!
|
||||
.findLegacyQuestionsBySemanticIdentity(ask)
|
||||
.find((row) => row.question.message_id === first.message.id)
|
||||
).toMatchObject({ question: { status: 'answered' }, answerAcknowledged: false })
|
||||
db!.acknowledgeLegacyQuestionAnswer({
|
||||
principalId: state.workerPrincipalId,
|
||||
questionId: first.message.id,
|
||||
answerMessageId: answered.message.id
|
||||
})
|
||||
expect(
|
||||
db!
|
||||
.findLegacyQuestionsBySemanticIdentity(ask)
|
||||
.find((row) => row.question.message_id === first.message.id)
|
||||
).toMatchObject({ answerAcknowledged: true })
|
||||
expect(() =>
|
||||
db!.commitLegacyReplyOperation({
|
||||
...reply,
|
||||
operationKey: 'reply_invocation_2',
|
||||
payloadHash: 'different_reply',
|
||||
body: 'no'
|
||||
})
|
||||
).toThrow(/different answer/)
|
||||
|
||||
const currentTask = db!.createTask({ runId: state.adoptedRunId, spec: 'current retry' })
|
||||
const currentDispatch = db!.createDispatchContext(currentTask.id, 'term_current_retry')
|
||||
const currentQuestion = db!.createQuestion({
|
||||
runId: state.adoptedRunId,
|
||||
dispatchId: currentDispatch.id,
|
||||
askerHandle: 'term_current_retry',
|
||||
question: 'Current question?'
|
||||
})
|
||||
expect(() =>
|
||||
db!.commitLegacyReplyOperation({
|
||||
...reply,
|
||||
operationKey: 'reply_current_question',
|
||||
payloadHash: 'current_question',
|
||||
questionId: currentQuestion.message.id
|
||||
})
|
||||
).toThrow(/not actionable/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
|
||||
export type LegacyStorageCutoverFixture = {
|
||||
dbPath: string
|
||||
currentRunId: string
|
||||
unrelatedRunId: string
|
||||
currentDispatchId: string
|
||||
legacyTaskId: string
|
||||
legacyDispatchId: string
|
||||
legacyGateId: string
|
||||
legacyMessageIds: string[]
|
||||
legacyQuestionId: string
|
||||
legacyDeliveryId: string
|
||||
rejectionMessageId: string
|
||||
lookalikeMessageId: string
|
||||
malformedRejectionMessageIds: string[]
|
||||
}
|
||||
|
||||
export function createLegacyStorageCutoverFixture(): {
|
||||
fixture: LegacyStorageCutoverFixture
|
||||
tempDir: string
|
||||
} {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'orca-legacy-storage-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
const first = new OrchestrationDb(dbPath)
|
||||
const currentRun = first.createRun({
|
||||
objective: 'Current work',
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
const currentTask = first.createTask({ spec: 'current', runId: currentRun.id })
|
||||
const unrelatedRun = first.createRun({
|
||||
objective: 'Unrelated current work',
|
||||
coordinatorHandle: 'term_unrelated_coord',
|
||||
coordinatorPaneKey: 'tab_unrelated:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
const currentDispatch = first.createDispatchContext(
|
||||
currentTask.id,
|
||||
'term_current_worker',
|
||||
'tab_current:22222222-2222-4222-9222-222222222222',
|
||||
'current_launch_hash'
|
||||
)
|
||||
first.insertMessage({
|
||||
runId: currentRun.id,
|
||||
from: 'term_current_worker',
|
||||
to: `run:${currentRun.id}`,
|
||||
subject: 'current mail'
|
||||
})
|
||||
|
||||
const legacyTask = first.createTask({
|
||||
spec: 'legacy',
|
||||
createdByTerminalHandle: 'term_legacy_coord'
|
||||
})
|
||||
first.createDispatchContext(
|
||||
legacyTask.id,
|
||||
'term_legacy_worker',
|
||||
'tab_legacy:33333333-3333-4333-8333-333333333333'
|
||||
)
|
||||
const legacyGate = first.createGate({
|
||||
taskId: legacyTask.id,
|
||||
question: 'Retained gate?'
|
||||
})
|
||||
first.resolveGate(legacyGate.id, 'continue')
|
||||
const retryDispatch = first.createDispatchContext(
|
||||
legacyTask.id,
|
||||
'term_legacy_worker',
|
||||
'tab_legacy:33333333-3333-4333-8333-333333333333'
|
||||
)
|
||||
const legacyMessages = [
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_coord',
|
||||
to: 'term_legacy_worker',
|
||||
subject: 'read worker mail'
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'read coordinator mail'
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_coord',
|
||||
to: 'term_legacy_worker',
|
||||
subject: 'second worker page'
|
||||
})
|
||||
]
|
||||
first.markAsRead(legacyMessages.map((message) => message.id))
|
||||
const question = first.createQuestion({
|
||||
runId: LEGACY_RUN_ID,
|
||||
dispatchId: retryDispatch.id,
|
||||
askerHandle: 'term_legacy_worker',
|
||||
question: 'Retained question?'
|
||||
})
|
||||
const rejection = first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Rejected heartbeat',
|
||||
type: 'heartbeat',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: { code: 'migration', reason: 'cutover' } })
|
||||
})
|
||||
const lookalike = first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Ordinary legacy mail',
|
||||
payload: JSON.stringify({
|
||||
userData: { _orcaLifecycleRejection: { code: 'not-a-top-level-audit-marker' } }
|
||||
})
|
||||
})
|
||||
const malformedRejections = [
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Invalid JSON marker',
|
||||
payload: '{"_orcaLifecycleRejection":'
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Array marker',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: [] })
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'String marker',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: 'migration' })
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Incomplete marker',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: { code: 'migration' } })
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Non-string marker fields',
|
||||
payload: JSON.stringify({ _orcaLifecycleRejection: { code: 19, reason: false } })
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'Array root',
|
||||
payload: JSON.stringify([
|
||||
{ _orcaLifecycleRejection: { code: 'migration', reason: 'nested' } }
|
||||
])
|
||||
}),
|
||||
first.insertMessage({
|
||||
from: 'term_legacy_worker',
|
||||
to: 'term_legacy_coord',
|
||||
subject: 'String root',
|
||||
payload: JSON.stringify('_orcaLifecycleRejection')
|
||||
})
|
||||
]
|
||||
first.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
const legacyDeliveryId = 'delivery_legacy_outstanding'
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO deliveries (
|
||||
id, run_id, consumer_generation, message_ids, status
|
||||
) VALUES (?, ?, 0, ?, 'outstanding')`
|
||||
)
|
||||
.run(legacyDeliveryId, LEGACY_RUN_ID, JSON.stringify([legacyMessages[0].id]))
|
||||
raw
|
||||
.prepare("UPDATE messages SET delivery_contract = 'legacy_direct' WHERE id = ?")
|
||||
.run(rejection.id)
|
||||
const seedAuditOnly = raw.prepare(
|
||||
"UPDATE messages SET delivery_contract = 'audit_only' WHERE id = ?"
|
||||
)
|
||||
for (const message of [lookalike, ...malformedRejections]) {
|
||||
seedAuditOnly.run(message.id)
|
||||
}
|
||||
raw.exec(`
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
fixture: {
|
||||
dbPath,
|
||||
currentRunId: currentRun.id,
|
||||
unrelatedRunId: unrelatedRun.id,
|
||||
currentDispatchId: currentDispatch.id,
|
||||
legacyTaskId: legacyTask.id,
|
||||
legacyDispatchId: retryDispatch.id,
|
||||
legacyGateId: legacyGate.id,
|
||||
legacyMessageIds: legacyMessages.map((message) => message.id),
|
||||
legacyQuestionId: question.message.id,
|
||||
legacyDeliveryId,
|
||||
rejectionMessageId: rejection.id,
|
||||
lookalikeMessageId: lookalike.id,
|
||||
malformedRejectionMessageIds: malformedRejections.map((message) => message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { LegacyWorkerTerminalRecoveryRow } from './types'
|
||||
import { planLegacyWorkerTerminalRecovery } from './orchestration-legacy-worker-terminal-recovery'
|
||||
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function recoveryRow(
|
||||
overrides: Partial<LegacyWorkerTerminalRecoveryRow> = {}
|
||||
): LegacyWorkerTerminalRecoveryRow {
|
||||
return {
|
||||
dispatch_id: 'dispatch-1',
|
||||
task_id: 'task-1',
|
||||
dispatch_status: 'completed',
|
||||
contract_version: 0,
|
||||
assignee_handle: 'term-worker',
|
||||
assignee_pane_key: `tab-worker:${LEAF_ID}`,
|
||||
process_incarnation: `ssh:ssh-1@@pty-worker:${INCARNATION_ID}`,
|
||||
worker_state: 'ready',
|
||||
worktree_id: 'repo::/workspace',
|
||||
agent_terminal_handle: 'term-worker',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('legacy worker terminal recovery planning', () => {
|
||||
it('retains completed Dispatches when the worker process row is still live', () => {
|
||||
expect(planLegacyWorkerTerminalRecovery([recoveryRow()])).toEqual({
|
||||
blockedPanes: [
|
||||
{
|
||||
worktreeId: 'repo::/workspace',
|
||||
paneKey: `tab-worker:${LEAF_ID}`,
|
||||
contractVersion: 0
|
||||
}
|
||||
],
|
||||
candidates: [
|
||||
expect.objectContaining({
|
||||
dispatchId: 'dispatch-1',
|
||||
ptyId: 'ssh:ssh-1@@pty-worker',
|
||||
incarnationId: INCARNATION_ID
|
||||
})
|
||||
],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks resume but refuses recovery when durable handles disagree', () => {
|
||||
expect(
|
||||
planLegacyWorkerTerminalRecovery([recoveryRow({ agent_terminal_handle: 'term-replacement' })])
|
||||
).toEqual({
|
||||
blockedPanes: [
|
||||
{
|
||||
worktreeId: 'repo::/workspace',
|
||||
paneKey: `tab-worker:${LEAF_ID}`,
|
||||
contractVersion: 0
|
||||
}
|
||||
],
|
||||
candidates: [],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when two Dispatches claim one terminal identity', () => {
|
||||
const plan = planLegacyWorkerTerminalRecovery([
|
||||
recoveryRow(),
|
||||
recoveryRow({ dispatch_id: 'dispatch-2', task_id: 'task-2' })
|
||||
])
|
||||
|
||||
expect(plan.candidates).toEqual([])
|
||||
expect(plan.ambiguousDispatchIds).toEqual(['dispatch-1', 'dispatch-2'])
|
||||
expect(plan.blockedPanes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not trust malformed pane or process identities', () => {
|
||||
const plan = planLegacyWorkerTerminalRecovery([
|
||||
recoveryRow({
|
||||
assignee_pane_key: 'tab-worker:1',
|
||||
process_incarnation: 'runtime:pty:generation'
|
||||
})
|
||||
])
|
||||
|
||||
expect(plan).toEqual({
|
||||
blockedPanes: [],
|
||||
candidates: [],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { isPtyIncarnationId, type PtyIncarnationId } from '../../../shared/pty-incarnation'
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import type { LegacyWorkerTerminalRecoveryRow } from './types'
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryCandidate = {
|
||||
dispatchId: string
|
||||
dispatchStatus: LegacyWorkerTerminalRecoveryRow['dispatch_status']
|
||||
contractVersion: number
|
||||
taskId: string
|
||||
worktreeId: string
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
processIncarnation: string
|
||||
ptyId: string
|
||||
incarnationId: PtyIncarnationId
|
||||
}
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryPlan = {
|
||||
blockedPanes: { worktreeId: string; paneKey: string; contractVersion: number }[]
|
||||
candidates: LegacyWorkerTerminalRecoveryCandidate[]
|
||||
ambiguousDispatchIds: string[]
|
||||
}
|
||||
|
||||
function parseProcessIncarnation(
|
||||
value: string
|
||||
): { ptyId: string; incarnationId: PtyIncarnationId } | null {
|
||||
const separator = value.lastIndexOf(':')
|
||||
if (separator <= 0) {
|
||||
return null
|
||||
}
|
||||
const ptyId = value.slice(0, separator)
|
||||
const incarnationId = value.slice(separator + 1)
|
||||
return ptyId && isPtyIncarnationId(incarnationId) ? { ptyId, incarnationId } : null
|
||||
}
|
||||
|
||||
function countCandidateKeys(
|
||||
candidates: readonly LegacyWorkerTerminalRecoveryCandidate[],
|
||||
select: (candidate: LegacyWorkerTerminalRecoveryCandidate) => string
|
||||
): Map<string, number> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const candidate of candidates) {
|
||||
const key = select(candidate)
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
export function planLegacyWorkerTerminalRecovery(
|
||||
rows: readonly LegacyWorkerTerminalRecoveryRow[]
|
||||
): LegacyWorkerTerminalRecoveryPlan {
|
||||
const blockedPanes = new Map<
|
||||
string,
|
||||
{ worktreeId: string; paneKey: string; contractVersion: number }
|
||||
>()
|
||||
const parsedCandidates: LegacyWorkerTerminalRecoveryCandidate[] = []
|
||||
for (const row of rows) {
|
||||
const worktreeId = row.worktree_id?.trim()
|
||||
const paneKey = row.assignee_pane_key?.trim()
|
||||
const pane = paneKey ? parsePaneKey(paneKey) : null
|
||||
if (worktreeId && paneKey && pane) {
|
||||
blockedPanes.set(`${worktreeId}\0${paneKey}`, {
|
||||
worktreeId,
|
||||
paneKey,
|
||||
contractVersion: row.contract_version
|
||||
})
|
||||
}
|
||||
const terminalHandle = row.assignee_handle?.trim()
|
||||
const workerHandle = row.agent_terminal_handle?.trim()
|
||||
const processIncarnation = row.process_incarnation?.trim()
|
||||
const process = processIncarnation ? parseProcessIncarnation(processIncarnation) : null
|
||||
if (
|
||||
!worktreeId ||
|
||||
!paneKey ||
|
||||
!pane ||
|
||||
!terminalHandle ||
|
||||
terminalHandle !== workerHandle ||
|
||||
!processIncarnation ||
|
||||
!process
|
||||
) {
|
||||
continue
|
||||
}
|
||||
parsedCandidates.push({
|
||||
dispatchId: row.dispatch_id,
|
||||
dispatchStatus: row.dispatch_status,
|
||||
contractVersion: row.contract_version,
|
||||
taskId: row.task_id,
|
||||
worktreeId,
|
||||
terminalHandle,
|
||||
paneKey,
|
||||
tabId: pane.tabId,
|
||||
leafId: pane.leafId,
|
||||
processIncarnation,
|
||||
ptyId: process.ptyId,
|
||||
incarnationId: process.incarnationId
|
||||
})
|
||||
}
|
||||
|
||||
const identityCounts = [
|
||||
countCandidateKeys(parsedCandidates, (candidate) => candidate.terminalHandle),
|
||||
countCandidateKeys(parsedCandidates, (candidate) => candidate.paneKey),
|
||||
countCandidateKeys(parsedCandidates, (candidate) => candidate.processIncarnation)
|
||||
]
|
||||
const ambiguousDispatchIds = new Set<string>()
|
||||
const candidates = parsedCandidates.filter((candidate) => {
|
||||
const keys = [candidate.terminalHandle, candidate.paneKey, candidate.processIncarnation]
|
||||
const ambiguous = keys.some((key, index) => (identityCounts[index]?.get(key) ?? 0) !== 1)
|
||||
if (ambiguous) {
|
||||
ambiguousDispatchIds.add(candidate.dispatchId)
|
||||
}
|
||||
return !ambiguous
|
||||
})
|
||||
return {
|
||||
blockedPanes: [...blockedPanes.values()],
|
||||
candidates,
|
||||
ambiguousDispatchIds: [...ambiguousDispatchIds]
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ import type Database from '../../sqlite/sync-database'
|
||||
|
||||
const POST_V6_COLUMNS = [
|
||||
['messages', 'run_id'],
|
||||
['messages', 'delivery_contract'],
|
||||
['coordinator_runs', 'scheduler_lost_at'],
|
||||
['tasks', 'run_id'],
|
||||
['dispatch_contexts', 'run_id'],
|
||||
['dispatch_contexts', 'contract_version'],
|
||||
['dispatch_contexts', 'launch_token_hash'],
|
||||
['dispatch_contexts', 'capability_hash'],
|
||||
['dispatch_contexts', 'process_incarnation'],
|
||||
['dispatch_contexts', 'capability_revoked_at'],
|
||||
@@ -14,11 +18,16 @@ const POST_V6_COLUMNS = [
|
||||
['remote_dispatch_attachments', 'to_worker_imported_sequence'],
|
||||
['remote_dispatch_attachments', 'protocol_version'],
|
||||
['federation_relay_items', 'dispatch_id'],
|
||||
['remote_questions', 'message_id']
|
||||
['remote_questions', 'message_id'],
|
||||
['legacy_adoptions', 'source_run_id'],
|
||||
['legacy_compatibility_principals', 'id'],
|
||||
['legacy_operation_receipts', 'principal_id'],
|
||||
['legacy_mail_receipts', 'principal_id']
|
||||
] as const
|
||||
|
||||
const POST_V6_INDEXES = [
|
||||
'idx_messages_run_sequence',
|
||||
'idx_messages_delivery_contract',
|
||||
'idx_tasks_run_status',
|
||||
'idx_dispatch_run_status',
|
||||
'idx_gates_run_status',
|
||||
@@ -46,11 +55,39 @@ function messagesAllowQuestions(db: Database.Database): boolean {
|
||||
return !!row && row.sql.includes("'question'")
|
||||
}
|
||||
|
||||
function hasConsistentLegacyAdoption(db: Database.Database): boolean {
|
||||
const sourceRunId = 'run_legacy_local'
|
||||
const sourceGraph = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
WHERE EXISTS(SELECT 1 FROM tasks WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM dispatch_contexts WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM decision_gates WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM messages WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM question_threads WHERE run_id = ?)
|
||||
OR EXISTS(SELECT 1 FROM deliveries WHERE run_id = ?)`
|
||||
)
|
||||
.get(sourceRunId, sourceRunId, sourceRunId, sourceRunId, sourceRunId, sourceRunId)
|
||||
const adoption = db
|
||||
.prepare('SELECT adopted_run_id FROM legacy_adoptions WHERE source_run_id = ?')
|
||||
.get(sourceRunId) as { adopted_run_id: string } | undefined
|
||||
if (sourceGraph) {
|
||||
return false
|
||||
}
|
||||
if (adoption) {
|
||||
return Boolean(
|
||||
db.prepare('SELECT 1 FROM runs WHERE id = ? AND legacy = 0').get(adoption.adopted_run_id)
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function hasCompletePostV6Schema(db: Database.Database): boolean {
|
||||
return (
|
||||
POST_V6_COLUMNS.every(([table, column]) => hasOrchestrationColumn(db, table, column)) &&
|
||||
POST_V6_INDEXES.every((index) => hasOrchestrationIndex(db, index)) &&
|
||||
messagesAllowQuestions(db)
|
||||
messagesAllowQuestions(db) &&
|
||||
hasConsistentLegacyAdoption(db)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,9 +96,12 @@ export function resolveOrchestrationMigrationStartVersion(
|
||||
storedVersion: number,
|
||||
schemaVersion: number
|
||||
): number {
|
||||
if (storedVersion >= schemaVersion || hasCompletePostV6Schema(db)) {
|
||||
if (storedVersion > schemaVersion) {
|
||||
return storedVersion
|
||||
}
|
||||
if (hasCompletePostV6Schema(db)) {
|
||||
return storedVersion
|
||||
}
|
||||
// Why: version-skewed pre-Run databases can claim the post-v6 range while retaining v6 tables.
|
||||
return Math.min(storedVersion, 6)
|
||||
return Math.min(storedVersion, schemaVersion, 6)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
import { LEGACY_CONTRACT_VERSION, LEGACY_RUN_ID, OrchestrationDb } from './db'
|
||||
import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew'
|
||||
|
||||
describe('OrchestrationDb version-skew migration', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
@@ -11,12 +12,14 @@ describe('OrchestrationDb version-skew migration', () => {
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
db = undefined
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function createLegacySchemaClaimingVersion17(): string {
|
||||
function createLegacySchemaClaimingVersion(claimedVersion = 17): string {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-db-version-skew-'))
|
||||
const dbPath = join(tempDir, 'orchestration.db')
|
||||
const raw = new Database(dbPath)
|
||||
@@ -126,20 +129,28 @@ describe('OrchestrationDb version-skew migration', () => {
|
||||
'gate_legacy', 'task_legacy', 'retained gate'
|
||||
);
|
||||
`)
|
||||
raw.pragma('user_version = 17')
|
||||
raw.pragma(`user_version = ${claimedVersion}`)
|
||||
raw.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
it('repairs retained v6 rows when the database already claims v17', () => {
|
||||
const dbPath = createLegacySchemaClaimingVersion17()
|
||||
const dbPath = createLegacySchemaClaimingVersion()
|
||||
db = new OrchestrationDb(dbPath)
|
||||
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id
|
||||
expect(adoptedRunId).toBeTruthy()
|
||||
expect(db.getRun(LEGACY_RUN_ID)).toMatchObject({ legacy: 1 })
|
||||
expect(db.getMessageById('msg_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID })
|
||||
expect(db.getTask('task_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID })
|
||||
expect(db.getDispatchContextById('ctx_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID })
|
||||
expect(db.getGate('gate_legacy')).toMatchObject({ run_id: LEGACY_RUN_ID })
|
||||
expect(db.getMessageById('msg_legacy')).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
expect(db.getTask('task_legacy')).toMatchObject({ run_id: adoptedRunId })
|
||||
expect(db.getDispatchContextById('ctx_legacy')).toMatchObject({
|
||||
run_id: adoptedRunId,
|
||||
contract_version: LEGACY_CONTRACT_VERSION
|
||||
})
|
||||
expect(db.getGate('gate_legacy')).toMatchObject({ run_id: adoptedRunId })
|
||||
|
||||
const run = db.createRun({
|
||||
objective: 'verify repaired orchestration',
|
||||
@@ -164,8 +175,18 @@ describe('OrchestrationDb version-skew migration', () => {
|
||||
db.close()
|
||||
db = undefined
|
||||
db = new OrchestrationDb(dbPath)
|
||||
expect(db.listTasks({ runId: LEGACY_RUN_ID }).map((row) => row.id)).toEqual(['task_legacy'])
|
||||
expect(db.listTasks({ runId: adoptedRunId }).map((row) => row.id)).toEqual(['task_legacy'])
|
||||
expect(db.getRun(run.id)).toBeDefined()
|
||||
expect(db.getQuestion(question.message.id)).toMatchObject({ status: 'pending' })
|
||||
})
|
||||
|
||||
it('does not repair an incomplete schema written by a future binary', () => {
|
||||
const dbPath = createLegacySchemaClaimingVersion(20)
|
||||
const raw = new Database(dbPath)
|
||||
|
||||
expect(resolveOrchestrationMigrationStartVersion(raw, 20, 19)).toBe(20)
|
||||
expect(raw.pragma('user_version', { simple: true })).toBe(20)
|
||||
|
||||
raw.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { CURRENT_CONTRACT_VERSION, OrchestrationDb } from './db'
|
||||
|
||||
describe('OrchestrationDb worker Dispatch state', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
@@ -44,6 +44,51 @@ describe('OrchestrationDb worker Dispatch state', () => {
|
||||
status: 'dispatched',
|
||||
assignee_handle: 'term_worker'
|
||||
})
|
||||
expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([
|
||||
expect.objectContaining({
|
||||
dispatch_id: started.dispatch.id,
|
||||
contract_version: CURRENT_CONTRACT_VERSION,
|
||||
worker_state: 'ready',
|
||||
agent_terminal_handle: 'term_worker'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('requeues an active Task before settling a worker whose terminal is missing', () => {
|
||||
const d = createDb()
|
||||
const task = d.createTask({ spec: 'recover missing worker' })
|
||||
const started = d.createStartingWorkerDispatch({
|
||||
taskId: task.id,
|
||||
startOptions: { topology: 'current', agent: 'codex' }
|
||||
})
|
||||
d.prepareStartingWorkerAuthority({
|
||||
dispatchId: started.dispatch.id,
|
||||
handle: 'term_missing',
|
||||
paneKey: 'tab_missing:11111111-1111-4111-8111-111111111111',
|
||||
processIncarnation: 'pty-missing:22222222-2222-4222-8222-222222222222',
|
||||
worktreeId: 'repo::worktree',
|
||||
setupState: 'not_applicable',
|
||||
effects: []
|
||||
})
|
||||
d.markWorkerDispatchReady(started.dispatch.id)
|
||||
|
||||
expect(
|
||||
d.reconcileMissingWorkerTerminal(started.dispatch.id, 'worker terminal is no longer live')
|
||||
).toMatchObject({
|
||||
state: 'abandoned',
|
||||
stage: 'terminal_missing',
|
||||
last_error: 'worker terminal is no longer live'
|
||||
})
|
||||
expect(d.getDispatchContextById(started.dispatch.id)).toMatchObject({
|
||||
status: 'failed',
|
||||
failure_count: 1,
|
||||
last_failure: 'worker terminal is no longer live'
|
||||
})
|
||||
expect(d.getTask(task.id)?.status).toBe('ready')
|
||||
|
||||
d.reconcileMissingWorkerTerminal(started.dispatch.id, 'duplicate recovery')
|
||||
expect(d.getDispatchContextById(started.dispatch.id)?.failure_count).toBe(1)
|
||||
expect(d.getTask(task.id)?.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('commits worker-start mutation acceptance with the starting Dispatch', () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ export type MessageType = (typeof MESSAGE_TYPES)[number]
|
||||
|
||||
export type MessagePriority = 'normal' | 'high' | 'urgent'
|
||||
|
||||
export type MessageDeliveryContract = 'legacy_direct' | 'current_delivery' | 'audit_only'
|
||||
|
||||
export type TaskStatus = 'pending' | 'ready' | 'dispatched' | 'completed' | 'failed' | 'blocked'
|
||||
|
||||
export type DispatchStatus = 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken'
|
||||
@@ -61,6 +63,46 @@ export type DeliveryRow = {
|
||||
acknowledged_at: string | null
|
||||
}
|
||||
|
||||
export type LegacyAdoptionRow = {
|
||||
source_run_id: string
|
||||
adopted_run_id: string
|
||||
scheduler_state_lost: number
|
||||
adopted_at: string
|
||||
}
|
||||
|
||||
export type LegacyPrincipalRole = 'worker' | 'coordinator'
|
||||
|
||||
export type LegacyPrincipalStatus = 'committed' | 'settled' | 'revoked'
|
||||
|
||||
export type LegacyCompatibilityPrincipalRow = {
|
||||
id: string
|
||||
run_id: string
|
||||
dispatch_id: string | null
|
||||
role: LegacyPrincipalRole
|
||||
host_scope: string
|
||||
terminal_handle: string
|
||||
pane_key: string
|
||||
launch_token_hash: string
|
||||
process_incarnation: string | null
|
||||
status: LegacyPrincipalStatus
|
||||
}
|
||||
|
||||
export type LegacyOperationReceiptRow = {
|
||||
principal_id: string
|
||||
operation_key: string
|
||||
method: string
|
||||
payload_hash: string
|
||||
effect_id: string
|
||||
response_json: string
|
||||
completed_at: string
|
||||
}
|
||||
|
||||
export type LegacyMailReceiptRow = {
|
||||
principal_id: string
|
||||
message_id: string
|
||||
acknowledged_at: string | null
|
||||
}
|
||||
|
||||
export type QuestionStatus = 'pending' | 'answered' | 'closed'
|
||||
|
||||
export type QuestionRow = {
|
||||
@@ -117,6 +159,19 @@ export type WorkerDispatchRow = {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryRow = {
|
||||
dispatch_id: string
|
||||
task_id: string
|
||||
dispatch_status: DispatchStatus
|
||||
contract_version: number
|
||||
assignee_handle: string | null
|
||||
assignee_pane_key: string | null
|
||||
process_incarnation: string | null
|
||||
worker_state: WorkerDispatchState
|
||||
worktree_id: string | null
|
||||
agent_terminal_handle: string | null
|
||||
}
|
||||
|
||||
export type FederatedDispatchRow = {
|
||||
dispatch_id: string
|
||||
environment_id: string
|
||||
@@ -170,6 +225,7 @@ export type FederationRelayItemRow = {
|
||||
export type MessageRow = {
|
||||
id: string
|
||||
run_id: string
|
||||
delivery_contract?: MessageDeliveryContract
|
||||
from_handle: string
|
||||
to_handle: string
|
||||
subject: string
|
||||
@@ -204,6 +260,8 @@ export type DispatchContextRow = {
|
||||
id: string
|
||||
run_id: string
|
||||
task_id: string
|
||||
contract_version: number
|
||||
launch_token_hash: string | null
|
||||
assignee_handle: string | null
|
||||
assignee_pane_key: string | null
|
||||
capability_hash: string | null
|
||||
@@ -238,4 +296,5 @@ export type CoordinatorRun = {
|
||||
poll_interval_ms: number
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
scheduler_lost_at: string | null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Why: single boundary between raw RPC frames and OrcaRuntimeService; keeps schema, handler, and result type on one object.
|
||||
import { ZodError, type ZodType } from 'zod'
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
|
||||
import type {
|
||||
DeviceCredentialInstalled,
|
||||
PairingGetEndpointsParams,
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PairingProvisionRelayParams
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
export type PairingRpcContext = {
|
||||
getEndpoints(params: PairingGetEndpointsParams): Promise<PairingGetEndpointsResult>
|
||||
@@ -48,8 +49,18 @@ export type RpcRequest = {
|
||||
orchestrationCapability?: string
|
||||
orchestrationContractVersion?: number
|
||||
orchestrationRequestId?: string
|
||||
compatibilityInvocationId?: string
|
||||
orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence
|
||||
}
|
||||
|
||||
export type LegacyCoordinatorAuthorityProof = Readonly<{
|
||||
runId: string
|
||||
principalId: string | null
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
consumerGeneration: number
|
||||
}>
|
||||
|
||||
export type RpcContext = {
|
||||
runtime: OrcaRuntimeService
|
||||
// Why: lets long-poll handlers release immediately on client disconnect instead of running down timeoutMs. See design doc §3.1.
|
||||
@@ -77,6 +88,11 @@ export type RpcContext = {
|
||||
method: string
|
||||
payloadHash: string
|
||||
}
|
||||
// Why: only the compatibility authority router can set this trusted scope; user params cannot bypass Run consumer binding.
|
||||
legacyCoordinatorRunId?: string
|
||||
legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof
|
||||
revalidateLegacyCoordinator?: () => string
|
||||
orchestrationCompatibilityCallerAuthority?: OrchestrationCompatibilityCallerAuthority
|
||||
// Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage.
|
||||
authenticatedCallerFingerprint?: string
|
||||
pairing?: PairingRpcContext
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { InvalidArgumentError, ZodError, formatZodError } from './core'
|
||||
import type { RpcEnvelopeMeta, RpcRequest, RpcResponse } from './core'
|
||||
import {
|
||||
computerErrorData,
|
||||
errorResponse,
|
||||
mapBrowserError,
|
||||
mapEmulatorError,
|
||||
mapRuntimeError
|
||||
} from './errors'
|
||||
|
||||
export function invalidArgumentResponse(
|
||||
request: RpcRequest,
|
||||
meta: RpcEnvelopeMeta,
|
||||
message: string
|
||||
): RpcResponse {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'invalid_argument',
|
||||
message,
|
||||
request.method.startsWith('computer.') ? computerErrorData('invalid_argument') : undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function mapDispatcherError(
|
||||
request: RpcRequest,
|
||||
meta: RpcEnvelopeMeta,
|
||||
error: unknown
|
||||
): RpcResponse {
|
||||
if (error instanceof ZodError) {
|
||||
return invalidArgumentResponse(request, meta, formatZodError(error))
|
||||
}
|
||||
if (error instanceof InvalidArgumentError) {
|
||||
return invalidArgumentResponse(request, meta, error.message)
|
||||
}
|
||||
if (request.method.startsWith('browser.')) {
|
||||
return mapBrowserError(request.id, meta, error)
|
||||
}
|
||||
if (request.method.startsWith('emulator.')) {
|
||||
return mapEmulatorError(request.id, meta, error)
|
||||
}
|
||||
return mapRuntimeError(request.id, meta, error)
|
||||
}
|
||||
@@ -46,6 +46,10 @@ function makeRuntime(ui: PersistedUIState = getDefaultUIState()): OrcaRuntimeSer
|
||||
updateUIState: vi.fn((updates: Partial<PersistedUIState>) => {
|
||||
currentUI = { ...currentUI, ...updates }
|
||||
return currentUI
|
||||
}),
|
||||
getOrchestrationDb: () => ({
|
||||
getLegacyAdoption: () => undefined,
|
||||
resolveLegacyWorkerCandidate: () => undefined
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { PairingRpcContext } from './core'
|
||||
|
||||
export type RpcDispatchStreamingOptions = {
|
||||
connectionId?: string
|
||||
signal?: AbortSignal
|
||||
clientId?: string
|
||||
pairedDeviceId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
pairing?: PairingRpcContext
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
|
||||
registerBinaryStreamHandler?: (
|
||||
streamId: number,
|
||||
handler: (frame: TerminalStreamFrame) => void
|
||||
) => () => void
|
||||
}
|
||||
+100
-118
@@ -1,57 +1,44 @@
|
||||
// Why: the dispatcher is the one place that knows how to turn a validated
|
||||
// RPC request into a response envelope. Splitting it from the transport
|
||||
// makes it unit-testable without spinning up a socket, and keeps
|
||||
// runtime-rpc.ts focused on framing/auth/connection bookkeeping.
|
||||
import {
|
||||
ZodError,
|
||||
InvalidArgumentError,
|
||||
buildRegistry,
|
||||
formatZodError,
|
||||
isStreamingMethod,
|
||||
type RpcAnyMethod,
|
||||
type RpcEnvelopeMeta,
|
||||
type PairingRpcContext,
|
||||
type RpcRegistry,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from './core'
|
||||
|
||||
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
|
||||
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
|
||||
import {
|
||||
computerErrorData,
|
||||
errorResponse,
|
||||
mapBrowserError,
|
||||
mapEmulatorError,
|
||||
mapRuntimeError,
|
||||
successResponse
|
||||
} from './errors'
|
||||
import { errorResponse, successResponse } from './errors'
|
||||
import { ALL_RPC_METHODS } from './methods'
|
||||
import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { RuntimeCapability } from '../../../shared/protocol-version'
|
||||
import {
|
||||
OrchestrationMutationExecutor,
|
||||
authenticatedCallerFingerprint,
|
||||
getOrchestrationMutationExecutor,
|
||||
type OrchestrationMutationExecutor,
|
||||
type DurableMutationInvocation
|
||||
} from './orchestration-mutation-executor'
|
||||
import { orchestrationMigrationFence } from './orchestration-contract-fence'
|
||||
import { getRuntimeFeatureInteractionId } from './runtime-feature-interaction'
|
||||
import { recordRuntimeFeatureInteraction } from './runtime-feature-interaction'
|
||||
import { OrchestrationLegacyCompatibility } from './orchestration-legacy-compatibility'
|
||||
import type { RpcDispatchStreamingOptions } from './dispatcher-stream-options'
|
||||
import { invalidArgumentResponse, mapDispatcherError } from './dispatcher-error-response'
|
||||
|
||||
export type DispatcherOptions = {
|
||||
runtime: OrcaRuntimeService
|
||||
methods?: readonly RpcAnyMethod[]
|
||||
}
|
||||
export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] }
|
||||
|
||||
export class RpcDispatcher {
|
||||
private readonly runtime: OrcaRuntimeService
|
||||
private readonly registry: RpcRegistry
|
||||
private readonly orchestrationMutations: OrchestrationMutationExecutor
|
||||
private readonly legacyOrchestration: OrchestrationLegacyCompatibility
|
||||
|
||||
constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) {
|
||||
this.runtime = runtime
|
||||
this.registry = buildRegistry(methods)
|
||||
this.orchestrationMutations = new OrchestrationMutationExecutor(runtime)
|
||||
this.orchestrationMutations = getOrchestrationMutationExecutor(runtime)
|
||||
this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime)
|
||||
}
|
||||
|
||||
async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise<RpcResponse> {
|
||||
@@ -76,9 +63,6 @@ export class RpcDispatcher {
|
||||
return parsedParams.error
|
||||
}
|
||||
|
||||
// Why: streaming methods are not supported over one-shot transports like
|
||||
// Unix sockets. They require a reply function that can be called multiple
|
||||
// times, which is only available via dispatchStreaming.
|
||||
if (isStreamingMethod(method)) {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
@@ -88,29 +72,60 @@ export class RpcDispatcher {
|
||||
)
|
||||
}
|
||||
|
||||
const isEmulator = request.method.startsWith('emulator.')
|
||||
if (isEmulator) {
|
||||
if (request.method.startsWith('emulator.')) {
|
||||
emulatorProbe(`rpc ${request.method}`, request.params)
|
||||
}
|
||||
try {
|
||||
const invoke = (mutation?: DurableMutationInvocation) =>
|
||||
method.handler(parsedParams.value, {
|
||||
const compatibility = await this.legacyOrchestration.tryHandle(
|
||||
request,
|
||||
parsedParams.value,
|
||||
options?.signal
|
||||
)
|
||||
if (compatibility.handled) {
|
||||
return successResponse(request.id, meta, compatibility.result)
|
||||
}
|
||||
const effectiveParams = compatibility.params ?? parsedParams.value
|
||||
const legacyCoordinator = this.legacyOrchestration.createCoordinatorInvocation(
|
||||
request,
|
||||
compatibility.legacyCoordinatorAuthority
|
||||
)
|
||||
const invoke = (mutation?: DurableMutationInvocation) => {
|
||||
const legacyCoordinatorRunId = legacyCoordinator?.revalidate()
|
||||
return method.handler(effectiveParams, {
|
||||
runtime: this.runtime,
|
||||
signal: options?.signal,
|
||||
requestId: request.id,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint: authenticatedCallerFingerprint(request),
|
||||
authenticatedCallerFingerprint:
|
||||
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint(request),
|
||||
recordMutationReceipt: mutation?.recordReceipt,
|
||||
orchestrationMutation: mutation?.identity
|
||||
orchestrationMutation: mutation?.identity,
|
||||
legacyCoordinatorRunId,
|
||||
legacyCoordinatorAuthority: legacyCoordinator?.authority,
|
||||
revalidateLegacyCoordinator: legacyCoordinator?.revalidate,
|
||||
orchestrationCompatibilityCallerAuthority:
|
||||
compatibility.orchestrationCompatibilityCallerAuthority
|
||||
})
|
||||
const result = await this.orchestrationMutations.run(request, parsedParams.value, invoke)
|
||||
this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params)
|
||||
}
|
||||
const result = await this.orchestrationMutations.run(
|
||||
request,
|
||||
effectiveParams,
|
||||
invoke,
|
||||
legacyCoordinator?.mutationCallerFingerprint
|
||||
)
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
request.method,
|
||||
result,
|
||||
undefined,
|
||||
request.params
|
||||
)
|
||||
return successResponse(request.id, meta, result)
|
||||
} catch (error) {
|
||||
if (isEmulator) {
|
||||
if (request.method.startsWith('emulator.')) {
|
||||
emulatorProbeError(`rpc ${request.method}`, error, { params: request.params })
|
||||
}
|
||||
return this.mapError(request, meta, error)
|
||||
return mapDispatcherError(request, meta, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,20 +135,7 @@ export class RpcDispatcher {
|
||||
async dispatchStreaming(
|
||||
request: RpcRequest,
|
||||
reply: (response: string) => void,
|
||||
options?: {
|
||||
connectionId?: string
|
||||
signal?: AbortSignal
|
||||
clientId?: string
|
||||
pairedDeviceId?: string
|
||||
clientKind?: 'mobile' | 'runtime'
|
||||
clientCapabilities?: readonly RuntimeCapability[]
|
||||
pairing?: PairingRpcContext
|
||||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
|
||||
registerBinaryStreamHandler?: (
|
||||
streamId: number,
|
||||
handler: (frame: TerminalStreamFrame) => void
|
||||
) => () => void
|
||||
}
|
||||
options?: RpcDispatchStreamingOptions
|
||||
): Promise<void> {
|
||||
const meta = this.meta()
|
||||
const method = this.registry.get(request.method)
|
||||
@@ -160,8 +162,23 @@ export class RpcDispatcher {
|
||||
|
||||
if (!isStreamingMethod(method)) {
|
||||
try {
|
||||
const invoke = (mutation?: DurableMutationInvocation) =>
|
||||
method.handler(parsedParams.value, {
|
||||
const compatibility = await this.legacyOrchestration.tryHandle(
|
||||
request,
|
||||
parsedParams.value,
|
||||
options?.signal
|
||||
)
|
||||
if (compatibility.handled) {
|
||||
reply(JSON.stringify(successResponse(request.id, meta, compatibility.result)))
|
||||
return
|
||||
}
|
||||
const effectiveParams = compatibility.params ?? parsedParams.value
|
||||
const legacyCoordinator = this.legacyOrchestration.createCoordinatorInvocation(
|
||||
request,
|
||||
compatibility.legacyCoordinatorAuthority
|
||||
)
|
||||
const invoke = (mutation?: DurableMutationInvocation) => {
|
||||
const legacyCoordinatorRunId = legacyCoordinator?.revalidate()
|
||||
return method.handler(effectiveParams, {
|
||||
runtime: this.runtime,
|
||||
signal: options?.signal,
|
||||
requestId: request.id,
|
||||
@@ -171,25 +188,44 @@ export class RpcDispatcher {
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
authenticatedCallerFingerprint: authenticatedCallerFingerprint(request),
|
||||
authenticatedCallerFingerprint:
|
||||
mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint(request),
|
||||
recordMutationReceipt: mutation?.recordReceipt,
|
||||
orchestrationMutation: mutation?.identity,
|
||||
pairing: options?.pairing,
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler,
|
||||
legacyCoordinatorRunId,
|
||||
legacyCoordinatorAuthority: legacyCoordinator?.authority,
|
||||
revalidateLegacyCoordinator: legacyCoordinator?.revalidate,
|
||||
orchestrationCompatibilityCallerAuthority:
|
||||
compatibility.orchestrationCompatibilityCallerAuthority
|
||||
})
|
||||
const result = await this.orchestrationMutations.run(request, parsedParams.value, invoke)
|
||||
this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params)
|
||||
}
|
||||
const result = await this.orchestrationMutations.run(
|
||||
request,
|
||||
effectiveParams,
|
||||
invoke,
|
||||
legacyCoordinator?.mutationCallerFingerprint
|
||||
)
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
request.method,
|
||||
result,
|
||||
undefined,
|
||||
request.params
|
||||
)
|
||||
reply(JSON.stringify(successResponse(request.id, meta, result)))
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
reply(JSON.stringify(mapDispatcherError(request, meta, error)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const recordedStreamingFeatureInteractions = new Set<FeatureInteractionId>()
|
||||
const emit = (result: unknown): void => {
|
||||
this.recordRuntimeFeatureInteraction(
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
request.method,
|
||||
result,
|
||||
recordedStreamingFeatureInteractions,
|
||||
@@ -212,20 +248,22 @@ export class RpcDispatcher {
|
||||
pairedDeviceId: options?.pairedDeviceId,
|
||||
clientKind: options?.clientKind,
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
orchestrationCapability: request.orchestrationCapability,
|
||||
pairing: options?.pairing,
|
||||
sendBinary: options?.sendBinary,
|
||||
registerBinaryStreamHandler: options?.registerBinaryStreamHandler
|
||||
},
|
||||
emit
|
||||
)
|
||||
this.recordRuntimeFeatureInteraction(
|
||||
recordRuntimeFeatureInteraction(
|
||||
this.runtime,
|
||||
request.method,
|
||||
result,
|
||||
recordedStreamingFeatureInteractions,
|
||||
request.params
|
||||
)
|
||||
} catch (error) {
|
||||
reply(JSON.stringify(this.mapError(request, meta, error)))
|
||||
reply(JSON.stringify(mapDispatcherError(request, meta, error)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,69 +279,13 @@ export class RpcDispatcher {
|
||||
const result = method.params.safeParse(rawParams)
|
||||
if (!result.success) {
|
||||
return {
|
||||
error: this.invalidArgumentResponse(request, meta, formatZodError(result.error))
|
||||
error: invalidArgumentResponse(request, meta, formatZodError(result.error))
|
||||
}
|
||||
}
|
||||
return { value: result.data }
|
||||
}
|
||||
|
||||
private mapError(request: RpcRequest, meta: RpcEnvelopeMeta, error: unknown): RpcResponse {
|
||||
if (error instanceof ZodError) {
|
||||
return this.invalidArgumentResponse(request, meta, formatZodError(error))
|
||||
}
|
||||
if (error instanceof InvalidArgumentError) {
|
||||
return this.invalidArgumentResponse(request, meta, error.message)
|
||||
}
|
||||
|
||||
// Why: browser methods throw BrowserError with a structured `code`;
|
||||
// every other runtime error has a plain-message code. Routing by method
|
||||
// prefix keeps the mapping a single decision rather than a per-method
|
||||
// flag callers must remember to set.
|
||||
if (request.method.startsWith('browser.')) {
|
||||
return mapBrowserError(request.id, meta, error)
|
||||
}
|
||||
if (request.method.startsWith('emulator.')) {
|
||||
return mapEmulatorError(request.id, meta, error)
|
||||
}
|
||||
return mapRuntimeError(request.id, meta, error)
|
||||
}
|
||||
|
||||
private invalidArgumentResponse(
|
||||
request: RpcRequest,
|
||||
meta: RpcEnvelopeMeta,
|
||||
message: string
|
||||
): RpcResponse {
|
||||
return errorResponse(
|
||||
request.id,
|
||||
meta,
|
||||
'invalid_argument',
|
||||
message,
|
||||
request.method.startsWith('computer.') ? computerErrorData('invalid_argument') : undefined
|
||||
)
|
||||
}
|
||||
|
||||
private meta(): RpcEnvelopeMeta {
|
||||
return { runtimeId: this.runtime.getRuntimeId() }
|
||||
}
|
||||
|
||||
private recordRuntimeFeatureInteraction(
|
||||
method: string,
|
||||
result: unknown,
|
||||
alreadyRecorded?: Set<FeatureInteractionId>,
|
||||
rawParams?: unknown
|
||||
): void {
|
||||
const id = getRuntimeFeatureInteractionId(method, result, rawParams)
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
if (alreadyRecorded?.has(id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.runtime.recordFeatureInteraction(id)
|
||||
alreadyRecorded?.add(id)
|
||||
} catch {
|
||||
// Best-effort education state must not break runtime tools.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'agent_unconfigured',
|
||||
'terminal_worktree_mismatch',
|
||||
'request_mismatch',
|
||||
'legacy_read_only',
|
||||
'orchestration_migration_required',
|
||||
'operation_unknown',
|
||||
'question_not_found',
|
||||
|
||||
@@ -265,7 +265,7 @@ describe('orchestration federation control mail', () => {
|
||||
})
|
||||
await expect(waiting).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: { count: 0, timedOut: true }
|
||||
result: { count: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ describe('orchestration federation', () => {
|
||||
])
|
||||
)
|
||||
expect(workerDb.listTasks()).toHaveLength(0)
|
||||
expect(vi.mocked(workerRuntime.createManagedWorktree).mock.calls[0]?.[0].activate).toBe(false)
|
||||
expect(workerRuntime.sendTerminalAgentPrompt).toHaveBeenCalledWith(
|
||||
'term_windows_worker',
|
||||
expect.stringContaining(`Your task ID is: ${task.id}`)
|
||||
|
||||
@@ -103,8 +103,12 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.gateCreate',
|
||||
params: GateCreateParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const task = db.getTask(params.task)
|
||||
if (legacyCoordinatorRunId && task?.run_id !== legacyCoordinatorRunId) {
|
||||
throw new Error(`Task not found: ${params.task}`)
|
||||
}
|
||||
let options: string[] | undefined
|
||||
if (params.options) {
|
||||
try {
|
||||
@@ -129,8 +133,12 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.gateResolve',
|
||||
params: GateResolveParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const existing = db.getGate(params.id)
|
||||
if (legacyCoordinatorRunId && existing?.run_id !== legacyCoordinatorRunId) {
|
||||
throw new Error(`Gate not found: ${params.id}`)
|
||||
}
|
||||
const gate = db.resolveGate(params.id, params.resolution)
|
||||
if (!gate) {
|
||||
throw new Error(`Gate not found: ${params.id}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalString, requiredString } from '../schemas'
|
||||
import { OptionalBoolean, OptionalString, requiredString } from '../schemas'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
|
||||
@@ -11,7 +11,8 @@ const RunCreateParams = z.object({
|
||||
|
||||
const RunUseParams = z.object({
|
||||
id: requiredString('Missing --id'),
|
||||
from: requiredString('Missing coordinator terminal')
|
||||
from: requiredString('Missing coordinator terminal'),
|
||||
takeoverLegacy: OptionalBoolean
|
||||
})
|
||||
|
||||
const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') })
|
||||
@@ -51,14 +52,33 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.runUse',
|
||||
params: RunUseParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (
|
||||
params,
|
||||
{
|
||||
runtime,
|
||||
legacyCoordinatorAuthority,
|
||||
orchestrationCompatibilityCallerAuthority: callerAuthority
|
||||
}
|
||||
) => {
|
||||
const paneKey = requireCallerPane(runtime, params.from)
|
||||
if (
|
||||
params.takeoverLegacy &&
|
||||
(callerAuthority?.terminalHandle !== params.from || callerAuthority.paneKey !== paneKey)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'Legacy takeover must be invoked by the live coordinator agent terminal it will bind. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const priorRun = db.getCurrentRunForPane(paneKey)
|
||||
const run = db.bindRun({
|
||||
runId: params.id,
|
||||
coordinatorHandle: params.from,
|
||||
coordinatorPaneKey: paneKey
|
||||
coordinatorPaneKey: paneKey,
|
||||
takeoverLegacy: params.takeoverLegacy,
|
||||
legacyCoordinatorAuthority
|
||||
})
|
||||
if (!run) {
|
||||
throw new OrchestrationError(
|
||||
|
||||
@@ -36,6 +36,21 @@ export type WorkerSetupReceipt = {
|
||||
| 'not_applicable'
|
||||
}
|
||||
|
||||
export function requireWorkerAuthority(runtime: OrcaRuntimeService, terminalHandle: string) {
|
||||
const authority = runtime.getOrchestrationDispatchAuthority(terminalHandle)
|
||||
const paneKey = authority?.paneKey ?? runtime.getTerminalPaneKey(terminalHandle)
|
||||
const processIncarnation =
|
||||
authority?.processIncarnation ?? runtime.getTerminalProcessIncarnation(terminalHandle)
|
||||
if (!paneKey || !processIncarnation) {
|
||||
throw new Error('stable_pane_required')
|
||||
}
|
||||
return {
|
||||
paneKey,
|
||||
processIncarnation,
|
||||
...(authority?.launchTokenHash ? { launchTokenHash: authority.launchTokenHash } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExistingWorktreeWorkerTerminal(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
worktreeId: string
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createExistingWorktreeWorkerTerminal,
|
||||
createWorkerWorktree,
|
||||
monitorWorkerSetup,
|
||||
requireWorkerAuthority,
|
||||
type WorkerEffect,
|
||||
type WorkerSetupReceipt
|
||||
} from './orchestration-worker-topology'
|
||||
@@ -235,16 +236,11 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [
|
||||
: `Agent did not become ready (${wait.status}).`
|
||||
)
|
||||
}
|
||||
const paneKey = runtime.getTerminalPaneKey(terminalHandle)
|
||||
const processIncarnation = runtime.getTerminalProcessIncarnation(terminalHandle)
|
||||
if (!paneKey || !processIncarnation) {
|
||||
throw new Error('stable_pane_required')
|
||||
}
|
||||
const terminalAuthority = requireWorkerAuthority(runtime, terminalHandle)
|
||||
const capability = db.prepareStartingWorkerAuthority({
|
||||
dispatchId: started.dispatch.id,
|
||||
handle: terminalHandle,
|
||||
paneKey,
|
||||
processIncarnation,
|
||||
...terminalAuthority,
|
||||
worktreeId: resolvedWorktree.id,
|
||||
effects,
|
||||
setupState: setupReceipt.state
|
||||
|
||||
@@ -1918,6 +1918,30 @@ describe('orchestration RPC methods', () => {
|
||||
expect(db.getDispatchContextById(result.dispatch.id)?.assignee_pane_key).toBe('tab_w:leaf_w')
|
||||
})
|
||||
|
||||
it('commits the target process launch token on a manual dispatch', async () => {
|
||||
setup()
|
||||
vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({
|
||||
runtimeId: runtime.getRuntimeId(),
|
||||
terminalHandle: 'term_a',
|
||||
ptyId: 'pty_a',
|
||||
worktreeId: 'repo::worktree',
|
||||
paneKey: 'tab_w:leaf_w',
|
||||
processIncarnation: 'runtime_test:term_a:1',
|
||||
launchTokenHash: 'launch-token-hash',
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
})
|
||||
const task = db.createTask({ spec: 'work' })
|
||||
|
||||
const result = (await call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
to: 'term_a'
|
||||
})) as { dispatch: { id: string } }
|
||||
|
||||
expect(db.getDispatchContextById(result.dispatch.id)?.launch_token_hash).toBe(
|
||||
'launch-token-hash'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects dispatch for a pending task', async () => {
|
||||
setup()
|
||||
const parent = db.createTask({ spec: 'parent' })
|
||||
@@ -2162,6 +2186,32 @@ describe('orchestration RPC methods', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('commits the launched worker token with its durable authority', async () => {
|
||||
setup()
|
||||
mockCurrentWorkerStart()
|
||||
vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({
|
||||
runtimeId: runtime.getRuntimeId(),
|
||||
terminalHandle: 'term_worker',
|
||||
ptyId: 'pty_worker',
|
||||
worktreeId: 'repo::worktree',
|
||||
paneKey: 'tab_worker:leaf_worker',
|
||||
processIncarnation: 'runtime_test:term_worker:1',
|
||||
launchTokenHash: 'worker-launch-token-hash',
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
})
|
||||
const task = db.createTask({ spec: 'persist worker identity' })
|
||||
|
||||
const result = (await call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
agent: 'codex'
|
||||
})) as { dispatchId: string }
|
||||
|
||||
expect(db.getDispatchContextById(result.dispatchId)?.launch_token_hash).toBe(
|
||||
'worker-launch-token-hash'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a worker terminal reveal failure without discarding the live worker', async () => {
|
||||
setup()
|
||||
mockCurrentWorkerStart()
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas'
|
||||
import type { MessageType, MessagePriority, TaskStatus } from '../../orchestration/db'
|
||||
import {
|
||||
LEGACY_CONTRACT_VERSION,
|
||||
type MessageType,
|
||||
type MessagePriority,
|
||||
type TaskStatus
|
||||
} from '../../orchestration/db'
|
||||
import { MESSAGE_TYPES } from '../../orchestration/types'
|
||||
import { buildDispatchPreamble } from '../../orchestration/preamble'
|
||||
import { formatMessageBanner } from '../../orchestration/formatter'
|
||||
@@ -51,6 +56,10 @@ function parseRemoteWorkerPayload(payload: string | undefined): Record<string, u
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' {
|
||||
return value === 'succeeded' || value === 'failed'
|
||||
}
|
||||
|
||||
const SendParams = z
|
||||
.object({
|
||||
to: OptionalString,
|
||||
@@ -107,6 +116,9 @@ const CheckParams = z
|
||||
// Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected.
|
||||
inject: OptionalBoolean,
|
||||
ack: OptionalString,
|
||||
compatibilityAck: OptionalString,
|
||||
compatibilityQuestionAck: OptionalString,
|
||||
compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(),
|
||||
run: OptionalString,
|
||||
wait: OptionalBoolean,
|
||||
timeoutMs: OptionalFiniteNumber
|
||||
@@ -205,7 +217,9 @@ const AskParams = z
|
||||
options: OptionalString,
|
||||
timeoutMs: OptionalFiniteNumber,
|
||||
from: OptionalString,
|
||||
run: OptionalString
|
||||
run: OptionalString,
|
||||
compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(),
|
||||
compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional()
|
||||
})
|
||||
.superRefine((params, ctx) => {
|
||||
if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) {
|
||||
@@ -241,6 +255,7 @@ function resolveRunScope(
|
||||
callerTerminalHandle?: string
|
||||
callerPaneKey?: string
|
||||
requireCurrentConsumer: boolean
|
||||
legacyCoordinatorRunId?: string
|
||||
}
|
||||
): RunRow {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
@@ -252,6 +267,9 @@ function resolveRunScope(
|
||||
if (!params.requireCurrentConsumer && explicit) {
|
||||
return explicit
|
||||
}
|
||||
if (explicit && params.legacyCoordinatorRunId === explicit.id) {
|
||||
return explicit
|
||||
}
|
||||
if (!params.callerTerminalHandle) {
|
||||
throw new OrchestrationError(
|
||||
'run_required',
|
||||
@@ -366,6 +384,49 @@ function resolveMessageRun(
|
||||
return { run, dispatchId: dispatch?.id ?? dispatchId }
|
||||
}
|
||||
|
||||
function legacyWorkerDeliveryContract(
|
||||
runtime: OrcaRuntimeService,
|
||||
runId: string | undefined,
|
||||
recipient: string
|
||||
): 'legacy_direct' | undefined {
|
||||
if (!runId) {
|
||||
return undefined
|
||||
}
|
||||
if (!recipient.startsWith('dispatch:')) {
|
||||
return runtime
|
||||
.getOrchestrationDb()
|
||||
.resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient })
|
||||
? 'legacy_direct'
|
||||
: undefined
|
||||
}
|
||||
const dispatch = runtime
|
||||
.getOrchestrationDb()
|
||||
.getDispatchContextById(recipient.slice('dispatch:'.length))
|
||||
return dispatch?.run_id === runId &&
|
||||
dispatch.contract_version === LEGACY_CONTRACT_VERSION &&
|
||||
(dispatch.status === 'pending' || dispatch.status === 'dispatched')
|
||||
? 'legacy_direct'
|
||||
: undefined
|
||||
}
|
||||
|
||||
function interruptedAcknowledgedCheck(
|
||||
runId: string,
|
||||
acknowledged: string,
|
||||
reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists'
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
runId,
|
||||
deliveryId: null,
|
||||
messages: [],
|
||||
count: 0,
|
||||
acknowledged,
|
||||
timedOut: false,
|
||||
cancelled: false,
|
||||
connectionLost: false,
|
||||
waitInterrupted: reason
|
||||
}
|
||||
}
|
||||
|
||||
function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void {
|
||||
if (params.to || params.run) {
|
||||
throw new OrchestrationError(
|
||||
@@ -382,7 +443,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.send',
|
||||
params: SendParams,
|
||||
handler: async (params, { runtime, orchestrationCapability }) => {
|
||||
handler: async (
|
||||
params,
|
||||
{ runtime, orchestrationCapability, legacyCoordinatorRunId, revalidateLegacyCoordinator }
|
||||
) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const from = params.from ?? 'unknown'
|
||||
// Why: caller-supplied pane fields are only compatibility metadata; lifecycle authority uses the runtime-observed pane plus capability.
|
||||
@@ -463,6 +527,15 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
runId: params.run,
|
||||
payload: params.payload
|
||||
})
|
||||
if (
|
||||
params.type === 'worker_done' &&
|
||||
!isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'worker_done requires outcome=succeeded|failed for a current Dispatch.'
|
||||
)
|
||||
}
|
||||
if (params.to?.startsWith('task:')) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
@@ -514,6 +587,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
'Coordinator-to-worker control mail cannot report worker lifecycle.'
|
||||
)
|
||||
}
|
||||
revalidateLegacyCoordinator?.()
|
||||
const relay = db.enqueueFederationRelay({
|
||||
dispatchId,
|
||||
direction: 'to_worker',
|
||||
@@ -540,6 +614,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
}
|
||||
}
|
||||
// Point-to-point — existing single-recipient behavior
|
||||
revalidateLegacyCoordinator?.()
|
||||
const msg = db.insertMessage({
|
||||
from,
|
||||
to,
|
||||
@@ -550,7 +625,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
threadId: params.threadId,
|
||||
payload: params.payload,
|
||||
senderPaneKey,
|
||||
runId: routing.run?.id
|
||||
runId: routing.run?.id,
|
||||
deliveryContract: legacyWorkerDeliveryContract(
|
||||
runtime,
|
||||
routing.run?.id ?? legacyCoordinatorRunId,
|
||||
to
|
||||
)
|
||||
})
|
||||
const dispatch = routing.dispatchId
|
||||
? db.getDispatchContextById(routing.dispatchId)
|
||||
@@ -607,6 +687,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
throw new Error(`No recipients resolved for group address: ${to}`)
|
||||
}
|
||||
|
||||
revalidateLegacyCoordinator?.()
|
||||
const threadId = params.threadId ?? `thread_${Date.now()}`
|
||||
const messages = handles.map((handle) =>
|
||||
db.insertMessage({
|
||||
@@ -619,7 +700,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
threadId,
|
||||
payload: params.payload,
|
||||
senderPaneKey,
|
||||
runId: routing.run?.id
|
||||
runId: routing.run?.id,
|
||||
deliveryContract: legacyWorkerDeliveryContract(
|
||||
runtime,
|
||||
routing.run?.id ?? legacyCoordinatorRunId,
|
||||
handle
|
||||
)
|
||||
})
|
||||
)
|
||||
for (const message of messages) {
|
||||
@@ -633,7 +719,16 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.check',
|
||||
params: CheckParams,
|
||||
handler: async (params, { runtime, signal }) => {
|
||||
handler: async (
|
||||
params,
|
||||
{
|
||||
runtime,
|
||||
signal,
|
||||
legacyCoordinatorRunId,
|
||||
revalidateLegacyCoordinator,
|
||||
recordMutationReceipt
|
||||
}
|
||||
) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const handle = params.terminal ?? 'unknown'
|
||||
const typeFilter = parseMessageTypes(params.types)
|
||||
@@ -646,7 +741,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
runId: params.run,
|
||||
callerTerminalHandle: handle,
|
||||
callerPaneKey: paneKey ?? undefined,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
const generation = run.consumer_generation
|
||||
const address = `run:${run.id}`
|
||||
@@ -659,6 +755,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
deliveryId: params.ack
|
||||
})
|
||||
: undefined
|
||||
if (acknowledged) {
|
||||
recordMutationReceipt?.(
|
||||
interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown')
|
||||
)
|
||||
}
|
||||
if (params.peek || params.all || params.unread === false) {
|
||||
const history = db.getRunMailboxHistory(run.id, 100, typeFilter)
|
||||
const messages =
|
||||
@@ -722,14 +823,28 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
signal,
|
||||
exclusive: true
|
||||
})
|
||||
try {
|
||||
revalidateLegacyCoordinator?.()
|
||||
} catch (error) {
|
||||
if (!acknowledged) {
|
||||
throw error
|
||||
}
|
||||
return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced')
|
||||
}
|
||||
const latestRun = db.getRun(run.id)
|
||||
if (!latestRun || latestRun.consumer_generation !== generation) {
|
||||
if (acknowledged) {
|
||||
return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced')
|
||||
}
|
||||
throw new OrchestrationError(
|
||||
'consumer_fenced',
|
||||
'This mailbox consumer was replaced while waiting.'
|
||||
)
|
||||
}
|
||||
if (waitResult === 'waiter_exists') {
|
||||
if (acknowledged) {
|
||||
return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists')
|
||||
}
|
||||
throw new OrchestrationError(
|
||||
'waiter_exists',
|
||||
`Run ${run.id} already has an active actionable waiter.`
|
||||
@@ -911,13 +1026,28 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.reply',
|
||||
params: ReplyParams,
|
||||
handler: async (params, { runtime }) => {
|
||||
handler: async (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const original = db.getMessageById(params.id)
|
||||
if (!original) {
|
||||
throw new Error(`Message not found: ${params.id}`)
|
||||
}
|
||||
if (original.run_id === ORCHESTRATION_LEGACY_RUN_ID) {
|
||||
if (
|
||||
legacyCoordinatorRunId &&
|
||||
(original.run_id !== legacyCoordinatorRunId ||
|
||||
(params.run !== undefined && params.run !== legacyCoordinatorRunId))
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Message ${params.id} does not belong to this adopted Run.`,
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
if (
|
||||
original.run_id === ORCHESTRATION_LEGACY_RUN_ID ||
|
||||
original.delivery_contract === 'legacy_direct' ||
|
||||
original.delivery_contract === 'audit_only'
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'Legacy orchestration messages are inspect-only; no reply was applied.',
|
||||
@@ -930,7 +1060,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
const run = resolveRunScope(runtime, {
|
||||
runId: params.run ?? question.run_id,
|
||||
callerTerminalHandle: params.from,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
const answered = db.answerQuestion({
|
||||
messageId: question.message_id,
|
||||
@@ -993,7 +1124,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.taskCreate',
|
||||
params: TaskCreateParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
let deps: string[] | undefined
|
||||
if (params.deps) {
|
||||
@@ -1017,7 +1148,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
runId: resolveRunScope(runtime, {
|
||||
runId: params.run,
|
||||
callerTerminalHandle: params.callerTerminalHandle,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
}).id
|
||||
})
|
||||
return { task }
|
||||
@@ -1027,7 +1159,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.taskList',
|
||||
params: TaskListParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const explicitRun = params.run ? db.getRun(params.run) : undefined
|
||||
const run =
|
||||
@@ -1036,7 +1168,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
: resolveRunScope(runtime, {
|
||||
runId: params.run,
|
||||
callerTerminalHandle: params.callerTerminalHandle,
|
||||
requireCurrentConsumer: params.run === undefined
|
||||
requireCurrentConsumer: params.run === undefined,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
// Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected.
|
||||
const joined = db.listTasksWithDispatch({
|
||||
@@ -1063,12 +1196,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.taskUpdate',
|
||||
params: TaskUpdateParams,
|
||||
handler: (params, { runtime }) => {
|
||||
handler: (params, { runtime, legacyCoordinatorRunId }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const run = resolveRunScope(runtime, {
|
||||
runId: params.run,
|
||||
callerTerminalHandle: params.callerTerminalHandle,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
const existing = db.getTask(params.id)
|
||||
if (!existing || existing.run_id !== run.id) {
|
||||
@@ -1088,7 +1222,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'orchestration.dispatch',
|
||||
params: DispatchParams,
|
||||
handler: async (params, { runtime }) => {
|
||||
handler: async (params, { runtime, legacyCoordinatorRunId, revalidateLegacyCoordinator }) => {
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const task = db.getTask(params.task)
|
||||
if (!task) {
|
||||
@@ -1097,7 +1231,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
const run = resolveRunScope(runtime, {
|
||||
runId: params.run,
|
||||
callerTerminalHandle: params.from,
|
||||
requireCurrentConsumer: true
|
||||
requireCurrentConsumer: true,
|
||||
legacyCoordinatorRunId
|
||||
})
|
||||
if (task.run_id !== run.id) {
|
||||
throw new OrchestrationError(
|
||||
@@ -1143,8 +1278,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
}
|
||||
}
|
||||
|
||||
const assigneePaneKey = runtime.getTerminalPaneKey(to) ?? undefined
|
||||
const processIncarnation = runtime.getTerminalProcessIncarnation(to) ?? undefined
|
||||
const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to)
|
||||
const assigneePaneKey =
|
||||
dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined
|
||||
const processIncarnation =
|
||||
dispatchAuthority?.processIncarnation ??
|
||||
runtime.getTerminalProcessIncarnation(to) ??
|
||||
undefined
|
||||
if (params.inject && (!assigneePaneKey || !processIncarnation)) {
|
||||
throw new OrchestrationError(
|
||||
'stable_pane_required',
|
||||
@@ -1152,7 +1292,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
)
|
||||
}
|
||||
|
||||
const ctx = db.createDispatchContext(params.task, to, assigneePaneKey)
|
||||
revalidateLegacyCoordinator?.()
|
||||
const ctx = db.createDispatchContext(
|
||||
params.task,
|
||||
to,
|
||||
assigneePaneKey,
|
||||
dispatchAuthority?.launchTokenHash ?? undefined
|
||||
)
|
||||
const dispatchCapability = params.inject
|
||||
? db.mintDispatchCapability({
|
||||
dispatchId: ctx.id,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import type {
|
||||
DispatchContextRow,
|
||||
LegacyCompatibilityPrincipalRow,
|
||||
LegacyPrincipalRole
|
||||
} from '../orchestration/types'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../orchestration/db'
|
||||
import type { RpcRequest } from './core'
|
||||
import {
|
||||
equivalentLegacyPaneKey,
|
||||
legacyCoordinatorReadOnly,
|
||||
legacyReadOnlyError
|
||||
} from './orchestration-legacy-process-identity'
|
||||
|
||||
export type LegacyPrincipalCandidate = {
|
||||
runId: string
|
||||
role: LegacyPrincipalRole
|
||||
dispatchId?: string
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
}
|
||||
|
||||
export function resolveAttestedLegacyPrincipal(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
evidence?: OrchestrationCompatibilityEvidence
|
||||
candidate: LegacyPrincipalCandidate
|
||||
authority?: OrchestrationCompatibilityCallerAuthority
|
||||
}): LegacyCompatibilityPrincipalRow {
|
||||
const authority = args.authority ?? verifyAttestedLegacyCandidate(args)
|
||||
return args.runtime.getOrchestrationDb().commitLegacyCompatibilityPrincipal({
|
||||
runId: args.candidate.runId,
|
||||
dispatchId: args.candidate.dispatchId,
|
||||
role: args.candidate.role,
|
||||
hostScope: JSON.stringify(authority.hostScope),
|
||||
terminalHandle: args.candidate.terminalHandle,
|
||||
paneKey: args.candidate.paneKey,
|
||||
launchTokenHash: authority.launchTokenHash,
|
||||
processIncarnation: authority.processIncarnation
|
||||
}).principal
|
||||
}
|
||||
|
||||
export function verifyAttestedLegacyCandidate(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
evidence?: OrchestrationCompatibilityEvidence
|
||||
candidate: LegacyPrincipalCandidate
|
||||
}) {
|
||||
const authority = args.runtime.verifyOrchestrationCompatibilityCaller(args.evidence)
|
||||
if (
|
||||
!authority ||
|
||||
!equivalentLegacyPaneKey(args.candidate.paneKey, authority.paneKey) ||
|
||||
args.candidate.terminalHandle !== authority.terminalHandle
|
||||
) {
|
||||
throw legacyReadOnlyError()
|
||||
}
|
||||
if (
|
||||
args.candidate.role === 'worker' &&
|
||||
args.candidate.dispatchId &&
|
||||
!args.runtime.getOrchestrationDb().isDispatchProcessCurrent({
|
||||
dispatchId: args.candidate.dispatchId,
|
||||
paneKey: authority.paneKey,
|
||||
processIncarnation: authority.processIncarnation
|
||||
})
|
||||
) {
|
||||
throw legacyReadOnlyError()
|
||||
}
|
||||
return authority
|
||||
}
|
||||
|
||||
export class LegacyCompatibilityAuthority {
|
||||
constructor(private readonly runtime: OrcaRuntimeService) {}
|
||||
|
||||
resolveWorkerDispatch(
|
||||
request: RpcRequest,
|
||||
target: { terminalHandle?: string; dispatchId?: string; taskId?: string }
|
||||
): DispatchContextRow | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const evidence = request.orchestrationCompatibilityEvidence
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id
|
||||
const existing = adoptedRunId
|
||||
? db.resolveLegacyCompatibilityPrincipalByIdentity({
|
||||
runId: adoptedRunId,
|
||||
role: 'worker',
|
||||
terminalHandle: evidence?.terminalHandle,
|
||||
paneKey: evidence?.paneKey
|
||||
})
|
||||
: undefined
|
||||
if (existing?.dispatch_id) {
|
||||
verifyAttestedLegacyCandidate({
|
||||
runtime: this.runtime,
|
||||
evidence,
|
||||
candidate: candidateFromPrincipal(existing)
|
||||
})
|
||||
const settledDispatch = db.getDispatchContextById(existing.dispatch_id)
|
||||
if (
|
||||
settledDispatch &&
|
||||
(!target.dispatchId || target.dispatchId === settledDispatch.id) &&
|
||||
(!target.taskId || target.taskId === settledDispatch.task_id)
|
||||
) {
|
||||
return settledDispatch
|
||||
}
|
||||
}
|
||||
const dispatch = db.resolveLegacyWorkerCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: evidence?.terminalHandle ?? target.terminalHandle,
|
||||
paneKey: evidence?.paneKey,
|
||||
dispatchId: target.dispatchId,
|
||||
taskId: target.taskId
|
||||
})?.dispatch
|
||||
if (!dispatch && evidence) {
|
||||
const retained = db.resolveLegacyWorkerCandidate({
|
||||
runId: adoptedRunId,
|
||||
terminalHandle: target.terminalHandle,
|
||||
dispatchId: target.dispatchId,
|
||||
taskId: target.taskId
|
||||
})
|
||||
if (retained) {
|
||||
throw legacyReadOnlyError()
|
||||
}
|
||||
}
|
||||
return dispatch
|
||||
}
|
||||
|
||||
resolveAskDispatch(
|
||||
request: RpcRequest,
|
||||
params: { from?: string; resume?: string }
|
||||
): DispatchContextRow | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
if (params.resume) {
|
||||
const question = db.getQuestion(params.resume)
|
||||
return question ? db.getDispatchContextById(question.dispatch_id) : undefined
|
||||
}
|
||||
return this.resolveWorkerDispatch(request, { terminalHandle: params.from })
|
||||
}
|
||||
|
||||
resolveCheckPrincipal(
|
||||
request: RpcRequest,
|
||||
terminalHandle?: string
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const adoption = db.getLegacyAdoption()
|
||||
if (!adoption) {
|
||||
return undefined
|
||||
}
|
||||
const evidence = request.orchestrationCompatibilityEvidence
|
||||
const existingWorker = db.resolveLegacyCompatibilityPrincipalByIdentity({
|
||||
runId: adoption.adopted_run_id,
|
||||
role: 'worker',
|
||||
terminalHandle: evidence?.terminalHandle,
|
||||
paneKey: evidence?.paneKey
|
||||
})
|
||||
if (existingWorker) {
|
||||
verifyAttestedLegacyCandidate({
|
||||
runtime: this.runtime,
|
||||
evidence,
|
||||
candidate: candidateFromPrincipal(existingWorker)
|
||||
})
|
||||
return existingWorker
|
||||
}
|
||||
const worker = db.resolveLegacyWorkerCandidate({
|
||||
runId: adoption.adopted_run_id,
|
||||
terminalHandle: evidence?.terminalHandle ?? terminalHandle,
|
||||
paneKey: evidence?.paneKey
|
||||
})
|
||||
if (worker) {
|
||||
return this.attestWorker(request, worker.dispatch)
|
||||
}
|
||||
if (evidence) {
|
||||
const retained = db.resolveLegacyWorkerCandidate({
|
||||
runId: adoption.adopted_run_id,
|
||||
terminalHandle
|
||||
})
|
||||
const settled = db.resolveLegacyCompatibilityPrincipalByIdentity({
|
||||
runId: adoption.adopted_run_id,
|
||||
role: 'worker',
|
||||
terminalHandle
|
||||
})
|
||||
if (retained || settled) {
|
||||
throw legacyReadOnlyError()
|
||||
}
|
||||
}
|
||||
return this.attestCoordinator(request, adoption.adopted_run_id, false)
|
||||
}
|
||||
|
||||
attestWorker(request: RpcRequest, dispatch: DispatchContextRow): LegacyCompatibilityPrincipalRow {
|
||||
if (dispatch.contract_version !== LEGACY_CONTRACT_VERSION) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${dispatch.id} does not use the legacy contract.`
|
||||
)
|
||||
}
|
||||
return resolveAttestedLegacyPrincipal({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate: candidateFromDispatch(dispatch)
|
||||
})
|
||||
}
|
||||
|
||||
attestCoordinator(
|
||||
request: RpcRequest,
|
||||
runId: string,
|
||||
required = true
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const candidate = db.resolveLegacyCoordinatorCandidate({
|
||||
runId,
|
||||
terminalHandle: request.orchestrationCompatibilityEvidence?.terminalHandle,
|
||||
paneKey: request.orchestrationCompatibilityEvidence?.paneKey
|
||||
})
|
||||
if (!candidate) {
|
||||
if (required) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const principal = resolveAttestedLegacyPrincipal({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate: {
|
||||
runId,
|
||||
role: 'coordinator',
|
||||
terminalHandle: candidate.terminalHandle,
|
||||
paneKey: candidate.paneKey
|
||||
}
|
||||
})
|
||||
return principal
|
||||
}
|
||||
}
|
||||
|
||||
function candidateFromDispatch(dispatch: DispatchContextRow): LegacyPrincipalCandidate {
|
||||
if (!dispatch.assignee_handle || !dispatch.assignee_pane_key) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
`Dispatch ${dispatch.id} lacks durable process identity. No effects were applied.`
|
||||
)
|
||||
}
|
||||
return {
|
||||
runId: dispatch.run_id,
|
||||
role: 'worker',
|
||||
dispatchId: dispatch.id,
|
||||
terminalHandle: dispatch.assignee_handle,
|
||||
paneKey: dispatch.assignee_pane_key
|
||||
}
|
||||
}
|
||||
|
||||
function candidateFromPrincipal(
|
||||
principal: LegacyCompatibilityPrincipalRow
|
||||
): LegacyPrincipalCandidate {
|
||||
return {
|
||||
runId: principal.run_id,
|
||||
role: principal.role,
|
||||
dispatchId: principal.dispatch_id ?? undefined,
|
||||
terminalHandle: principal.terminal_handle,
|
||||
paneKey: principal.pane_key
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, vi } from 'vitest'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationDb } from '../orchestration/db'
|
||||
import type { RpcRequest, RpcResponse } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './methods/orchestration'
|
||||
|
||||
export const WORKER_HANDLE = 'term_legacy_worker'
|
||||
export const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
export const COORDINATOR_HANDLE = 'term_legacy_coord'
|
||||
export const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
|
||||
type Transport = 'dispatch' | 'websocket'
|
||||
|
||||
export type LegacyCompatibilityDispatcherHarness = {
|
||||
db: OrchestrationDb
|
||||
dispatcher: RpcDispatcher
|
||||
runtime: OrcaRuntimeService
|
||||
adoptedRunId: string
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
notify: ReturnType<typeof vi.spyOn>
|
||||
verify: ReturnType<typeof vi.spyOn>
|
||||
}
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
export function cleanupLegacyCompatibilityDispatcherHarnesses(): void {
|
||||
for (const database of databases.splice(0)) {
|
||||
database.close()
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function createHarness(): LegacyCompatibilityDispatcherHarness {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-dispatcher-'))
|
||||
tempDirs.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const before = new OrchestrationDb(dbPath)
|
||||
const task = before.createTask({
|
||||
spec: 'legacy assignment',
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
before.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
raw.exec(`
|
||||
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
databases.push(db)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : null
|
||||
)
|
||||
const verify = vi
|
||||
.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller')
|
||||
.mockImplementation((evidence) => {
|
||||
const validWorker =
|
||||
evidence?.terminalHandle === WORKER_HANDLE && evidence.paneKey === WORKER_PANE
|
||||
const validCoordinator =
|
||||
evidence?.terminalHandle === COORDINATOR_HANDLE && evidence.paneKey === COORDINATOR_PANE
|
||||
if ((!validWorker && !validCoordinator) || !evidence?.launchToken) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
terminalHandle: evidence.terminalHandle as string,
|
||||
paneKey: evidence.paneKey as string,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256').update(evidence.launchToken).digest('hex')
|
||||
}
|
||||
})
|
||||
const notify = vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
return {
|
||||
db,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
runtime,
|
||||
adoptedRunId,
|
||||
taskId: task.id,
|
||||
dispatchId: dispatch.id,
|
||||
notify,
|
||||
verify
|
||||
}
|
||||
}
|
||||
|
||||
export function evidence(
|
||||
role: 'worker' | 'coordinator',
|
||||
valid = true
|
||||
): OrchestrationCompatibilityEvidence {
|
||||
const worker = role === 'worker'
|
||||
return {
|
||||
terminalHandle: worker ? WORKER_HANDLE : COORDINATOR_HANDLE,
|
||||
paneKey: valid ? (worker ? WORKER_PANE : COORDINATOR_PANE) : 'tab_wrong:wrong-leaf',
|
||||
launchToken: `${role}-token`
|
||||
}
|
||||
}
|
||||
|
||||
export function request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
proof: OrchestrationCompatibilityEvidence,
|
||||
invocationId: string
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: `rpc_${invocationId}`,
|
||||
authToken: 'caller-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: invocationId,
|
||||
compatibilityInvocationId: invocationId,
|
||||
orchestrationCompatibilityEvidence: proof
|
||||
}
|
||||
}
|
||||
|
||||
export async function invoke(
|
||||
dispatcher: RpcDispatcher,
|
||||
rpcRequest: RpcRequest,
|
||||
transport: Transport
|
||||
): Promise<RpcResponse> {
|
||||
if (transport === 'dispatch') {
|
||||
return await dispatcher.dispatch(rpcRequest)
|
||||
}
|
||||
const replies: string[] = []
|
||||
await dispatcher.dispatchStreaming(rpcRequest, (reply) => replies.push(reply))
|
||||
expect(replies).toHaveLength(1)
|
||||
return JSON.parse(replies[0]) as RpcResponse
|
||||
}
|
||||
|
||||
export function counts(db: OrchestrationDb): Record<string, number> {
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
return Object.fromEntries(
|
||||
[
|
||||
'messages',
|
||||
'legacy_compatibility_principals',
|
||||
'legacy_operation_receipts',
|
||||
'legacy_mail_receipts',
|
||||
'mutation_receipts'
|
||||
].map((table) => [
|
||||
table,
|
||||
(sqlite.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function escalationParams(harness: LegacyCompatibilityDispatcherHarness) {
|
||||
return {
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Blocked',
|
||||
type: 'escalation',
|
||||
payload: JSON.stringify({ taskId: harness.taskId, dispatchId: harness.dispatchId })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import {
|
||||
cleanupLegacyCompatibilityDispatcherHarnesses,
|
||||
COORDINATOR_HANDLE,
|
||||
COORDINATOR_PANE,
|
||||
counts,
|
||||
createHarness,
|
||||
escalationParams,
|
||||
evidence,
|
||||
invoke,
|
||||
request,
|
||||
WORKER_HANDLE,
|
||||
WORKER_PANE
|
||||
} from './orchestration-legacy-compatibility-dispatcher-test-fixture'
|
||||
|
||||
afterEach(() => {
|
||||
cleanupLegacyCompatibilityDispatcherHarnesses()
|
||||
})
|
||||
|
||||
describe('legacy compatibility through RpcDispatcher', () => {
|
||||
it('rejects malformed current-contract input before compatibility attestation', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: WORKER_HANDLE, type: 'escalation' },
|
||||
evidence('worker'),
|
||||
'malformed'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['dispatch', true],
|
||||
['dispatch', false],
|
||||
['websocket', true],
|
||||
['websocket', false]
|
||||
] as const)(
|
||||
'%s routes task-only escalation with valid proof=%s and zero partial effects',
|
||||
async (transport, valid) => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.send',
|
||||
escalationParams(harness),
|
||||
evidence('worker', valid),
|
||||
`${transport}-${valid}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
if (!valid) {
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'legacy_read_only' }
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: { type: 'escalation', delivery_contract: 'legacy_direct' },
|
||||
legacyCompatibility: { replayed: false }
|
||||
}
|
||||
})
|
||||
expect(counts(harness.db)).toEqual({
|
||||
...before,
|
||||
messages: before.messages + 1,
|
||||
legacy_compatibility_principals: before.legacy_compatibility_principals + 1,
|
||||
legacy_operation_receipts: before.legacy_operation_receipts + 1
|
||||
})
|
||||
expect(harness.notify).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('validates, infers, settles, and replays legacy worker completion exactly once', async () => {
|
||||
const harness = createHarness()
|
||||
const baseParams = {
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
type: 'worker_done',
|
||||
body: 'legacy result',
|
||||
payload: JSON.stringify({ taskId: harness.taskId, dispatchId: harness.dispatchId })
|
||||
}
|
||||
const before = counts(harness.db)
|
||||
const invalid = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ ...baseParams, subject: 'Completed', payload: JSON.stringify({ outcome: 'maybe' }) },
|
||||
evidence('worker'),
|
||||
'invalid-outcome'
|
||||
)
|
||||
)
|
||||
|
||||
expect(invalid).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
|
||||
const firstRequest = request(
|
||||
'orchestration.send',
|
||||
{ ...baseParams, subject: 'Completed' },
|
||||
evidence('worker'),
|
||||
'completion'
|
||||
)
|
||||
const first = await harness.dispatcher.dispatch(firstRequest)
|
||||
const replay = await harness.dispatcher.dispatch({ ...firstRequest, id: 'rpc_replay' })
|
||||
const mismatch = await harness.dispatcher.dispatch({
|
||||
...firstRequest,
|
||||
id: 'rpc_mismatch',
|
||||
params: { ...baseParams, subject: 'Changed completion' }
|
||||
})
|
||||
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
lifecycle: { action: 'settled', outcome: 'succeeded' },
|
||||
legacyCompatibility: { replayed: false }
|
||||
}
|
||||
})
|
||||
expect(replay).toMatchObject({
|
||||
ok: true,
|
||||
result: { legacyCompatibility: { replayed: true } }
|
||||
})
|
||||
expect(mismatch).toMatchObject({ ok: false, error: { code: 'request_mismatch' } })
|
||||
expect(harness.db.getTask(harness.taskId)?.status).toBe('completed')
|
||||
expect(harness.db.getDispatchContextById(harness.dispatchId)?.status).toBe('completed')
|
||||
expect(counts(harness.db)).toEqual({
|
||||
...before,
|
||||
messages: before.messages + 1,
|
||||
legacy_compatibility_principals: before.legacy_compatibility_principals + 1,
|
||||
legacy_operation_receipts: before.legacy_operation_receipts + 1
|
||||
})
|
||||
expect(harness.notify).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('replays an A-era completion without touching a newer current attempt', async () => {
|
||||
const harness = createHarness()
|
||||
const payload = JSON.stringify({
|
||||
taskId: harness.taskId,
|
||||
dispatchId: harness.dispatchId
|
||||
})
|
||||
const completion = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Failed: pinned A treated this as complete',
|
||||
body: 'persisted result',
|
||||
type: 'worker_done',
|
||||
payload,
|
||||
senderPaneKey: WORKER_PANE,
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
harness.db.settleWorkerReport({
|
||||
taskId: harness.taskId,
|
||||
dispatchId: harness.dispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'persisted result'
|
||||
})
|
||||
harness.db.commitLegacyCompatibilityPrincipal({
|
||||
runId: harness.adoptedRunId,
|
||||
dispatchId: harness.dispatchId,
|
||||
role: 'worker',
|
||||
hostScope: JSON.stringify({ kind: 'local', hostId: 'local' }),
|
||||
terminalHandle: WORKER_HANDLE,
|
||||
paneKey: WORKER_PANE,
|
||||
launchTokenHash: createHash('sha256').update('worker-token').digest('hex'),
|
||||
processIncarnation: 'process-1'
|
||||
})
|
||||
harness.db.updateTaskStatus(harness.taskId, 'ready')
|
||||
const currentDispatch = harness.db.createDispatchContext(
|
||||
harness.taskId,
|
||||
'term_current_worker',
|
||||
'tab_current_worker:77777777-7777-4777-8777-777777777777',
|
||||
'current-launch-hash'
|
||||
)
|
||||
const currentTaskBefore = harness.db.getTask(harness.taskId)
|
||||
const currentDispatchBefore = harness.db.getDispatchContextById(currentDispatch.id)
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: completion.subject,
|
||||
body: completion.body,
|
||||
type: 'worker_done',
|
||||
payload
|
||||
},
|
||||
evidence('worker'),
|
||||
'reconstruct-pinned-a-completion'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: { id: completion.id },
|
||||
lifecycle: { action: 'settled', outcome: 'succeeded', duplicate: true }
|
||||
}
|
||||
})
|
||||
expect(harness.db.getTask(harness.taskId)).toEqual(currentTaskBefore)
|
||||
expect(harness.db.getDispatchContextById(harness.dispatchId)?.status).toBe('completed')
|
||||
expect(harness.db.getDispatchContextById(currentDispatch.id)).toEqual(currentDispatchBefore)
|
||||
expect(counts(harness.db)).toEqual({
|
||||
...before,
|
||||
legacy_operation_receipts: before.legacy_operation_receipts + 1
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a legacy lifecycle recipient outside the adopted Run with zero effects', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
...escalationParams(harness),
|
||||
to: 'term_unrelated'
|
||||
},
|
||||
evidence('worker'),
|
||||
'wrong-recipient'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'request_mismatch' }
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a reused pane whose live process incarnation is not the legacy worker', async () => {
|
||||
const harness = createHarness()
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare('UPDATE dispatch_contexts SET process_incarnation = ? WHERE id = ?')
|
||||
.run('different-process', harness.dispatchId)
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request('orchestration.send', escalationParams(harness), evidence('worker'), 'reused-pane')
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a legacy question recipient outside the adopted Run with zero effects', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: 'term_unrelated',
|
||||
question: 'Proceed?',
|
||||
timeoutMs: 1
|
||||
},
|
||||
evidence('worker'),
|
||||
'wrong-question-recipient'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'request_mismatch' }
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps distinct compatibility ask invocations on distinct questions', async () => {
|
||||
const harness = createHarness()
|
||||
const ask = {
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Same question?',
|
||||
options: ['yes', 'no'],
|
||||
timeoutMs: 1
|
||||
}
|
||||
const first = await harness.dispatcher.dispatch(
|
||||
request('orchestration.ask', ask, evidence('worker'), 'ask-one')
|
||||
)
|
||||
const replay = await harness.dispatcher.dispatch(
|
||||
request('orchestration.ask', ask, evidence('worker'), 'ask-one')
|
||||
)
|
||||
const second = await harness.dispatcher.dispatch(
|
||||
request('orchestration.ask', ask, evidence('worker'), 'ask-two')
|
||||
)
|
||||
|
||||
expect(first).toMatchObject({ ok: true, result: { legacyCompatibility: { replayed: false } } })
|
||||
expect(replay).toMatchObject({ ok: true, result: { legacyCompatibility: { replayed: true } } })
|
||||
expect(second).toMatchObject({ ok: true, result: { legacyCompatibility: { replayed: false } } })
|
||||
const firstId = (first as { result: { messageId: string } }).result.messageId
|
||||
const replayId = (replay as { result: { messageId: string } }).result.messageId
|
||||
const secondId = (second as { result: { messageId: string } }).result.messageId
|
||||
expect(replayId).toBe(firstId)
|
||||
expect(secondId).not.toBe(firstId)
|
||||
expect(harness.db.getMessageById(firstId)?.type).toBe('decision_gate')
|
||||
|
||||
const coordinatorCheck = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: COORDINATOR_HANDLE,
|
||||
types: 'worker_done,escalation,decision_gate',
|
||||
format: true
|
||||
},
|
||||
evidence('coordinator'),
|
||||
'coordinator-check-decision-gates'
|
||||
)
|
||||
)
|
||||
expect(coordinatorCheck).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [
|
||||
{ id: firstId, type: 'decision_gate' },
|
||||
{ id: secondId, type: 'decision_gate' }
|
||||
],
|
||||
count: 2
|
||||
}
|
||||
})
|
||||
expect((coordinatorCheck as { result: { formatted: string } }).result.formatted).toContain(
|
||||
`orca orchestration reply --id ${firstId}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the normal wait budget when legacy check omits timeout', async () => {
|
||||
const harness = createHarness()
|
||||
const waitForMessage = vi
|
||||
.spyOn(harness.runtime, 'waitForMessage')
|
||||
.mockResolvedValue('timed_out')
|
||||
let clockReads = 0
|
||||
const now = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
clockReads += 1
|
||||
return clockReads === 1 ? 0 : clockReads === 2 ? 1 : 120_001
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE, wait: true },
|
||||
evidence('worker'),
|
||||
'legacy-check-default-timeout'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { messages: [], count: 0, timedOut: true }
|
||||
})
|
||||
expect(waitForMessage).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not infer an outcome for a current Dispatch when legacy adoption exists', async () => {
|
||||
const harness = createHarness()
|
||||
const run = harness.db.createRun({
|
||||
objective: 'current work',
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current_coord:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
const task = harness.db.createTask({ spec: 'current assignment', runId: run.id })
|
||||
const dispatch = harness.db.createDispatchContext(
|
||||
task.id,
|
||||
'term_current_worker',
|
||||
'tab_current_worker:66666666-6666-4666-8666-666666666666',
|
||||
'current-launch-hash'
|
||||
)
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: 'term_current_worker',
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Completed',
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
|
||||
},
|
||||
{
|
||||
terminalHandle: 'term_current_worker',
|
||||
paneKey: 'tab_current_worker:66666666-6666-4666-8666-666666666666',
|
||||
launchToken: 'current-token'
|
||||
},
|
||||
'current-missing-outcome'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(harness.db.getTask(task.id)?.status).toBe('dispatched')
|
||||
expect(harness.db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
|
||||
it('rejects invalid typed ACKs and consumes only the filtered legacy page', async () => {
|
||||
const harness = createHarness()
|
||||
await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
...escalationParams(harness),
|
||||
type: 'worker_done',
|
||||
subject: 'Completed',
|
||||
payload: JSON.stringify({ taskId: harness.taskId, dispatchId: harness.dispatchId })
|
||||
},
|
||||
evidence('worker'),
|
||||
'settle-for-mail'
|
||||
)
|
||||
)
|
||||
const status = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'status first',
|
||||
type: 'status',
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
const question = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'question second',
|
||||
type: 'question',
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
const check = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE, types: 'question' },
|
||||
evidence('worker'),
|
||||
'question-check'
|
||||
)
|
||||
)
|
||||
expect(check).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [{ id: question.id }],
|
||||
legacyCompatibility: { ackMessageIds: [question.id] }
|
||||
}
|
||||
})
|
||||
|
||||
const invalid = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: WORKER_HANDLE,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: [question.id],
|
||||
types: ['not-a-message-type']
|
||||
})
|
||||
},
|
||||
evidence('worker'),
|
||||
'invalid-ack'
|
||||
)
|
||||
)
|
||||
expect(invalid).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
|
||||
expect(harness.db.getMessageById(status.id)?.read).toBe(0)
|
||||
expect(harness.db.getMessageById(question.id)?.read).toBe(0)
|
||||
|
||||
const acknowledged = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: WORKER_HANDLE,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: [question.id],
|
||||
types: ['question']
|
||||
})
|
||||
},
|
||||
evidence('worker'),
|
||||
'valid-ack'
|
||||
)
|
||||
)
|
||||
expect(acknowledged).toMatchObject({
|
||||
ok: true,
|
||||
result: { acknowledged: [question.id], legacyCompatibility: { acknowledged: true } }
|
||||
})
|
||||
expect(harness.db.getMessageById(status.id)?.read).toBe(0)
|
||||
expect(harness.db.getMessageById(question.id)?.read).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects invalid legacy check types before attestation or mail consumption', async () => {
|
||||
const harness = createHarness()
|
||||
const message = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'retained status',
|
||||
type: 'status',
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE, types: 'status,not-a-message-type' },
|
||||
evidence('worker'),
|
||||
'invalid-types'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'invalid_argument', message: 'Invalid --types: not-a-message-type' }
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.db.getMessageById(message.id)?.read).toBe(0)
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advertises only Run-addressed current delivery to a legacy coordinator', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: 'term_current_worker',
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'worker-only current mail',
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const workerOnly = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
'worker-current-mail'
|
||||
)
|
||||
)
|
||||
expect(workerOnly).toMatchObject({
|
||||
ok: true,
|
||||
result: { legacyCompatibility: { currentDelivery: undefined } }
|
||||
})
|
||||
|
||||
harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: 'term_current_worker',
|
||||
to: `run:${harness.adoptedRunId}`,
|
||||
subject: 'Run current mail',
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const runMail = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
'run-current-mail'
|
||||
)
|
||||
)
|
||||
expect(runMail).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
legacyCompatibility: {
|
||||
currentDelivery: {
|
||||
runId: harness.adoptedRunId,
|
||||
checkCommand: expect.stringContaining(`--run ${harness.adoptedRunId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a legacy coordinator reply outside the adopted Run with zero effects', async () => {
|
||||
const harness = createHarness()
|
||||
const unrelatedRun = harness.db.createRun({
|
||||
objective: 'unrelated current work',
|
||||
coordinatorHandle: 'term_unrelated_coord',
|
||||
coordinatorPaneKey: 'tab_unrelated_coord:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
const unrelated = harness.db.insertMessage({
|
||||
runId: unrelatedRun.id,
|
||||
from: 'term_unrelated_worker',
|
||||
to: `run:${unrelatedRun.id}`,
|
||||
subject: 'Unrelated current status',
|
||||
deliveryContract: 'current_delivery'
|
||||
})
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: unrelated.id,
|
||||
body: 'Forged cross-Run reply',
|
||||
from: COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId
|
||||
},
|
||||
evidence('coordinator'),
|
||||
'cross-run-reply'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'request_mismatch',
|
||||
data: { effectsApplied: false }
|
||||
}
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.db.getMessageById(unrelated.id)?.read).toBe(0)
|
||||
expect(harness.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps explicit adopted Run task inspection available to unrelated callers', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskList',
|
||||
{ run: harness.adoptedRunId, callerTerminalHandle: 'term_unrelated' },
|
||||
{
|
||||
terminalHandle: 'term_unrelated',
|
||||
paneKey: 'tab_unrelated:55555555-5555-4555-8555-555555555555',
|
||||
launchToken: 'unrelated-token'
|
||||
},
|
||||
'explicit-adopted-run-inspection'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
runId: harness.adoptedRunId,
|
||||
tasks: [expect.objectContaining({ id: harness.taskId })]
|
||||
}
|
||||
})
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
expect(harness.verify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s binds an attested coordinator once and rejects contradictory proof before binding',
|
||||
async (transport) => {
|
||||
const invalidHarness = createHarness()
|
||||
const invalidBefore = counts(invalidHarness.db)
|
||||
const rejected = await invoke(
|
||||
invalidHarness.dispatcher,
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{ id: invalidHarness.adoptedRunId, from: COORDINATOR_HANDLE },
|
||||
evidence('coordinator', false),
|
||||
`run-use-invalid-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
expect(rejected).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(invalidHarness.db.getRun(invalidHarness.adoptedRunId)?.consumer_generation).toBe(0)
|
||||
expect(counts(invalidHarness.db)).toEqual(invalidBefore)
|
||||
|
||||
const harness = createHarness()
|
||||
const runUse = request(
|
||||
'orchestration.runUse',
|
||||
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
`run-use-${transport}`
|
||||
)
|
||||
const first = await invoke(harness.dispatcher, runUse, transport)
|
||||
const replay = await invoke(
|
||||
harness.dispatcher,
|
||||
{ ...runUse, id: 'rpc_run-use-replay' },
|
||||
transport
|
||||
)
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
result: { binding: { consumerGeneration: 1 }, mutation: { replayed: false } }
|
||||
})
|
||||
expect(replay).toMatchObject({
|
||||
ok: true,
|
||||
result: { binding: { consumerGeneration: 1 }, mutation: { replayed: true } }
|
||||
})
|
||||
expect(harness.db.getRun(harness.adoptedRunId)?.consumer_generation).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('passes trusted coordinator scope to a failing handler without binding or receipts', async () => {
|
||||
const harness = createHarness()
|
||||
const before = counts(harness.db)
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskUpdate',
|
||||
{
|
||||
id: 'task_missing',
|
||||
status: 'completed',
|
||||
callerTerminalHandle: COORDINATOR_HANDLE
|
||||
},
|
||||
evidence('coordinator'),
|
||||
'missing-task'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'task_not_found' } })
|
||||
expect(harness.db.getRun(harness.adoptedRunId)?.consumer_generation).toBe(0)
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
|
||||
it('rejects stale legacy coordinator proof after current takeover', async () => {
|
||||
const harness = createHarness()
|
||||
const principal = harness.db.commitLegacyCompatibilityPrincipal({
|
||||
runId: harness.adoptedRunId,
|
||||
role: 'coordinator',
|
||||
hostScope: JSON.stringify({ kind: 'local', hostId: 'local' }),
|
||||
terminalHandle: COORDINATOR_HANDLE,
|
||||
paneKey: COORDINATOR_PANE,
|
||||
launchTokenHash: createHash('sha256').update('coordinator-token').digest('hex'),
|
||||
processIncarnation: 'process-1'
|
||||
}).principal
|
||||
harness.db.settleWorkerReport({
|
||||
taskId: harness.taskId,
|
||||
dispatchId: harness.dispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'done'
|
||||
})
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
expect(harness.db.getLegacyCompatibilityPrincipal(principal.id)?.status).toBe('revoked')
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskList',
|
||||
{},
|
||||
evidence('coordinator'),
|
||||
'stale-coordinator-after-takeover'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { LegacyCoordinatorAuthorityProof, RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
|
||||
import { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
|
||||
import { handleLegacyLifecycleSend } from './orchestration-legacy-lifecycle'
|
||||
import { handleLegacyCheck, handleLegacyReply } from './orchestration-legacy-mail'
|
||||
import { handleLegacyAsk } from './orchestration-legacy-question'
|
||||
import type {
|
||||
LegacyAskParams,
|
||||
LegacyCheckParams,
|
||||
LegacyReplyParams,
|
||||
LegacySendParams
|
||||
} from './orchestration-legacy-operation'
|
||||
import { LegacyCoordinatorAuthority } from './orchestration-legacy-coordinator-authority'
|
||||
|
||||
const COORDINATOR_PREFLIGHT_METHODS = new Set([
|
||||
'orchestration.taskCreate',
|
||||
'orchestration.taskList',
|
||||
'orchestration.taskUpdate',
|
||||
'orchestration.dispatch',
|
||||
'orchestration.gateCreate',
|
||||
'orchestration.gateResolve',
|
||||
'orchestration.runUse',
|
||||
'orchestration.send',
|
||||
'orchestration.check',
|
||||
'orchestration.reply'
|
||||
])
|
||||
|
||||
export type LegacyCompatibilityRoute =
|
||||
| { handled: true; result: unknown }
|
||||
| {
|
||||
handled: false
|
||||
params?: unknown
|
||||
legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof
|
||||
orchestrationCompatibilityCallerAuthority?: OrchestrationCompatibilityCallerAuthority
|
||||
}
|
||||
|
||||
export type LegacyCoordinatorInvocation = Readonly<{
|
||||
authority: LegacyCoordinatorAuthorityProof
|
||||
mutationCallerFingerprint: string
|
||||
revalidate: () => string
|
||||
}>
|
||||
|
||||
export class OrchestrationLegacyCompatibility {
|
||||
private readonly authority: LegacyCompatibilityAuthority
|
||||
private readonly coordinatorAuthority: LegacyCoordinatorAuthority
|
||||
|
||||
constructor(private readonly runtime: OrcaRuntimeService) {
|
||||
this.authority = new LegacyCompatibilityAuthority(runtime)
|
||||
this.coordinatorAuthority = new LegacyCoordinatorAuthority(runtime)
|
||||
}
|
||||
|
||||
async tryHandle(
|
||||
request: RpcRequest,
|
||||
params: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<LegacyCompatibilityRoute> {
|
||||
if (!request.method.startsWith('orchestration.')) {
|
||||
return { handled: false }
|
||||
}
|
||||
const result = await this.route(request, params, signal)
|
||||
if (result !== undefined) {
|
||||
return { handled: true, result }
|
||||
}
|
||||
if (!COORDINATOR_PREFLIGHT_METHODS.has(request.method)) {
|
||||
return { handled: false }
|
||||
}
|
||||
const values = params as Record<string, unknown>
|
||||
if (request.method === 'orchestration.runUse' && values.takeoverLegacy === true) {
|
||||
const callerAuthority = this.runtime.verifyOrchestrationCompatibilityCaller(
|
||||
request.orchestrationCompatibilityEvidence
|
||||
)
|
||||
return {
|
||||
handled: false,
|
||||
...(callerAuthority ? { orchestrationCompatibilityCallerAuthority: callerAuthority } : {})
|
||||
}
|
||||
}
|
||||
const requestedRunId =
|
||||
request.method === 'orchestration.runUse' ? stringValue(values.id) : stringValue(values.run)
|
||||
if (request.method === 'orchestration.taskList' && requestedRunId) {
|
||||
return { handled: false }
|
||||
}
|
||||
const authority = this.coordinatorAuthority.resolve(request, requestedRunId)
|
||||
return authority
|
||||
? {
|
||||
handled: false,
|
||||
params: { ...values, run: authority.runId },
|
||||
legacyCoordinatorAuthority: authority
|
||||
}
|
||||
: { handled: false }
|
||||
}
|
||||
|
||||
revalidateCoordinatorAuthority(
|
||||
request: RpcRequest,
|
||||
proof: LegacyCoordinatorAuthorityProof
|
||||
): string {
|
||||
return this.coordinatorAuthority.revalidate(request, proof)
|
||||
}
|
||||
|
||||
createCoordinatorInvocation(
|
||||
request: RpcRequest,
|
||||
authority?: LegacyCoordinatorAuthorityProof
|
||||
): LegacyCoordinatorInvocation | undefined {
|
||||
return authority
|
||||
? {
|
||||
authority,
|
||||
mutationCallerFingerprint: legacyCoordinatorMutationCallerFingerprint(authority),
|
||||
revalidate: () => this.revalidateCoordinatorAuthority(request, authority)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
private async route(
|
||||
request: RpcRequest,
|
||||
params: unknown,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown | undefined> {
|
||||
if (request.method === 'orchestration.send') {
|
||||
return await handleLegacyLifecycleSend({
|
||||
runtime: this.runtime,
|
||||
authority: this.authority,
|
||||
request,
|
||||
params: params as LegacySendParams
|
||||
})
|
||||
}
|
||||
if (request.method === 'orchestration.check') {
|
||||
return await handleLegacyCheck({
|
||||
runtime: this.runtime,
|
||||
authority: this.authority,
|
||||
request,
|
||||
params: params as LegacyCheckParams,
|
||||
signal
|
||||
})
|
||||
}
|
||||
if (request.method === 'orchestration.ask') {
|
||||
return await handleLegacyAsk({
|
||||
runtime: this.runtime,
|
||||
authority: this.authority,
|
||||
request,
|
||||
params: params as LegacyAskParams,
|
||||
signal
|
||||
})
|
||||
}
|
||||
if (request.method === 'orchestration.reply') {
|
||||
return await handleLegacyReply({
|
||||
runtime: this.runtime,
|
||||
authority: this.authority,
|
||||
request,
|
||||
params: params as LegacyReplyParams
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function legacyCoordinatorMutationCallerFingerprint(
|
||||
authority: LegacyCoordinatorAuthorityProof
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
['legacy-coordinator-v1', authority.runId, authority.terminalHandle, authority.paneKey].join(
|
||||
'\0'
|
||||
)
|
||||
)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
|
||||
import type { LegacyCompatibilityPrincipalRow } from '../orchestration/types'
|
||||
import type { LegacyCoordinatorAuthorityProof, RpcRequest } from './core'
|
||||
import {
|
||||
resolveAttestedLegacyPrincipal,
|
||||
verifyAttestedLegacyCandidate,
|
||||
type LegacyPrincipalCandidate
|
||||
} from './orchestration-legacy-authority'
|
||||
import {
|
||||
equivalentLegacyPaneKey,
|
||||
legacyCoordinatorReadOnly
|
||||
} from './orchestration-legacy-process-identity'
|
||||
|
||||
export class LegacyCoordinatorAuthority {
|
||||
constructor(private readonly runtime: OrcaRuntimeService) {}
|
||||
|
||||
resolve(
|
||||
request: RpcRequest,
|
||||
requestedRunId?: string
|
||||
): LegacyCoordinatorAuthorityProof | undefined {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const adoption = db.getLegacyAdoption()
|
||||
const requestedRun = requestedRunId ?? adoption?.adopted_run_id
|
||||
if (!adoption || requestedRun !== adoption.adopted_run_id) {
|
||||
return undefined
|
||||
}
|
||||
const candidate = db.resolveLegacyCoordinatorCandidate({
|
||||
runId: adoption.adopted_run_id,
|
||||
terminalHandle: request.orchestrationCompatibilityEvidence?.terminalHandle,
|
||||
paneKey: request.orchestrationCompatibilityEvidence?.paneKey
|
||||
})
|
||||
if (!candidate) {
|
||||
if (request.orchestrationCompatibilityEvidence) {
|
||||
const caller = this.runtime.verifyOrchestrationCompatibilityCaller(
|
||||
request.orchestrationCompatibilityEvidence
|
||||
)
|
||||
const run = db.getRun(adoption.adopted_run_id)
|
||||
if (
|
||||
caller &&
|
||||
run?.coordinator_handle === caller.terminalHandle &&
|
||||
run.coordinator_pane_key &&
|
||||
equivalentLegacyPaneKey(run.coordinator_pane_key, caller.paneKey)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const existing = db.getLegacyCoordinatorPrincipal(adoption.adopted_run_id)
|
||||
const proofCandidate = existing
|
||||
? this.candidate(adoption.adopted_run_id, existing)
|
||||
: this.candidate(adoption.adopted_run_id, candidate)
|
||||
const attestation = verifyAttestedLegacyCandidate({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate: proofCandidate
|
||||
})
|
||||
const run = db.getRun(adoption.adopted_run_id)
|
||||
if (!run || !this.bindingMatches(run, proofCandidate)) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
if (existing && !this.principalMatchesAttestation(existing, attestation)) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
const principal = existing
|
||||
? existing
|
||||
: request.method === 'orchestration.runUse'
|
||||
? resolveAttestedLegacyPrincipal({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate: proofCandidate,
|
||||
authority: attestation
|
||||
})
|
||||
: undefined
|
||||
return {
|
||||
runId: run.id,
|
||||
principalId: principal?.id ?? null,
|
||||
terminalHandle: proofCandidate.terminalHandle,
|
||||
paneKey: proofCandidate.paneKey,
|
||||
consumerGeneration: run.consumer_generation
|
||||
}
|
||||
}
|
||||
|
||||
revalidate(request: RpcRequest, proof: LegacyCoordinatorAuthorityProof): string {
|
||||
const db = this.runtime.getOrchestrationDb()
|
||||
const adoption = db.getLegacyAdoption()
|
||||
const run = db.getRun(proof.runId)
|
||||
if (
|
||||
adoption?.adopted_run_id !== proof.runId ||
|
||||
!run ||
|
||||
run.consumer_generation !== proof.consumerGeneration
|
||||
) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
const candidate = this.candidate(proof.runId, proof)
|
||||
const principal = db.getLegacyCoordinatorPrincipal(proof.runId)
|
||||
if (proof.principalId) {
|
||||
if (
|
||||
principal?.id !== proof.principalId ||
|
||||
principal.status !== 'committed' ||
|
||||
principal.terminal_handle !== proof.terminalHandle ||
|
||||
!equivalentLegacyPaneKey(principal.pane_key, proof.paneKey)
|
||||
) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
} else if (
|
||||
principal &&
|
||||
(principal.status !== 'committed' ||
|
||||
principal.terminal_handle !== proof.terminalHandle ||
|
||||
!equivalentLegacyPaneKey(principal.pane_key, proof.paneKey))
|
||||
) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
const attestation = verifyAttestedLegacyCandidate({
|
||||
runtime: this.runtime,
|
||||
evidence: request.orchestrationCompatibilityEvidence,
|
||||
candidate
|
||||
})
|
||||
if (principal && !this.principalMatchesAttestation(principal, attestation)) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
if (!this.bindingMatches(run, candidate)) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
return run.id
|
||||
}
|
||||
|
||||
private candidate(
|
||||
runId: string,
|
||||
identity: {
|
||||
terminalHandle?: string
|
||||
terminal_handle?: string
|
||||
paneKey?: string
|
||||
pane_key?: string
|
||||
}
|
||||
): LegacyPrincipalCandidate {
|
||||
return {
|
||||
runId,
|
||||
role: 'coordinator',
|
||||
terminalHandle: identity.terminalHandle ?? (identity.terminal_handle as string),
|
||||
paneKey: identity.paneKey ?? (identity.pane_key as string)
|
||||
}
|
||||
}
|
||||
|
||||
private bindingMatches(
|
||||
run: { coordinator_handle: string | null; coordinator_pane_key: string | null },
|
||||
candidate: LegacyPrincipalCandidate
|
||||
): boolean {
|
||||
return (
|
||||
!run.coordinator_pane_key ||
|
||||
(run.coordinator_handle === candidate.terminalHandle &&
|
||||
equivalentLegacyPaneKey(run.coordinator_pane_key, candidate.paneKey))
|
||||
)
|
||||
}
|
||||
|
||||
private principalMatchesAttestation(
|
||||
principal: LegacyCompatibilityPrincipalRow,
|
||||
attestation: OrchestrationCompatibilityCallerAuthority
|
||||
): boolean {
|
||||
return (
|
||||
principal.status === 'committed' &&
|
||||
principal.host_scope === JSON.stringify(attestation.hostScope) &&
|
||||
principal.terminal_handle === attestation.terminalHandle &&
|
||||
equivalentLegacyPaneKey(principal.pane_key, attestation.paneKey) &&
|
||||
principal.launch_token_hash === attestation.launchTokenHash &&
|
||||
principal.process_incarnation === attestation.processIncarnation
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationDb } from '../orchestration/db'
|
||||
import type { RpcRequest, RpcResponse } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './methods/orchestration'
|
||||
|
||||
const COORDINATOR_HANDLE = 'term_legacy_coord'
|
||||
const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
const WORKER_HANDLE = 'term_legacy_worker'
|
||||
const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
|
||||
type Transport = 'dispatch' | 'websocket'
|
||||
|
||||
type Harness = {
|
||||
db: OrchestrationDb
|
||||
dispatcher: RpcDispatcher
|
||||
runtime: OrcaRuntimeService
|
||||
adoptedRunId: string
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
}
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
afterEach(() => {
|
||||
databases.splice(0).forEach((database) => database.close())
|
||||
tempDirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function createHarness(): Harness {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-coordinator-race-'))
|
||||
tempDirs.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const before = new OrchestrationDb(dbPath)
|
||||
const task = before.createTask({
|
||||
spec: 'legacy assignment',
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
before.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
raw.exec(`
|
||||
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
databases.push(db)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : null
|
||||
)
|
||||
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation(
|
||||
(compatibilityEvidence) => {
|
||||
const coordinator =
|
||||
compatibilityEvidence?.terminalHandle === COORDINATOR_HANDLE &&
|
||||
compatibilityEvidence.paneKey === COORDINATOR_PANE &&
|
||||
compatibilityEvidence.launchToken === 'coordinator-token'
|
||||
const worker =
|
||||
compatibilityEvidence?.terminalHandle === WORKER_HANDLE &&
|
||||
compatibilityEvidence.paneKey === WORKER_PANE &&
|
||||
compatibilityEvidence.launchToken === 'worker-token'
|
||||
return coordinator || worker
|
||||
? {
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
terminalHandle: coordinator ? COORDINATOR_HANDLE : WORKER_HANDLE,
|
||||
paneKey: coordinator ? COORDINATOR_PANE : WORKER_PANE,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256')
|
||||
.update(coordinator ? 'coordinator-token' : 'worker-token')
|
||||
.digest('hex')
|
||||
}
|
||||
: null
|
||||
}
|
||||
)
|
||||
return {
|
||||
db,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
runtime,
|
||||
adoptedRunId,
|
||||
taskId: task.id,
|
||||
dispatchId: dispatch.id
|
||||
}
|
||||
}
|
||||
|
||||
function evidence(
|
||||
role: 'coordinator' | 'worker' = 'coordinator'
|
||||
): OrchestrationCompatibilityEvidence {
|
||||
return {
|
||||
terminalHandle: role === 'coordinator' ? COORDINATOR_HANDLE : WORKER_HANDLE,
|
||||
paneKey: role === 'coordinator' ? COORDINATOR_PANE : WORKER_PANE,
|
||||
launchToken: role === 'coordinator' ? 'coordinator-token' : 'worker-token'
|
||||
}
|
||||
}
|
||||
|
||||
function request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
invocationId: string,
|
||||
callerEvidence = evidence()
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: `rpc_${invocationId}`,
|
||||
authToken: 'caller-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: invocationId,
|
||||
compatibilityInvocationId: invocationId,
|
||||
orchestrationCompatibilityEvidence: callerEvidence
|
||||
}
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
dispatcher: RpcDispatcher,
|
||||
rpcRequest: RpcRequest,
|
||||
transport: Transport
|
||||
): Promise<RpcResponse> {
|
||||
if (transport === 'dispatch') {
|
||||
return await dispatcher.dispatch(rpcRequest)
|
||||
}
|
||||
const replies: string[] = []
|
||||
await dispatcher.dispatchStreaming(rpcRequest, (reply) => replies.push(reply))
|
||||
expect(replies).toHaveLength(1)
|
||||
return JSON.parse(replies[0]) as RpcResponse
|
||||
}
|
||||
|
||||
function installTakeoverDuringMutationPreflight(harness: Harness): void {
|
||||
const originalBegin = harness.db.beginMutationReceipt.bind(harness.db)
|
||||
vi.spyOn(harness.db, 'beginMutationReceipt').mockImplementation((identity) => {
|
||||
const begun = originalBegin(identity)
|
||||
settleLegacyWorkerAndTakeOver(harness, 'settled during takeover')
|
||||
return begun
|
||||
})
|
||||
}
|
||||
|
||||
function settleLegacyWorkerAndTakeOver(harness: Harness, result: string): void {
|
||||
harness.db.settleWorkerReport({
|
||||
taskId: harness.taskId,
|
||||
dispatchId: harness.dispatchId,
|
||||
outcome: 'succeeded',
|
||||
result
|
||||
})
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555'
|
||||
})
|
||||
}
|
||||
|
||||
function mutationReceiptCount(db: OrchestrationDb): number {
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
return (
|
||||
sqlite.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { count: number }
|
||||
).count
|
||||
}
|
||||
|
||||
function messageCount(db: OrchestrationDb): number {
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
return (sqlite.prepare('SELECT COUNT(*) AS count FROM messages').get() as { count: number }).count
|
||||
}
|
||||
|
||||
describe('legacy coordinator takeover races', () => {
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s routes a pre-bind coordinator send to the retained worker mailbox',
|
||||
async (transport) => {
|
||||
const harness = createHarness()
|
||||
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: WORKER_HANDLE, subject: 'continue safely' },
|
||||
`send-direct-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: WORKER_HANDLE,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}
|
||||
}
|
||||
})
|
||||
const workerCheck = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE },
|
||||
`worker-check-${transport}`,
|
||||
evidence('worker')
|
||||
)
|
||||
)
|
||||
expect(workerCheck).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
subject: 'continue safely',
|
||||
run_id: harness.adoptedRunId,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}
|
||||
],
|
||||
legacyCompatibility: { ackMessageIds: [expect.any(String)] }
|
||||
}
|
||||
})
|
||||
expect(
|
||||
harness.db.getInbox(100).some((message) => message.run_id === 'run_legacy_local')
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('replays coordinator mutations after transport authentication rotates', async () => {
|
||||
const harness = createHarness()
|
||||
const mutation = request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: WORKER_HANDLE, subject: 'send once' },
|
||||
'coordinator-restart-send'
|
||||
)
|
||||
mutation.authToken = 'before-restart'
|
||||
|
||||
const first = await harness.dispatcher.dispatch(mutation)
|
||||
const restartedDispatcher = new RpcDispatcher({
|
||||
runtime: harness.runtime,
|
||||
methods: ORCHESTRATION_METHODS
|
||||
})
|
||||
const replay = await restartedDispatcher.dispatch({
|
||||
...mutation,
|
||||
id: 'rpc_coordinator-restart-send-retry',
|
||||
authToken: 'after-restart'
|
||||
})
|
||||
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: { subject: 'send once' },
|
||||
mutation: { requestId: 'coordinator-restart-send', replayed: false }
|
||||
}
|
||||
})
|
||||
expect(replay).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: { id: (first as { result: { message: { id: string } } }).result.message.id },
|
||||
mutation: { requestId: 'coordinator-restart-send', replayed: true }
|
||||
}
|
||||
})
|
||||
expect(messageCount(harness.db)).toBe(1)
|
||||
expect(mutationReceiptCount(harness.db)).toBe(1)
|
||||
})
|
||||
|
||||
it('routes an exact legacy Dispatch recipient to compatibility delivery', async () => {
|
||||
const harness = createHarness()
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: `dispatch:${harness.dispatchId}`,
|
||||
subject: 'dispatch guidance'
|
||||
},
|
||||
'send-dispatch'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: `dispatch:${harness.dispatchId}`,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}
|
||||
}
|
||||
})
|
||||
const workerCheck = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE },
|
||||
'worker-check-dispatch',
|
||||
evidence('worker')
|
||||
)
|
||||
)
|
||||
expect(workerCheck).toMatchObject({
|
||||
ok: true,
|
||||
result: { messages: [{ subject: 'dispatch guidance' }] }
|
||||
})
|
||||
})
|
||||
|
||||
it('partitions a coordinator group send by legacy recipient contract', async () => {
|
||||
const harness = createHarness()
|
||||
vi.spyOn(harness.runtime, 'listTerminals').mockResolvedValue({
|
||||
terminals: [
|
||||
{ handle: COORDINATOR_HANDLE },
|
||||
{ handle: WORKER_HANDLE },
|
||||
{ handle: 'term_current_worker' }
|
||||
],
|
||||
totalCount: 3,
|
||||
truncated: false
|
||||
} as never)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: '@all', subject: 'group guidance' },
|
||||
'send-group'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: true, result: { recipients: 2 } })
|
||||
const messages = harness.db
|
||||
.getInbox(100)
|
||||
.filter((message) => message.subject === 'group guidance')
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: WORKER_HANDLE,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: 'term_current_worker',
|
||||
delivery_contract: 'current_delivery'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s rejects a coordinator send after takeover during durable preflight',
|
||||
async (transport) => {
|
||||
const harness = createHarness()
|
||||
installTakeoverDuringMutationPreflight(harness)
|
||||
const before = messageCount(harness.db)
|
||||
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: WORKER_HANDLE, subject: 'must not send' },
|
||||
`send-takeover-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(messageCount(harness.db)).toBe(before)
|
||||
expect(mutationReceiptCount(harness.db)).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('revalidates a group send after asynchronous terminal discovery', async () => {
|
||||
const harness = createHarness()
|
||||
let resolveTerminals:
|
||||
| ((value: {
|
||||
terminals: { handle: string }[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}) => void)
|
||||
| undefined
|
||||
let signalListingStarted: (() => void) | undefined
|
||||
const listingStarted = new Promise<void>((resolve) => {
|
||||
signalListingStarted = resolve
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'listTerminals').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveTerminals = resolve as typeof resolveTerminals
|
||||
signalListingStarted?.()
|
||||
}) as never
|
||||
)
|
||||
const before = messageCount(harness.db)
|
||||
const pending = harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{ from: COORDINATOR_HANDLE, to: '@all', subject: 'must remain unsent' },
|
||||
'send-group-takeover'
|
||||
)
|
||||
)
|
||||
await listingStarted
|
||||
settleLegacyWorkerAndTakeOver(harness, 'settled during terminal discovery')
|
||||
resolveTerminals?.({
|
||||
terminals: [{ handle: COORDINATOR_HANDLE }, { handle: WORKER_HANDLE }],
|
||||
totalCount: 2,
|
||||
truncated: false
|
||||
})
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'legacy_read_only' }
|
||||
})
|
||||
expect(messageCount(harness.db)).toBe(before)
|
||||
expect(mutationReceiptCount(harness.db)).toBe(0)
|
||||
})
|
||||
|
||||
it('reports a committed ACK when coordinator takeover interrupts its wait', async () => {
|
||||
const harness = createHarness()
|
||||
const incoming = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: `run:${harness.adoptedRunId}`,
|
||||
subject: 'ready to acknowledge'
|
||||
})
|
||||
const first = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: COORDINATOR_HANDLE, run: harness.adoptedRunId },
|
||||
'check-delivery-before-takeover'
|
||||
)
|
||||
)
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
result: { deliveryId: expect.any(String), messages: [{ id: incoming.id }] }
|
||||
})
|
||||
const deliveryId = (first as { result: { deliveryId: string } }).result.deliveryId
|
||||
let resolveWait: (() => void) | undefined
|
||||
let signalWaitStarted: (() => void) | undefined
|
||||
const waitStarted = new Promise<void>((resolve) => {
|
||||
signalWaitStarted = resolve
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'waitForMessage').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveWait = () => resolve('notified')
|
||||
signalWaitStarted?.()
|
||||
})
|
||||
)
|
||||
const pending = harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId,
|
||||
ack: deliveryId,
|
||||
wait: true,
|
||||
timeoutMs: 10_000
|
||||
},
|
||||
'check-ack-takeover'
|
||||
)
|
||||
)
|
||||
await waitStarted
|
||||
expect(harness.db.getMessageById(incoming.id)?.read).toBe(1)
|
||||
settleLegacyWorkerAndTakeOver(harness, 'settled during acknowledged wait')
|
||||
resolveWait?.()
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
acknowledged: deliveryId,
|
||||
count: 0,
|
||||
waitInterrupted: 'consumer_fenced',
|
||||
mutation: { requestId: 'check-ack-takeover', replayed: false }
|
||||
}
|
||||
})
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
expect(sqlite.prepare('SELECT state FROM mutation_receipts').get()).toEqual({
|
||||
state: 'completed'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps concurrent duplicates on the live ACK-and-wait result', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: `run:${harness.adoptedRunId}`,
|
||||
subject: 'first delivery'
|
||||
})
|
||||
const firstCheck = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: COORDINATOR_HANDLE, run: harness.adoptedRunId },
|
||||
'check-first-delivery'
|
||||
)
|
||||
)
|
||||
const deliveryId = (firstCheck as { result: { deliveryId: string } }).result.deliveryId
|
||||
let resolveWait: (() => void) | undefined
|
||||
let signalWaitStarted: (() => void) | undefined
|
||||
const waitStarted = new Promise<void>((resolve) => {
|
||||
signalWaitStarted = resolve
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'waitForMessage').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveWait = () => resolve('notified')
|
||||
signalWaitStarted?.()
|
||||
})
|
||||
)
|
||||
const ackRequest = request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId,
|
||||
ack: deliveryId,
|
||||
wait: true,
|
||||
timeoutMs: 10_000
|
||||
},
|
||||
'check-ack-concurrent'
|
||||
)
|
||||
const original = harness.dispatcher.dispatch(ackRequest)
|
||||
await waitStarted
|
||||
const secondLiveDispatcher = new RpcDispatcher({
|
||||
runtime: harness.runtime,
|
||||
methods: ORCHESTRATION_METHODS
|
||||
})
|
||||
const duplicate = secondLiveDispatcher.dispatch({
|
||||
...ackRequest,
|
||||
id: 'rpc_check-ack-concurrent-duplicate'
|
||||
})
|
||||
let duplicateSettled = false
|
||||
void duplicate.then(() => {
|
||||
duplicateSettled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(duplicateSettled).toBe(false)
|
||||
|
||||
const arrived = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: `run:${harness.adoptedRunId}`,
|
||||
subject: 'arrived while waiting'
|
||||
})
|
||||
resolveWait?.()
|
||||
const [originalResult, duplicateResult] = await Promise.all([original, duplicate])
|
||||
expect(originalResult).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [{ id: arrived.id }],
|
||||
mutation: { requestId: 'check-ack-concurrent', replayed: false }
|
||||
}
|
||||
})
|
||||
expect(duplicateResult).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [{ id: arrived.id }],
|
||||
mutation: { requestId: 'check-ack-concurrent', replayed: true }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s rejects run-use after takeover during durable preflight',
|
||||
async (transport) => {
|
||||
const harness = createHarness()
|
||||
installTakeoverDuringMutationPreflight(harness)
|
||||
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
|
||||
`run-use-takeover-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(harness.db.getRun(harness.adoptedRunId)).toMatchObject({
|
||||
coordinator_handle: 'term_current_coord',
|
||||
consumer_generation: 1
|
||||
})
|
||||
expect(mutationReceiptCount(harness.db)).toBe(0)
|
||||
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('revoked')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['dispatch', 'websocket'] as const)(
|
||||
'%s rejects a mutation after takeover during durable preflight',
|
||||
async (transport) => {
|
||||
const harness = createHarness()
|
||||
const target = harness.db.createTask({
|
||||
spec: 'must remain ready',
|
||||
runId: harness.adoptedRunId
|
||||
})
|
||||
installTakeoverDuringMutationPreflight(harness)
|
||||
|
||||
const response = await invoke(
|
||||
harness.dispatcher,
|
||||
request(
|
||||
'orchestration.taskUpdate',
|
||||
{
|
||||
id: target.id,
|
||||
status: 'completed',
|
||||
callerTerminalHandle: COORDINATOR_HANDLE
|
||||
},
|
||||
`task-update-takeover-${transport}`
|
||||
),
|
||||
transport
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(harness.db.getTask(target.id)?.status).toBe('ready')
|
||||
expect(mutationReceiptCount(harness.db)).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('revalidates after asynchronous agent detection', async () => {
|
||||
const harness = createHarness()
|
||||
const target = harness.db.createTask({
|
||||
spec: 'must not dispatch after takeover',
|
||||
runId: harness.adoptedRunId
|
||||
})
|
||||
const targetHandle = 'term_current_worker'
|
||||
const targetPane = 'tab_current_worker:77777777-7777-4777-8777-777777777777'
|
||||
vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle: string) =>
|
||||
handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === targetHandle ? targetPane : null
|
||||
)
|
||||
vi.spyOn(harness.runtime, 'getTerminalProcessIncarnation').mockReturnValue('current-process')
|
||||
let resolveDetection: ((detected: boolean) => void) | undefined
|
||||
let signalDetectionStarted: (() => void) | undefined
|
||||
const detectionStarted = new Promise<void>((resolve) => {
|
||||
signalDetectionStarted = resolve
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveDetection = resolve
|
||||
signalDetectionStarted?.()
|
||||
})
|
||||
)
|
||||
const sendPrompt = vi
|
||||
.spyOn(harness.runtime, 'sendTerminalAgentPrompt')
|
||||
.mockResolvedValue({ handle: targetHandle, accepted: true, bytesWritten: 1 })
|
||||
|
||||
const pending = harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.dispatch',
|
||||
{ task: target.id, to: targetHandle, from: COORDINATOR_HANDLE, inject: true },
|
||||
'dispatch-detection-takeover'
|
||||
)
|
||||
)
|
||||
await detectionStarted
|
||||
settleLegacyWorkerAndTakeOver(harness, 'settled during detection')
|
||||
resolveDetection?.(true)
|
||||
const response = await pending
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(harness.db.getTask(target.id)?.status).toBe('ready')
|
||||
expect(harness.db.getDispatchContext(target.id)).toBeUndefined()
|
||||
expect(sendPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { MessageType } from '../orchestration/db'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
import type { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
|
||||
import {
|
||||
inferLegacyWorkerOutcome,
|
||||
isWorkerOutcome,
|
||||
operationIdentity,
|
||||
parseLegacyPayload,
|
||||
stringValue,
|
||||
type LegacySendParams
|
||||
} from './orchestration-legacy-operation'
|
||||
|
||||
export async function handleLegacyLifecycleSend(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
authority: LegacyCompatibilityAuthority
|
||||
request: RpcRequest
|
||||
params: LegacySendParams
|
||||
}): Promise<unknown | undefined> {
|
||||
const { runtime, authority, request, params } = args
|
||||
if (!['heartbeat', 'worker_done', 'escalation'].includes(params.type ?? '')) {
|
||||
return undefined
|
||||
}
|
||||
const payload = parseLegacyPayload(params.payload)
|
||||
const dispatch = authority.resolveWorkerDispatch(request, {
|
||||
terminalHandle: params.from,
|
||||
dispatchId: stringValue(payload.dispatchId),
|
||||
taskId: stringValue(payload.taskId)
|
||||
})
|
||||
if (!dispatch) {
|
||||
if (params.type === 'worker_done' && !isWorkerOutcome(payload.outcome)) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'worker_done requires outcome=succeeded|failed for a current Dispatch.'
|
||||
)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (!params.to) {
|
||||
throw new OrchestrationError('invalid_argument', 'Legacy lifecycle mail requires --to.')
|
||||
}
|
||||
const db = runtime.getOrchestrationDb()
|
||||
if (!db.isLegacyCoordinatorHandle(dispatch.run_id, params.to)) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Terminal ${params.to} is not a retained coordinator for this legacy Dispatch.`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
params.type === 'worker_done' &&
|
||||
Object.hasOwn(payload, 'outcome') &&
|
||||
!isWorkerOutcome(payload.outcome)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'invalid_argument',
|
||||
'Legacy worker_done outcome must be succeeded or failed when provided.'
|
||||
)
|
||||
}
|
||||
const principal = authority.attestWorker(request, dispatch)
|
||||
const operation = operationIdentity(request, params.type as string, { ...params, payload })
|
||||
const inferredOutcome =
|
||||
params.type === 'worker_done'
|
||||
? isWorkerOutcome(payload.outcome)
|
||||
? payload.outcome
|
||||
: inferLegacyWorkerOutcome(params.subject)
|
||||
: undefined
|
||||
const existingId =
|
||||
params.type === 'worker_done'
|
||||
? db.findLegacyWorkerCompletion({
|
||||
principalId: principal.id,
|
||||
taskId: dispatch.task_id,
|
||||
recipientHandle: params.to,
|
||||
subject: params.subject,
|
||||
body: params.body ?? '',
|
||||
payload: params.payload ?? null
|
||||
})?.id
|
||||
: undefined
|
||||
const committed = db.commitLegacyLifecycleOperation({
|
||||
principalId: principal.id,
|
||||
operationKey: operation.key,
|
||||
method: request.method,
|
||||
payloadHash: operation.payloadHash,
|
||||
message: {
|
||||
existingId,
|
||||
to: params.to,
|
||||
subject: params.subject,
|
||||
body: params.body,
|
||||
type: params.type as MessageType,
|
||||
priority: params.priority,
|
||||
payload: params.payload
|
||||
},
|
||||
lifecycle:
|
||||
params.type === 'heartbeat'
|
||||
? { kind: 'heartbeat', at: new Date().toISOString() }
|
||||
: params.type === 'worker_done'
|
||||
? {
|
||||
kind: 'worker_report',
|
||||
taskId: dispatch.task_id,
|
||||
outcome: inferredOutcome as 'succeeded' | 'failed',
|
||||
result: params.body || params.subject
|
||||
}
|
||||
: { kind: 'message_only' }
|
||||
})
|
||||
if (!committed.duplicate) {
|
||||
runtime.notifyMessageArrived(committed.message.to_handle, committed.message.type)
|
||||
}
|
||||
return {
|
||||
message: committed.message,
|
||||
...(committed.settlement ? { lifecycle: committed.settlement } : {}),
|
||||
legacyCompatibility: { replayed: committed.duplicate }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
import { formatMessageBanner } from '../orchestration/formatter'
|
||||
import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../../shared/orchestration-message-wait-timeout'
|
||||
import type { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
|
||||
import {
|
||||
operationIdentity,
|
||||
parseLegacyMailAck,
|
||||
parseLegacyQuestionAck,
|
||||
parseLegacyTypes,
|
||||
supportedLegacyHints,
|
||||
type LegacyCheckParams,
|
||||
type LegacyReplyParams
|
||||
} from './orchestration-legacy-operation'
|
||||
|
||||
export async function handleLegacyCheck(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
authority: LegacyCompatibilityAuthority
|
||||
request: RpcRequest
|
||||
params: LegacyCheckParams
|
||||
signal?: AbortSignal
|
||||
}): Promise<unknown | undefined> {
|
||||
const { runtime, authority, request, params, signal } = args
|
||||
if (params.run) {
|
||||
return undefined
|
||||
}
|
||||
const typeFilter = parseLegacyTypes(params.types)
|
||||
const principal = authority.resolveCheckPrincipal(request, params.terminal)
|
||||
if (!principal) {
|
||||
return undefined
|
||||
}
|
||||
const db = runtime.getOrchestrationDb()
|
||||
if (params.compatibilityQuestionAck) {
|
||||
const answer = parseLegacyQuestionAck(params.compatibilityQuestionAck)
|
||||
const acknowledged = db.acknowledgeLegacyQuestionAnswer({
|
||||
principalId: principal.id,
|
||||
...answer
|
||||
})
|
||||
return {
|
||||
messages: [],
|
||||
count: 0,
|
||||
acknowledged: [answer.answerMessageId],
|
||||
duplicate: acknowledged.duplicate,
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
}
|
||||
if (params.compatibilityAck) {
|
||||
const ack = parseLegacyMailAck(params.compatibilityAck)
|
||||
const acknowledged = db.acknowledgeLegacyMail({
|
||||
principalId: principal.id,
|
||||
messageIds: ack.messageIds,
|
||||
types: ack.types
|
||||
})
|
||||
return {
|
||||
messages: [],
|
||||
count: 0,
|
||||
acknowledged: ack.messageIds,
|
||||
duplicate: acknowledged.duplicate,
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
}
|
||||
|
||||
const history = params.all || (params.unread === false && !params.peek)
|
||||
const readOnly = Boolean(params.peek || history)
|
||||
const read = () =>
|
||||
history
|
||||
? db.getLegacyMailHistory({ principalId: principal.id, types: typeFilter })
|
||||
: db.getLegacyMailPage({ principalId: principal.id, types: typeFilter })
|
||||
let page = read()
|
||||
const deadline =
|
||||
Date.now() + Math.max(params.timeoutMs ?? ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS, 0)
|
||||
while (page.messages.length === 0 && params.wait && !signal?.aborted && Date.now() < deadline) {
|
||||
if (principal.role === 'coordinator' && db.hasPendingCurrentDelivery(principal.run_id)) {
|
||||
break
|
||||
}
|
||||
await runtime.waitForMessage(
|
||||
principal.role === 'worker'
|
||||
? `dispatch:${principal.dispatch_id as string}`
|
||||
: principal.terminal_handle,
|
||||
{
|
||||
typeFilter,
|
||||
timeoutMs: Math.min(1_000, Math.max(deadline - Date.now(), 1)),
|
||||
signal
|
||||
}
|
||||
)
|
||||
page = read()
|
||||
}
|
||||
|
||||
const consuming = !readOnly
|
||||
const messageIds = consuming ? page.messages.map((message) => message.id) : []
|
||||
const formattingAuthority = readOnly
|
||||
? 'legacy_read_only'
|
||||
: page.recovery
|
||||
? 'legacy_recovery_replay'
|
||||
: 'legacy_compatibility'
|
||||
const formatted =
|
||||
params.format || params.inject
|
||||
? page.messages
|
||||
.map((message) =>
|
||||
formatMessageBanner(message, {
|
||||
authority: formattingAuthority,
|
||||
supportedActionHints: readOnly
|
||||
? []
|
||||
: supportedLegacyHints(message, principal, params.compatibilityCliCommand ?? 'orca')
|
||||
})
|
||||
)
|
||||
.join('\n\n')
|
||||
: undefined
|
||||
const currentDelivery =
|
||||
principal.role === 'coordinator' && db.hasPendingCurrentDelivery(principal.run_id)
|
||||
? {
|
||||
runId: principal.run_id,
|
||||
checkCommand: `${params.compatibilityCliCommand ?? 'orca'} orchestration check --run ${principal.run_id}`,
|
||||
ackCommand: `${params.compatibilityCliCommand ?? 'orca'} orchestration check --run ${principal.run_id} --ack <delivery-id>`
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
runId: principal.run_id,
|
||||
dispatchId: principal.dispatch_id,
|
||||
messages: page.messages,
|
||||
count: page.messages.length,
|
||||
formatted,
|
||||
timedOut: Boolean(params.wait && page.messages.length === 0 && !signal?.aborted),
|
||||
cancelled: signal?.aborted === true,
|
||||
connectionLost: signal?.aborted === true,
|
||||
legacyCompatibility: {
|
||||
recovery: page.recovery,
|
||||
readOnly,
|
||||
ackMessageIds: messageIds,
|
||||
currentDelivery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLegacyReply(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
authority: LegacyCompatibilityAuthority
|
||||
request: RpcRequest
|
||||
params: LegacyReplyParams
|
||||
}): Promise<unknown | undefined> {
|
||||
const { runtime, authority, request, params } = args
|
||||
const db = runtime.getOrchestrationDb()
|
||||
const original = db.getMessageById(params.id)
|
||||
if (!original || original.delivery_contract !== 'legacy_direct') {
|
||||
return undefined
|
||||
}
|
||||
const principal = authority.attestCoordinator(request, original.run_id)
|
||||
if (!principal) {
|
||||
throw legacyCoordinatorReadOnly()
|
||||
}
|
||||
const operation = operationIdentity(request, 'reply', {
|
||||
questionId: params.id,
|
||||
body: params.body
|
||||
})
|
||||
const committed = db.commitLegacyReplyOperation({
|
||||
principalId: principal.id,
|
||||
operationKey: operation.key,
|
||||
method: request.method,
|
||||
payloadHash: operation.payloadHash,
|
||||
questionId: params.id,
|
||||
body: params.body
|
||||
})
|
||||
if (!committed.duplicate) {
|
||||
runtime.notifyMessageArrived(committed.question.asker_handle, 'status')
|
||||
}
|
||||
return {
|
||||
message: committed.message,
|
||||
question: committed.question,
|
||||
duplicate: committed.duplicate,
|
||||
legacyCompatibility: { replayed: committed.duplicate }
|
||||
}
|
||||
}
|
||||
|
||||
function legacyCoordinatorReadOnly(): OrchestrationError {
|
||||
return new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy coordinator could not prove its original process identity. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { RpcRequest } from './core'
|
||||
import type { MessageType } from '../orchestration/db'
|
||||
import { MESSAGE_TYPES } from '../orchestration/types'
|
||||
import type { LegacyCompatibilityPrincipalRow, MessageRow } from '../orchestration/types'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
|
||||
const MESSAGE_TYPE_SET = new Set<string>(MESSAGE_TYPES)
|
||||
|
||||
export type LegacySendParams = {
|
||||
from?: string
|
||||
to?: string
|
||||
subject: string
|
||||
body?: string
|
||||
type?: string
|
||||
priority?: 'normal' | 'high' | 'urgent'
|
||||
payload?: string
|
||||
}
|
||||
|
||||
export type LegacyCheckParams = {
|
||||
terminal?: string
|
||||
run?: string
|
||||
unread?: boolean
|
||||
peek?: boolean
|
||||
all?: boolean
|
||||
types?: string
|
||||
format?: boolean
|
||||
inject?: boolean
|
||||
wait?: boolean
|
||||
timeoutMs?: number
|
||||
compatibilityAck?: string
|
||||
compatibilityQuestionAck?: string
|
||||
compatibilityCliCommand?: 'orca' | 'orca-ide' | 'orca-dev'
|
||||
}
|
||||
|
||||
export type LegacyAskParams = {
|
||||
from?: string
|
||||
to?: string
|
||||
run?: string
|
||||
question?: string
|
||||
resume?: string
|
||||
options?: string
|
||||
timeoutMs?: number
|
||||
compatibilityCliCommand?: 'orca' | 'orca-ide' | 'orca-dev'
|
||||
compatibilityWindowsCommand?: 'orca' | 'orca-ide'
|
||||
}
|
||||
|
||||
export type LegacyReplyParams = {
|
||||
id: string
|
||||
body: string
|
||||
from?: string
|
||||
run?: string
|
||||
}
|
||||
|
||||
export function operationIdentity(
|
||||
request: RpcRequest,
|
||||
method: string,
|
||||
payload: unknown
|
||||
): { key: string; payloadHash: string } {
|
||||
const canonical = JSON.stringify(canonicalize(payload))
|
||||
const payloadHash = createHash('sha256').update(canonical).digest('hex')
|
||||
const semantic =
|
||||
method === 'worker_done' || method === 'reply' || method === 'ask'
|
||||
? `${method}:${payloadHash}`
|
||||
: `${method}:${randomUUID()}`
|
||||
return {
|
||||
key: request.compatibilityInvocationId
|
||||
? `invocation:${request.compatibilityInvocationId}`
|
||||
: `semantic:${semantic}`,
|
||||
payloadHash
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLegacyPayload(raw: string | undefined): Record<string, unknown> {
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// Fall through to one stable validation error.
|
||||
}
|
||||
throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.')
|
||||
}
|
||||
|
||||
export function parseLegacyMailAck(raw: string): { messageIds: string[]; types?: MessageType[] } {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
const messageIds = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as { messageIds?: unknown }).messageIds
|
||||
: undefined
|
||||
const types =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as { types?: unknown }).types
|
||||
: undefined
|
||||
if (
|
||||
Array.isArray(messageIds) &&
|
||||
messageIds.length > 0 &&
|
||||
messageIds.every((value) => typeof value === 'string' && value.length > 0) &&
|
||||
(types === undefined ||
|
||||
(Array.isArray(types) &&
|
||||
types.every((value) => typeof value === 'string' && MESSAGE_TYPE_SET.has(value))))
|
||||
) {
|
||||
return {
|
||||
messageIds,
|
||||
types: types && types.length > 0 ? (types as MessageType[]) : undefined
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to one stable validation error.
|
||||
}
|
||||
throw new OrchestrationError('invalid_argument', 'Invalid compatibility acknowledgment.')
|
||||
}
|
||||
|
||||
export function parseLegacyQuestionAck(raw: string): {
|
||||
questionId: string
|
||||
answerMessageId: string
|
||||
} {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
typeof (parsed as { questionId?: unknown }).questionId === 'string' &&
|
||||
typeof (parsed as { answerMessageId?: unknown }).answerMessageId === 'string'
|
||||
) {
|
||||
return parsed as { questionId: string; answerMessageId: string }
|
||||
}
|
||||
} catch {
|
||||
// Fall through to one stable validation error.
|
||||
}
|
||||
throw new OrchestrationError('invalid_argument', 'Invalid legacy question acknowledgment.')
|
||||
}
|
||||
|
||||
export function parseLegacyTypes(raw: string | undefined): MessageType[] | undefined {
|
||||
const values = raw
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
const invalidValues = values?.filter((value) => !MESSAGE_TYPE_SET.has(value))
|
||||
if (invalidValues && invalidValues.length > 0) {
|
||||
throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidValues.join(',')}`)
|
||||
}
|
||||
return values && values.length > 0 ? (values as MessageType[]) : undefined
|
||||
}
|
||||
|
||||
export function parseLegacyOptions(raw: string | undefined): string[] {
|
||||
return (
|
||||
raw
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
export function supportedLegacyHints(
|
||||
message: MessageRow,
|
||||
principal: LegacyCompatibilityPrincipalRow,
|
||||
cliCommand: 'orca' | 'orca-ide' | 'orca-dev'
|
||||
): string[] {
|
||||
if (
|
||||
principal.role !== 'coordinator' ||
|
||||
(message.type !== 'question' && message.type !== 'decision_gate')
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
`${cliCommand} orchestration reply --id ${message.id} --from ${principal.terminal_handle} --body "..."`
|
||||
]
|
||||
}
|
||||
|
||||
export function inferLegacyWorkerOutcome(subject: string): 'succeeded' | 'failed' {
|
||||
const normalized = subject.trim()
|
||||
return normalized === 'Failed' || normalized.startsWith('Failed:') ? 'failed' : 'succeeded'
|
||||
}
|
||||
|
||||
export function isWorkerOutcome(value: unknown): value is 'succeeded' | 'failed' {
|
||||
return value === 'succeeded' || value === 'failed'
|
||||
}
|
||||
|
||||
export function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export function normalizeLegacyText(value: string): string {
|
||||
return value.replace(/\r\n/g, '\n').trim()
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize)
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value
|
||||
}
|
||||
const source = value as Record<string, unknown>
|
||||
return Object.fromEntries(
|
||||
Object.keys(source)
|
||||
.sort()
|
||||
.filter((key) => source[key] !== undefined)
|
||||
.map((key) => [key, canonicalize(source[key])])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
|
||||
export function equivalentLegacyPaneKey(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined
|
||||
): boolean {
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
const aLeaf = parsePaneKey(a)?.leafId
|
||||
const bLeaf = parsePaneKey(b)?.leafId
|
||||
return Boolean(aLeaf && bLeaf && aLeaf === bLeaf)
|
||||
}
|
||||
|
||||
export function legacyReadOnlyError(): OrchestrationError {
|
||||
return new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy assignment could not prove authority from its original live process. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
|
||||
export function legacyCoordinatorReadOnly(): OrchestrationError {
|
||||
return new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy coordinator could not prove its original process identity. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrchestrationDb } from '../orchestration/db'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { RpcRequest } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './methods/orchestration'
|
||||
|
||||
const COORDINATOR_HANDLE = 'term_legacy_coord'
|
||||
const CURRENT_COORDINATOR_HANDLE = 'term_current_coord'
|
||||
const CURRENT_COORDINATOR_PANE = 'tab_current:55555555-5555-4555-8555-555555555555'
|
||||
const WORKER_HANDLE = 'term_legacy_worker'
|
||||
const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
afterEach(() => {
|
||||
databases.splice(0).forEach((database) => database.close())
|
||||
tempDirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function createHarness(options?: { seedCutoverQuestion?: boolean; seedCutoverAnswer?: boolean }): {
|
||||
db: OrchestrationDb
|
||||
dispatcher: RpcDispatcher
|
||||
adoptedRunId: string
|
||||
dispatchId: string
|
||||
cutoverQuestionId?: string
|
||||
cutoverAnswerId?: string
|
||||
} {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-question-takeover-'))
|
||||
tempDirs.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const before = new OrchestrationDb(dbPath)
|
||||
const task = before.createTask({
|
||||
spec: 'legacy assignment',
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
const cutoverQuestion = options?.seedCutoverQuestion
|
||||
? before.insertMessage({
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Question',
|
||||
body: 'Continue after update?',
|
||||
type: 'decision_gate',
|
||||
payload: JSON.stringify({
|
||||
question: 'Continue after update?',
|
||||
options: ['yes', 'no']
|
||||
})
|
||||
})
|
||||
: undefined
|
||||
const cutoverAnswer =
|
||||
cutoverQuestion && options?.seedCutoverAnswer
|
||||
? before.insertMessage({
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'Re: Question',
|
||||
body: 'Continue.',
|
||||
threadId: cutoverQuestion.id
|
||||
})
|
||||
: undefined
|
||||
before.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
raw.exec(`
|
||||
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
databases.push(db)
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === WORKER_HANDLE
|
||||
? WORKER_PANE
|
||||
: handle === CURRENT_COORDINATOR_HANDLE
|
||||
? CURRENT_COORDINATOR_PANE
|
||||
: null
|
||||
)
|
||||
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation((evidence) => {
|
||||
const worker =
|
||||
evidence?.terminalHandle === WORKER_HANDLE &&
|
||||
evidence.paneKey === WORKER_PANE &&
|
||||
evidence.launchToken === 'worker-token'
|
||||
const coordinator =
|
||||
evidence?.terminalHandle === CURRENT_COORDINATOR_HANDLE &&
|
||||
evidence.paneKey === CURRENT_COORDINATOR_PANE &&
|
||||
evidence.launchToken === 'current-coordinator-token'
|
||||
const launchToken = worker
|
||||
? 'worker-token'
|
||||
: coordinator
|
||||
? 'current-coordinator-token'
|
||||
: undefined
|
||||
return launchToken
|
||||
? {
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
terminalHandle: evidence?.terminalHandle as string,
|
||||
paneKey: evidence?.paneKey as string,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256').update(launchToken).digest('hex')
|
||||
}
|
||||
: null
|
||||
})
|
||||
return {
|
||||
db,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
adoptedRunId: db.getLegacyAdoption()?.adopted_run_id as string,
|
||||
dispatchId: dispatch.id,
|
||||
...(cutoverQuestion ? { cutoverQuestionId: cutoverQuestion.id } : {}),
|
||||
...(cutoverAnswer ? { cutoverAnswerId: cutoverAnswer.id } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
invocationId: string,
|
||||
evidence?: OrchestrationCompatibilityEvidence
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: `rpc_${invocationId}`,
|
||||
authToken: 'caller-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: invocationId,
|
||||
compatibilityInvocationId: invocationId,
|
||||
orchestrationCompatibilityEvidence: evidence
|
||||
}
|
||||
}
|
||||
|
||||
const workerEvidence: OrchestrationCompatibilityEvidence = {
|
||||
terminalHandle: WORKER_HANDLE,
|
||||
paneKey: WORKER_PANE,
|
||||
launchToken: 'worker-token'
|
||||
}
|
||||
|
||||
describe('legacy question takeover compatibility', () => {
|
||||
it('reuses a pending ask that crossed the schema cutover', async () => {
|
||||
const harness = createHarness({ seedCutoverQuestion: true })
|
||||
|
||||
const asked = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Continue after update?',
|
||||
options: 'yes,no',
|
||||
timeoutMs: 0
|
||||
},
|
||||
'retry-cutover-ask',
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
|
||||
expect(asked).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messageId: harness.cutoverQuestionId,
|
||||
legacyCompatibility: { replayed: true }
|
||||
}
|
||||
})
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
expect(
|
||||
(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM messages
|
||||
WHERE type IN ('decision_gate', 'question')`
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
).toBe(1)
|
||||
expect(
|
||||
(sqlite.prepare('SELECT COUNT(*) AS count FROM question_threads').get() as { count: number })
|
||||
.count
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('resumes an answered pinned-A ask without duplicating it', async () => {
|
||||
const harness = createHarness({
|
||||
seedCutoverQuestion: true,
|
||||
seedCutoverAnswer: true
|
||||
})
|
||||
|
||||
const resumed = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
resume: harness.cutoverQuestionId,
|
||||
timeoutMs: 0
|
||||
},
|
||||
'resume-answered-cutover-ask',
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
|
||||
expect(resumed).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
answer: 'Continue.',
|
||||
answerMessageId: harness.cutoverAnswerId,
|
||||
messageId: harness.cutoverQuestionId,
|
||||
legacyCompatibility: {
|
||||
answerAcknowledgement: {
|
||||
questionId: harness.cutoverQuestionId,
|
||||
answerMessageId: harness.cutoverAnswerId
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
expect(
|
||||
(sqlite.prepare('SELECT COUNT(*) AS count FROM question_threads').get() as { count: number })
|
||||
.count
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('resumes and idempotently acknowledges the exact current Run answer', async () => {
|
||||
const harness = createHarness()
|
||||
const asked = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Continue after takeover?',
|
||||
timeoutMs: 0
|
||||
},
|
||||
'ask-before-takeover',
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
const questionId = (asked as { result: { messageId: string } }).result.messageId
|
||||
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{
|
||||
id: harness.adoptedRunId,
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
takeoverLegacy: true
|
||||
},
|
||||
'take-over',
|
||||
{
|
||||
terminalHandle: CURRENT_COORDINATOR_HANDLE,
|
||||
paneKey: CURRENT_COORDINATOR_PANE,
|
||||
launchToken: 'current-coordinator-token'
|
||||
}
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: questionId,
|
||||
body: 'Continue.',
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId
|
||||
},
|
||||
'answer-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
|
||||
const resumed = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{ from: WORKER_HANDLE, resume: questionId, timeoutMs: 100 },
|
||||
'resume-after-takeover',
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
expect(resumed).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
answer: 'Continue.',
|
||||
legacyCompatibility: {
|
||||
answerAcknowledgement: {
|
||||
questionId,
|
||||
answerMessageId: expect.any(String)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const acknowledgement = (
|
||||
resumed as {
|
||||
result: {
|
||||
legacyCompatibility: {
|
||||
answerAcknowledgement: { questionId: string; answerMessageId: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
).result.legacyCompatibility.answerAcknowledgement
|
||||
expect(harness.db.getMessageById(acknowledgement.answerMessageId)).toMatchObject({
|
||||
run_id: harness.adoptedRunId,
|
||||
from_handle: `run:${harness.adoptedRunId}`,
|
||||
to_handle: `dispatch:${harness.dispatchId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
thread_id: questionId
|
||||
})
|
||||
|
||||
const acknowledge = (invocationId: string) =>
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: WORKER_HANDLE,
|
||||
compatibilityQuestionAck: JSON.stringify(acknowledgement)
|
||||
},
|
||||
invocationId,
|
||||
workerEvidence
|
||||
)
|
||||
)
|
||||
const sqlite = (harness.db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run('dispatch:foreign', acknowledgement.answerMessageId)
|
||||
await expect(acknowledge('ack-wrong-answer-route')).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'request_mismatch' }
|
||||
})
|
||||
sqlite
|
||||
.prepare('UPDATE messages SET to_handle = ? WHERE id = ?')
|
||||
.run(`dispatch:${harness.dispatchId}`, acknowledgement.answerMessageId)
|
||||
|
||||
await expect(acknowledge('ack-answer')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
acknowledged: [acknowledgement.answerMessageId],
|
||||
duplicate: false,
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
})
|
||||
await expect(acknowledge('ack-answer-repeat')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
acknowledged: [acknowledgement.answerMessageId],
|
||||
duplicate: true,
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { clampOrchestrationAskTimeoutMs } from '../../../shared/orchestration-ask-timeout'
|
||||
import type { RpcRequest } from './core'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationError } from '../orchestration/orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../orchestration/db'
|
||||
import type { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
|
||||
import {
|
||||
normalizeLegacyText,
|
||||
operationIdentity,
|
||||
parseLegacyOptions,
|
||||
type LegacyAskParams
|
||||
} from './orchestration-legacy-operation'
|
||||
|
||||
export async function handleLegacyAsk(args: {
|
||||
runtime: OrcaRuntimeService
|
||||
authority: LegacyCompatibilityAuthority
|
||||
request: RpcRequest
|
||||
params: LegacyAskParams
|
||||
signal?: AbortSignal
|
||||
}): Promise<unknown | undefined> {
|
||||
const { runtime, authority, request, params, signal } = args
|
||||
const dispatch = authority.resolveAskDispatch(request, params)
|
||||
if (!dispatch || dispatch.contract_version !== LEGACY_CONTRACT_VERSION) {
|
||||
return undefined
|
||||
}
|
||||
const db = runtime.getOrchestrationDb()
|
||||
if (!params.resume) {
|
||||
if (!params.to) {
|
||||
throw new OrchestrationError('invalid_argument', 'Legacy ask requires --to.')
|
||||
}
|
||||
if (!db.isLegacyCoordinatorHandle(dispatch.run_id, params.to)) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Terminal ${params.to} is not a retained coordinator for this legacy Dispatch.`
|
||||
)
|
||||
}
|
||||
}
|
||||
const principal = authority.attestWorker(request, dispatch)
|
||||
const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs)
|
||||
let questionId = params.resume
|
||||
let duplicate = true
|
||||
if (questionId) {
|
||||
const question = db.getQuestion(questionId)
|
||||
if (!question || question.dispatch_id !== dispatch.id) {
|
||||
throw new OrchestrationError(
|
||||
'question_not_found',
|
||||
`Question ${questionId} does not belong to this legacy Dispatch.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const question = params.question as string
|
||||
const options = parseLegacyOptions(params.options)
|
||||
const recipient = params.to as string
|
||||
const operation = operationIdentity(request, 'ask', {
|
||||
question: normalizeLegacyText(question),
|
||||
options: options.map(normalizeLegacyText),
|
||||
recipient
|
||||
})
|
||||
const priorReceipt = db.getLegacyOperationReceipt(principal.id, operation.key)
|
||||
let existingQuestionId: string | undefined
|
||||
if (!priorReceipt) {
|
||||
const matches = db.findLegacyQuestionsBySemanticIdentity({
|
||||
principalId: principal.id,
|
||||
question,
|
||||
options,
|
||||
recipientHandle: recipient
|
||||
})
|
||||
const unclaimed = matches.filter((match) => !match.claimedByOperation)
|
||||
const pending = unclaimed.filter((match) => match.question.status === 'pending')
|
||||
const lostAnswer = unclaimed.find(
|
||||
(match) => match.question.status === 'answered' && !match.answerAcknowledged
|
||||
)
|
||||
if (lostAnswer) {
|
||||
const cliCommand =
|
||||
params.compatibilityCliCommand ?? params.compatibilityWindowsCommand ?? 'orca'
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`A matching legacy answer may have been accepted before the update. Run ${cliCommand} orchestration check --terminal ${principal.terminal_handle} before asking again.`
|
||||
)
|
||||
}
|
||||
if (pending.length > 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Multiple matching pending legacy questions exist; drain legacy check before retrying.'
|
||||
)
|
||||
}
|
||||
existingQuestionId = pending[0]?.question.message_id
|
||||
}
|
||||
const committed = db.commitLegacyAskOperation({
|
||||
question,
|
||||
principalId: principal.id,
|
||||
operationKey: operation.key,
|
||||
method: request.method,
|
||||
payloadHash: operation.payloadHash,
|
||||
options,
|
||||
recipientHandle: recipient,
|
||||
existingQuestionId
|
||||
})
|
||||
questionId = committed.question.message_id
|
||||
duplicate = committed.duplicate || Boolean(existingQuestionId)
|
||||
if (!duplicate) {
|
||||
runtime.notifyMessageArrived(committed.message.to_handle, committed.message.type)
|
||||
}
|
||||
}
|
||||
|
||||
if (params.compatibilityWindowsCommand && !params.resume) {
|
||||
return {
|
||||
answer: null,
|
||||
messageId: questionId,
|
||||
threadId: questionId,
|
||||
timedOut: false,
|
||||
cancelled: false,
|
||||
connectionLost: false,
|
||||
timeoutMs,
|
||||
legacyCompatibility: {
|
||||
resumeRequired: true,
|
||||
resumeCommand: `${params.compatibilityWindowsCommand} orchestration ask --resume ${questionId}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
const current = db.getQuestion(questionId as string)
|
||||
if (!current || current.status === 'closed') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Question ${questionId as string} closed because its Dispatch is inactive.`
|
||||
)
|
||||
}
|
||||
if (current.status === 'answered') {
|
||||
return {
|
||||
answer: current.answer_body,
|
||||
answerMessageId: current.answer_message_id,
|
||||
messageId: current.message_id,
|
||||
threadId: current.message_id,
|
||||
timedOut: false,
|
||||
cancelled: false,
|
||||
connectionLost: false,
|
||||
timeoutMs,
|
||||
legacyCompatibility: {
|
||||
replayed: duplicate,
|
||||
answerAcknowledgement: current.answer_message_id
|
||||
? {
|
||||
questionId: current.message_id,
|
||||
answerMessageId: current.answer_message_id
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
if (signal?.aborted || Date.now() >= deadline) {
|
||||
return {
|
||||
answer: null,
|
||||
messageId: questionId,
|
||||
threadId: questionId,
|
||||
timedOut: !signal?.aborted,
|
||||
cancelled: signal?.aborted === true,
|
||||
connectionLost: signal?.aborted === true,
|
||||
timeoutMs,
|
||||
legacyCompatibility: { replayed: duplicate, ackMessageIds: [] }
|
||||
}
|
||||
}
|
||||
await runtime.waitForMessage(principal.terminal_handle, {
|
||||
timeoutMs: Math.min(1_000, Math.max(deadline - Date.now(), 1)),
|
||||
signal
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { OrchestrationDb } from '../orchestration/db'
|
||||
import type { RpcRequest } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './methods/orchestration'
|
||||
|
||||
const WORKER_HANDLE = 'term_legacy_worker'
|
||||
const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
const COORDINATOR_HANDLE = 'term_legacy_coord'
|
||||
const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
|
||||
const CURRENT_COORDINATOR_HANDLE = 'term_current_coord'
|
||||
const CURRENT_COORDINATOR_PANE = 'tab_current:55555555-5555-4555-8555-555555555555'
|
||||
|
||||
type Harness = {
|
||||
db: OrchestrationDb
|
||||
dispatcher: RpcDispatcher
|
||||
adoptedRunId: string
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
}
|
||||
|
||||
const tempDirs: string[] = []
|
||||
const databases: OrchestrationDb[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const database of databases.splice(0)) {
|
||||
database.close()
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function createHarness(): Harness {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-takeover-'))
|
||||
tempDirs.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const before = new OrchestrationDb(dbPath)
|
||||
const task = before.createTask({
|
||||
spec: 'legacy assignment',
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = before.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
before.close()
|
||||
|
||||
const raw = new Database(dbPath)
|
||||
raw.exec(`
|
||||
UPDATE dispatch_contexts SET process_incarnation = 'process-1';
|
||||
DROP INDEX IF EXISTS idx_messages_delivery_contract;
|
||||
DROP TABLE legacy_mail_receipts;
|
||||
DROP TABLE legacy_operation_receipts;
|
||||
DROP TABLE legacy_compatibility_principals;
|
||||
DROP TABLE legacy_adoptions;
|
||||
`)
|
||||
raw.pragma('user_version = 18')
|
||||
raw.close()
|
||||
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
databases.push(db)
|
||||
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === COORDINATOR_HANDLE
|
||||
? COORDINATOR_PANE
|
||||
: handle === WORKER_HANDLE
|
||||
? WORKER_PANE
|
||||
: handle === CURRENT_COORDINATOR_HANDLE
|
||||
? CURRENT_COORDINATOR_PANE
|
||||
: null
|
||||
)
|
||||
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation((proof) => {
|
||||
const validWorker = proof?.terminalHandle === WORKER_HANDLE && proof.paneKey === WORKER_PANE
|
||||
const validCoordinator =
|
||||
proof?.terminalHandle === COORDINATOR_HANDLE && proof.paneKey === COORDINATOR_PANE
|
||||
const validCurrentCoordinator =
|
||||
proof?.terminalHandle === CURRENT_COORDINATOR_HANDLE &&
|
||||
proof.paneKey === CURRENT_COORDINATOR_PANE
|
||||
if ((!validWorker && !validCoordinator && !validCurrentCoordinator) || !proof?.launchToken) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
hostScope: { kind: 'local', hostId: 'local' },
|
||||
terminalHandle: proof.terminalHandle as string,
|
||||
paneKey: proof.paneKey as string,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256').update(proof.launchToken).digest('hex')
|
||||
}
|
||||
})
|
||||
vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
return {
|
||||
db,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
adoptedRunId,
|
||||
taskId: task.id,
|
||||
dispatchId: dispatch.id
|
||||
}
|
||||
}
|
||||
|
||||
function evidence(
|
||||
role: 'worker' | 'coordinator' | 'current-coordinator'
|
||||
): OrchestrationCompatibilityEvidence {
|
||||
const worker = role === 'worker'
|
||||
const currentCoordinator = role === 'current-coordinator'
|
||||
return {
|
||||
terminalHandle: worker
|
||||
? WORKER_HANDLE
|
||||
: currentCoordinator
|
||||
? CURRENT_COORDINATOR_HANDLE
|
||||
: COORDINATOR_HANDLE,
|
||||
paneKey: worker
|
||||
? WORKER_PANE
|
||||
: currentCoordinator
|
||||
? CURRENT_COORDINATOR_PANE
|
||||
: COORDINATOR_PANE,
|
||||
launchToken: `${role}-token`
|
||||
}
|
||||
}
|
||||
|
||||
function request(
|
||||
method: string,
|
||||
params: unknown,
|
||||
proof: OrchestrationCompatibilityEvidence,
|
||||
invocationId: string
|
||||
): RpcRequest {
|
||||
return {
|
||||
id: `rpc_${invocationId}`,
|
||||
authToken: 'caller-token',
|
||||
method,
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: invocationId,
|
||||
compatibilityInvocationId: invocationId,
|
||||
orchestrationCompatibilityEvidence: proof
|
||||
}
|
||||
}
|
||||
|
||||
function counts(db: OrchestrationDb): Record<string, number> {
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
return Object.fromEntries(
|
||||
[
|
||||
'messages',
|
||||
'legacy_compatibility_principals',
|
||||
'legacy_operation_receipts',
|
||||
'legacy_mail_receipts',
|
||||
'mutation_receipts'
|
||||
].map((table) => [
|
||||
table,
|
||||
(sqlite.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function escalationParams(harness: Harness) {
|
||||
return {
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Blocked',
|
||||
type: 'escalation',
|
||||
payload: JSON.stringify({ taskId: harness.taskId, dispatchId: harness.dispatchId })
|
||||
}
|
||||
}
|
||||
|
||||
describe('legacy compatibility after explicit takeover', () => {
|
||||
it('binds takeover to the authenticated invoking coordinator pane', async () => {
|
||||
const harness = createHarness()
|
||||
const before = harness.db.getRun(harness.adoptedRunId)
|
||||
|
||||
const spoofed = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{
|
||||
id: harness.adoptedRunId,
|
||||
from: COORDINATOR_HANDLE,
|
||||
takeoverLegacy: true
|
||||
},
|
||||
evidence('current-coordinator'),
|
||||
'spoofed-takeover'
|
||||
)
|
||||
)
|
||||
|
||||
expect(spoofed).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'legacy_read_only', data: { effectsApplied: false } }
|
||||
})
|
||||
expect(harness.db.getRun(harness.adoptedRunId)).toEqual(before)
|
||||
|
||||
const bound = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{
|
||||
id: harness.adoptedRunId,
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
takeoverLegacy: true
|
||||
},
|
||||
evidence('current-coordinator'),
|
||||
'authenticated-takeover'
|
||||
)
|
||||
)
|
||||
|
||||
expect(bound).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
run: {
|
||||
coordinator_handle: CURRENT_COORDINATOR_HANDLE,
|
||||
coordinator_pane_key: CURRENT_COORDINATOR_PANE
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let an uncommitted legacy coordinator attest after explicit takeover', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)).toBeUndefined()
|
||||
const before = counts(harness.db)
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskList',
|
||||
{},
|
||||
evidence('coordinator'),
|
||||
'uncommitted-coordinator-after-takeover'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)).toBeUndefined()
|
||||
expect(counts(harness.db)).toEqual(before)
|
||||
})
|
||||
|
||||
it.each(['escalation', 'worker_done'] as const)(
|
||||
'keeps the retained coordinator address routable for post-takeover %s',
|
||||
async (type) => {
|
||||
const harness = createHarness()
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
...escalationParams(harness),
|
||||
type,
|
||||
subject: type === 'worker_done' ? 'Completed' : 'Blocked'
|
||||
},
|
||||
evidence('worker'),
|
||||
`post-takeover-${type}`
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
type
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('routes future worker mail to Run Delivery after same-handle takeover', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: COORDINATOR_HANDLE,
|
||||
coordinatorPaneKey: COORDINATOR_PANE,
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
escalationParams(harness),
|
||||
evidence('worker'),
|
||||
'same-handle-takeover-mail'
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
type: 'escalation'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps post-takeover legacy ask routed to the adopted Run', async () => {
|
||||
const harness = createHarness()
|
||||
harness.db.bindRun({
|
||||
runId: harness.adoptedRunId,
|
||||
coordinatorHandle: 'term_current_coord',
|
||||
coordinatorPaneKey: 'tab_current:55555555-5555-4555-8555-555555555555',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
|
||||
const response = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Continue after takeover?',
|
||||
timeoutMs: 1
|
||||
},
|
||||
evidence('worker'),
|
||||
'post-takeover-ask'
|
||||
)
|
||||
)
|
||||
const messageId = (response as { result?: { messageId?: string } }).result?.messageId
|
||||
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: { timedOut: true, messageId: expect.any(String) }
|
||||
})
|
||||
expect(harness.db.getMessageById(messageId as string)).toMatchObject({
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery',
|
||||
type: 'question'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an active legacy worker usable across authenticated takeover', async () => {
|
||||
const harness = createHarness()
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.runUse',
|
||||
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
'bind-original-coordinator'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
const pendingAsk = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
question: 'Continue with the migration?',
|
||||
timeoutMs: 0
|
||||
},
|
||||
evidence('worker'),
|
||||
'pending-before-takeover'
|
||||
)
|
||||
)
|
||||
const questionId = (pendingAsk as { result: { messageId: string } }).result.messageId
|
||||
const unrelated = harness.db.insertMessage({
|
||||
runId: harness.adoptedRunId,
|
||||
from: WORKER_HANDLE,
|
||||
to: 'term_peer_worker',
|
||||
subject: 'peer-only',
|
||||
deliveryContract: 'legacy_direct'
|
||||
})
|
||||
const currentRequest = (method: string, params: unknown, invocationId: string) =>
|
||||
request(method, params, evidence('current-coordinator'), invocationId)
|
||||
const takeoverParams = {
|
||||
id: harness.adoptedRunId,
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
takeoverLegacy: true
|
||||
}
|
||||
|
||||
const takeover = await harness.dispatcher.dispatch(
|
||||
currentRequest('orchestration.runUse', takeoverParams, 'explicit-takeover')
|
||||
)
|
||||
const repeated = await harness.dispatcher.dispatch(
|
||||
currentRequest('orchestration.runUse', takeoverParams, 'explicit-takeover-repeat')
|
||||
)
|
||||
|
||||
expect(takeover).toMatchObject({
|
||||
ok: true,
|
||||
result: { binding: { consumerGeneration: 2 } }
|
||||
})
|
||||
expect(repeated).toMatchObject({
|
||||
ok: true,
|
||||
result: { binding: { consumerGeneration: 2 } }
|
||||
})
|
||||
expect(harness.db.getDispatchContextById(harness.dispatchId)?.status).toBe('dispatched')
|
||||
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('revoked')
|
||||
expect(harness.db.getMessageById(unrelated.id)).toMatchObject({
|
||||
to_handle: 'term_peer_worker',
|
||||
delivery_contract: 'legacy_direct'
|
||||
})
|
||||
|
||||
const promoted = await harness.dispatcher.dispatch(
|
||||
currentRequest(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: CURRENT_COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId,
|
||||
format: true
|
||||
},
|
||||
'read-promoted-question'
|
||||
)
|
||||
)
|
||||
expect(promoted).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [
|
||||
{
|
||||
id: questionId,
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery'
|
||||
}
|
||||
],
|
||||
formatted: expect.not.stringContaining(`--from run:${harness.adoptedRunId}`)
|
||||
}
|
||||
})
|
||||
const deliveryId = (promoted as { result: { deliveryId: string } }).result.deliveryId
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
currentRequest(
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: questionId,
|
||||
body: 'Yes, continue.',
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId
|
||||
},
|
||||
'reply-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true, result: { question: { status: 'answered' } } })
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.ask',
|
||||
{ from: WORKER_HANDLE, resume: questionId, timeoutMs: 100 },
|
||||
evidence('worker'),
|
||||
'resume-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true, result: { answer: 'Yes, continue.' } })
|
||||
|
||||
const followUp = await harness.dispatcher.dispatch(
|
||||
currentRequest(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: CURRENT_COORDINATOR_HANDLE,
|
||||
to: `dispatch:${harness.dispatchId}`,
|
||||
subject: 'Continue from the new coordinator'
|
||||
},
|
||||
'current-coordinator-to-legacy-worker'
|
||||
)
|
||||
)
|
||||
expect(followUp).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
run_id: harness.adoptedRunId,
|
||||
to_handle: `dispatch:${harness.dispatchId}`,
|
||||
delivery_contract: 'legacy_direct'
|
||||
}
|
||||
}
|
||||
})
|
||||
const workerFollowUp = await harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{ terminal: WORKER_HANDLE },
|
||||
evidence('worker'),
|
||||
'legacy-worker-check-after-takeover'
|
||||
)
|
||||
)
|
||||
expect(workerFollowUp).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: [{ subject: 'Continue from the new coordinator' }],
|
||||
legacyCompatibility: { ackMessageIds: [expect.any(String)] }
|
||||
}
|
||||
})
|
||||
const [followUpMessageId] = (
|
||||
workerFollowUp as { result: { legacyCompatibility: { ackMessageIds: string[] } } }
|
||||
).result.legacyCompatibility.ackMessageIds
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: WORKER_HANDLE,
|
||||
compatibilityAck: JSON.stringify({ messageIds: [followUpMessageId] })
|
||||
},
|
||||
evidence('worker'),
|
||||
'legacy-worker-ack-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
acknowledged: [followUpMessageId],
|
||||
legacyCompatibility: { acknowledged: true }
|
||||
}
|
||||
})
|
||||
|
||||
await harness.dispatcher.dispatch(
|
||||
currentRequest(
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: CURRENT_COORDINATOR_HANDLE,
|
||||
run: harness.adoptedRunId,
|
||||
ack: deliveryId
|
||||
},
|
||||
'ack-promoted-question'
|
||||
)
|
||||
)
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: WORKER_HANDLE,
|
||||
to: COORDINATOR_HANDLE,
|
||||
subject: 'Need current coordinator',
|
||||
type: 'escalation',
|
||||
payload: JSON.stringify({ taskId: harness.taskId })
|
||||
},
|
||||
evidence('worker'),
|
||||
'escalation-after-takeover'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
message: {
|
||||
to_handle: `run:${harness.adoptedRunId}`,
|
||||
delivery_contract: 'current_delivery'
|
||||
}
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
harness.dispatcher.dispatch(
|
||||
request(
|
||||
'orchestration.taskList',
|
||||
{ callerTerminalHandle: COORDINATOR_HANDLE },
|
||||
evidence('coordinator'),
|
||||
'old-coordinator-fenced'
|
||||
)
|
||||
)
|
||||
).resolves.toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
|
||||
})
|
||||
})
|
||||
@@ -22,13 +22,14 @@ export class OrchestrationMutationExecutor {
|
||||
async run(
|
||||
request: RpcRequest,
|
||||
params: unknown,
|
||||
invoke: (mutation?: DurableMutationInvocation) => Promise<unknown> | unknown
|
||||
invoke: (mutation?: DurableMutationInvocation) => Promise<unknown> | unknown,
|
||||
callerFingerprintOverride?: string
|
||||
): Promise<unknown> {
|
||||
const requestId = request.orchestrationRequestId
|
||||
if (!requestId || !isOrchestrationMutation(request.method, params)) {
|
||||
return await invoke()
|
||||
}
|
||||
const callerFingerprint = authenticatedCallerFingerprint(request)
|
||||
const callerFingerprint = callerFingerprintOverride ?? authenticatedCallerFingerprint(request)
|
||||
const payloadHash = createHash('sha256')
|
||||
.update(JSON.stringify(canonicalize({ method: request.method, params })))
|
||||
.digest('hex')
|
||||
@@ -55,6 +56,10 @@ export class OrchestrationMutationExecutor {
|
||||
: db.beginMutationReceipt(identity)
|
||||
|
||||
if (begun.disposition === 'completed') {
|
||||
const active = this.inFlight.get(key)
|
||||
if (active) {
|
||||
return attachMutationReceipt(await active, requestId, true)
|
||||
}
|
||||
return attachMutationReceipt(JSON.parse(begun.row.receipt ?? 'null'), requestId, true)
|
||||
}
|
||||
if (begun.disposition === 'pending') {
|
||||
@@ -102,6 +107,20 @@ export class OrchestrationMutationExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
const executorsByRuntime = new WeakMap<OrcaRuntimeService, OrchestrationMutationExecutor>()
|
||||
|
||||
export function getOrchestrationMutationExecutor(
|
||||
runtime: OrcaRuntimeService
|
||||
): OrchestrationMutationExecutor {
|
||||
const existing = executorsByRuntime.get(runtime)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const executor = new OrchestrationMutationExecutor(runtime)
|
||||
executorsByRuntime.set(runtime, executor)
|
||||
return executor
|
||||
}
|
||||
|
||||
export function authenticatedCallerFingerprint(request: RpcRequest): string {
|
||||
const callerToken =
|
||||
request.authToken ||
|
||||
|
||||
@@ -252,7 +252,10 @@ describe('durable orchestration mutation ledger', () => {
|
||||
})
|
||||
|
||||
it('recovers a lost ask acceptance without creating a second question', async () => {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-mutation-ask-recovery-'))
|
||||
paths.push(dir)
|
||||
const dbPath = join(dir, 'orchestration.db')
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker')
|
||||
@@ -290,7 +293,16 @@ describe('durable orchestration mutation ledger', () => {
|
||||
const first = firstDispatcher.dispatch(askRequest, { signal: controller.signal })
|
||||
await vi.waitFor(() => expect(db.getInbox(10)).toHaveLength(1))
|
||||
|
||||
const restartedDispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
|
||||
const restartedDb = new OrchestrationDb(dbPath)
|
||||
const restartedRuntime = new OrcaRuntimeService()
|
||||
restartedRuntime.setOrchestrationDb(restartedDb)
|
||||
vi.spyOn(restartedRuntime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker')
|
||||
vi.spyOn(restartedRuntime, 'getTerminalProcessIncarnation').mockReturnValue('runtime:pty:1')
|
||||
vi.spyOn(restartedRuntime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
const restartedDispatcher = new RpcDispatcher({
|
||||
runtime: restartedRuntime,
|
||||
methods: ORCHESTRATION_METHODS
|
||||
})
|
||||
const recovered = await restartedDispatcher.dispatch({ ...askRequest, id: 'rpc_ask_2' })
|
||||
expect(recovered).toMatchObject({
|
||||
ok: true,
|
||||
@@ -304,6 +316,7 @@ describe('durable orchestration mutation ledger', () => {
|
||||
|
||||
controller.abort()
|
||||
await first
|
||||
restartedDb.close()
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
|
||||
import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
|
||||
export function getRuntimeFeatureInteractionId(
|
||||
method: string,
|
||||
@@ -37,6 +38,25 @@ export function getRuntimeFeatureInteractionId(
|
||||
return method.startsWith('orchestration.') ? 'agent-orchestration' : null
|
||||
}
|
||||
|
||||
export function recordRuntimeFeatureInteraction(
|
||||
runtime: OrcaRuntimeService,
|
||||
method: string,
|
||||
result: unknown,
|
||||
alreadyRecorded?: Set<FeatureInteractionId>,
|
||||
rawParams?: unknown
|
||||
): void {
|
||||
const id = getRuntimeFeatureInteractionId(method, result, rawParams)
|
||||
if (!id || alreadyRecorded?.has(id)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
runtime.recordFeatureInteraction(id)
|
||||
alreadyRecorded?.add(id)
|
||||
} catch {
|
||||
// Best-effort education state must not break runtime tools.
|
||||
}
|
||||
}
|
||||
|
||||
function hasBooleanResult(value: unknown, key: string): boolean {
|
||||
return (
|
||||
value !== null && typeof value === 'object' && (value as Record<string, unknown>)[key] === true
|
||||
|
||||
@@ -1,20 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type * as NodeCrypto from 'node:crypto'
|
||||
import { SshRelaySession } from './ssh-relay-session'
|
||||
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
|
||||
|
||||
const { muxRequestMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn() }))
|
||||
type MockMuxInstance = {
|
||||
requestHandlers: Map<string, (params: Record<string, unknown>) => Promise<unknown>>
|
||||
}
|
||||
|
||||
const { muxRequestMock, muxInstancesRaw } = vi.hoisted(() => ({
|
||||
muxRequestMock: vi.fn(),
|
||||
muxInstancesRaw: [] as unknown[]
|
||||
}))
|
||||
const muxInstances = muxInstancesRaw as MockMuxInstance[]
|
||||
|
||||
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') }))
|
||||
vi.mock('node:crypto', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeCrypto>()
|
||||
return { ...actual, randomUUID: vi.fn() }
|
||||
})
|
||||
vi.mock('./ssh-remote-orca-cli', () => ({
|
||||
runRemoteOrcaCli: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' })
|
||||
}))
|
||||
vi.mock('./ssh-channel-multiplexer', () => ({
|
||||
SshChannelMultiplexer: class MockSshChannelMultiplexer {
|
||||
requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
|
||||
notify = vi.fn()
|
||||
request = muxRequestMock
|
||||
onNotification = vi.fn().mockReturnValue(() => {})
|
||||
onRequest = vi.fn().mockReturnValue(() => {})
|
||||
onRequest = vi.fn(
|
||||
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
|
||||
this.requestHandlers.set(method, handler)
|
||||
return () => this.requestHandlers.delete(method)
|
||||
}
|
||||
)
|
||||
onDispose = vi.fn().mockReturnValue(() => {})
|
||||
dispose = vi.fn()
|
||||
isDisposed = vi.fn().mockReturnValue(false)
|
||||
|
||||
constructor() {
|
||||
muxInstancesRaw.push(this)
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({
|
||||
@@ -98,13 +125,74 @@ function emitExitDuringAttach(payload: { id: string; code: number; incarnationId
|
||||
describe('SshRelaySession reconnect incarnation ordering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
muxInstances.splice(0)
|
||||
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
|
||||
muxRequestMock.mockReset()
|
||||
muxRequestMock.mockResolvedValue([])
|
||||
vi.mocked(randomUUID).mockReset()
|
||||
vi.mocked(randomUUID).mockReturnValue('00000000-0000-4000-8000-000000000001')
|
||||
mockDeploySuccess()
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
})
|
||||
|
||||
it('keeps the winning reconnect incarnation when a stale health check resolves last', async () => {
|
||||
const initialIncarnation = '00000000-0000-4000-8000-000000000001'
|
||||
const winningIncarnation = '00000000-0000-4000-8000-000000000002'
|
||||
const staleIncarnation = '00000000-0000-4000-8000-000000000003'
|
||||
let resolveStaleHealthCheck!: (value: unknown) => void
|
||||
const staleHealthCheck = new Promise((resolve) => {
|
||||
resolveStaleHealthCheck = resolve
|
||||
})
|
||||
let resolveHomeCalls = 0
|
||||
muxRequestMock.mockImplementation((method: string) => {
|
||||
if (method !== 'session.resolveHome') {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
resolveHomeCalls += 1
|
||||
return resolveHomeCalls === 2 ? staleHealthCheck : Promise.resolve('/')
|
||||
})
|
||||
vi.mocked(randomUUID)
|
||||
.mockReturnValueOnce(initialIncarnation)
|
||||
.mockReturnValueOnce(winningIncarnation)
|
||||
.mockReturnValue(staleIncarnation)
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const runtime = {
|
||||
registerOrchestrationCompatibilitySshAttachment: vi.fn(
|
||||
(_targetId: string, connectionIncarnation: string) => ({
|
||||
attachmentId: `attachment-${connectionIncarnation}`,
|
||||
connectionIncarnation
|
||||
})
|
||||
),
|
||||
releaseOrchestrationCompatibilitySshAttachment: vi.fn()
|
||||
}
|
||||
const session = new SshRelaySession(
|
||||
'target-1',
|
||||
getMainWindow,
|
||||
mockStore,
|
||||
mockPortForward,
|
||||
runtime as never
|
||||
)
|
||||
await session.establish(mockConn)
|
||||
|
||||
const staleReconnect = session.reconnect(mockConn)
|
||||
await vi.waitFor(() => expect(resolveHomeCalls).toBe(2))
|
||||
await session.reconnect(mockConn)
|
||||
expect(session.getState()).toBe('ready')
|
||||
|
||||
resolveStaleHealthCheck('/')
|
||||
await staleReconnect
|
||||
|
||||
const winningCliHandler = muxInstances[2]?.requestHandlers.get('orca.cli')
|
||||
expect(winningCliHandler).toBeDefined()
|
||||
await winningCliHandler?.({ argv: ['status'], cwd: '/', env: {} })
|
||||
|
||||
expect(runtime.registerOrchestrationCompatibilitySshAttachment).toHaveBeenCalledWith(
|
||||
'target-1',
|
||||
winningIncarnation
|
||||
)
|
||||
expect(randomUUID).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('restores and persists exact incarnation proof from reconnect attach', async () => {
|
||||
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
|
||||
const incarnationId = 'incarnation-reconnect'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Why: single authority for all relay lifecycle state per SSH target (previously scattered across module Maps/Sets with duplicated paths).
|
||||
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { deployAndLaunchRelay } from './ssh-relay-deploy'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
|
||||
@@ -65,6 +66,10 @@ import {
|
||||
import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
import {
|
||||
acknowledgeRemoteOrcaCliPostOutput,
|
||||
parseRemoteOrcaCliPostOutput
|
||||
} from './ssh-remote-orchestration-post-output'
|
||||
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
@@ -151,6 +156,7 @@ export class SshRelaySession {
|
||||
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
|
||||
private forwardedReattachReplayByPty = new Map<string, ForwardedReplayFingerprint>()
|
||||
private pendingPtyReattaches = new Map<string, PendingPtyReattach>()
|
||||
private activeCompatibilityAttachmentIds = new Set<string>()
|
||||
|
||||
constructor(
|
||||
readonly targetId: string,
|
||||
@@ -278,8 +284,15 @@ export class SshRelaySession {
|
||||
|
||||
// Why: round-trip the relay before registering providers so a closed --connect bridge fails fast instead of leaving a 'ready' session on a dead mux.
|
||||
await mux.request('session.resolveHome', { path: '~' })
|
||||
if (!ownsAttempt()) {
|
||||
if (!mux.isDisposed()) {
|
||||
mux.dispose()
|
||||
}
|
||||
throw new Error('Session disposed during establish')
|
||||
}
|
||||
const connectionIncarnation = randomUUID()
|
||||
|
||||
const registered = await this.registerProviders(mux, ownsAttempt)
|
||||
const registered = await this.registerProviders(mux, ownsAttempt, connectionIncarnation)
|
||||
if (!registered) {
|
||||
if (!mux.isDisposed()) {
|
||||
mux.dispose()
|
||||
@@ -387,8 +400,9 @@ export class SshRelaySession {
|
||||
}
|
||||
return
|
||||
}
|
||||
const connectionIncarnation = randomUUID()
|
||||
|
||||
const registered = await this.registerProviders(mux, ownsAttempt)
|
||||
const registered = await this.registerProviders(mux, ownsAttempt, connectionIncarnation)
|
||||
if (!registered) {
|
||||
if (!mux.isDisposed()) {
|
||||
mux.dispose()
|
||||
@@ -499,7 +513,8 @@ export class SshRelaySession {
|
||||
// Why: shared by establish() and reconnect() so both use the exact same registration sequence.
|
||||
private async registerProviders(
|
||||
mux: SshChannelMultiplexer,
|
||||
shouldContinue?: () => boolean
|
||||
shouldContinue: (() => boolean) | undefined,
|
||||
connectionIncarnation: string
|
||||
): Promise<boolean> {
|
||||
await this.registerRelayRoots(mux)
|
||||
if (shouldContinue && !shouldContinue()) {
|
||||
@@ -530,7 +545,7 @@ export class SshRelaySession {
|
||||
return false
|
||||
}
|
||||
|
||||
this.wireUpRemoteOrcaCli(mux)
|
||||
this.wireUpRemoteOrcaCli(mux, connectionIncarnation)
|
||||
|
||||
const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined)
|
||||
registerSshPtyProvider(this.targetId, ptyProvider)
|
||||
@@ -667,7 +682,7 @@ export class SshRelaySession {
|
||||
}
|
||||
}
|
||||
|
||||
private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer): void {
|
||||
private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer, connectionIncarnation: string): void {
|
||||
mux.onRequest('orca.cli', async (params) => {
|
||||
if (!this.runtime) {
|
||||
throw new Error('Orca runtime is unavailable')
|
||||
@@ -687,12 +702,54 @@ export class SshRelaySession {
|
||||
)
|
||||
: {}
|
||||
const stdin = typeof params.stdin === 'string' ? params.stdin : undefined
|
||||
return await runRemoteOrcaCli(this.runtime, {
|
||||
argv,
|
||||
cwd,
|
||||
env,
|
||||
...(stdin !== undefined ? { stdin } : {})
|
||||
})
|
||||
const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment(
|
||||
this.targetId,
|
||||
connectionIncarnation
|
||||
)
|
||||
this.activeCompatibilityAttachmentIds.add(runtimeAuthority.attachmentId)
|
||||
try {
|
||||
return await runRemoteOrcaCli(this.runtime, {
|
||||
argv,
|
||||
cwd,
|
||||
env,
|
||||
...(stdin !== undefined ? { stdin } : {}),
|
||||
runtimeAuthority
|
||||
})
|
||||
} finally {
|
||||
this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId)
|
||||
this.runtime.releaseOrchestrationCompatibilitySshAttachment(runtimeAuthority.attachmentId)
|
||||
}
|
||||
})
|
||||
mux.onRequest('orca.cli.postOutput', async (params) => {
|
||||
if (!this.runtime) {
|
||||
throw new Error('Orca runtime is unavailable')
|
||||
}
|
||||
const rawEnv = params.env
|
||||
const env =
|
||||
rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv)
|
||||
? Object.fromEntries(
|
||||
Object.entries(rawEnv).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[0] === 'string' && typeof entry[1] === 'string'
|
||||
)
|
||||
)
|
||||
: {}
|
||||
const runtimeAuthority = this.runtime.registerOrchestrationCompatibilitySshAttachment(
|
||||
this.targetId,
|
||||
connectionIncarnation
|
||||
)
|
||||
this.activeCompatibilityAttachmentIds.add(runtimeAuthority.attachmentId)
|
||||
try {
|
||||
await acknowledgeRemoteOrcaCliPostOutput(this.runtime, {
|
||||
postOutput: parseRemoteOrcaCliPostOutput(params.postOutput),
|
||||
env,
|
||||
runtimeAuthority
|
||||
})
|
||||
return { acknowledged: true }
|
||||
} finally {
|
||||
this.activeCompatibilityAttachmentIds.delete(runtimeAuthority.attachmentId)
|
||||
this.runtime.releaseOrchestrationCompatibilitySshAttachment(runtimeAuthority.attachmentId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -826,6 +883,10 @@ export class SshRelaySession {
|
||||
this.mux.dispose(reason)
|
||||
}
|
||||
this.mux = null
|
||||
for (const attachmentId of this.activeCompatibilityAttachmentIds) {
|
||||
this.runtime?.releaseOrchestrationCompatibilitySshAttachment(attachmentId)
|
||||
}
|
||||
this.activeCompatibilityAttachmentIds.clear()
|
||||
|
||||
if (reason === 'shutdown') {
|
||||
clearPtyOwnershipForConnection(this.targetId)
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
import { resolveOrchestrationAskClientTimeoutMs } from '../../shared/orchestration-ask-timeout'
|
||||
import { remoteCliRequestTimeoutMs } from '../../relay/remote-cli-timeout'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
import {
|
||||
ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
|
||||
} from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
type FakeChild = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
@@ -65,7 +71,12 @@ describe('buildHostCliEnv', () => {
|
||||
ORCA_TERMINAL_HANDLE: 'term_remote',
|
||||
ORCA_WORKTREE_ID: 'repo::/home/alice/wt',
|
||||
ORCA_PANE_KEY: 'pane-9',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'launch-secret',
|
||||
ORCA_WORKSPACE_ID: 'ws-1',
|
||||
[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]: 'wsl',
|
||||
[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]: 'caller-host',
|
||||
[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]: 'caller-incarnation',
|
||||
[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]: 'caller-attachment',
|
||||
// Why: these are remote-machine paths and must not leak into the host
|
||||
// subprocess (PATH would break host binary lookup; user-data would
|
||||
// retarget the CLI at a different local instance).
|
||||
@@ -73,13 +84,24 @@ describe('buildHostCliEnv', () => {
|
||||
ORCA_USER_DATA_PATH: '/remote/user-data'
|
||||
},
|
||||
userDataPath: '/host/user-data',
|
||||
remoteCwd: '/home/alice/wt/sub'
|
||||
remoteCwd: '/home/alice/wt/sub',
|
||||
runtimeAuthority: {
|
||||
kind: 'ssh',
|
||||
targetId: 'saved-target',
|
||||
connectionIncarnation: 'connection-incarnation',
|
||||
attachmentId: 'runtime-attachment'
|
||||
}
|
||||
})
|
||||
|
||||
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote')
|
||||
expect(env.ORCA_WORKTREE_ID).toBe('repo::/home/alice/wt')
|
||||
expect(env.ORCA_PANE_KEY).toBe('pane-9')
|
||||
expect(env.ORCA_AGENT_LAUNCH_TOKEN).toBe('launch-secret')
|
||||
expect(env.ORCA_WORKSPACE_ID).toBe('ws-1')
|
||||
expect(env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]).toBe('ssh')
|
||||
expect(env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]).toBe('saved-target')
|
||||
expect(env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]).toBe('connection-incarnation')
|
||||
expect(env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]).toBe('runtime-attachment')
|
||||
expect(env.PATH).toBe('/host/bin')
|
||||
expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data')
|
||||
expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub')
|
||||
|
||||
@@ -17,20 +17,49 @@ import {
|
||||
parsePositiveSafeIntegerNumericText,
|
||||
parsePositiveSafeIntegerText
|
||||
} from '../../shared/timer-delay'
|
||||
import {
|
||||
ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
|
||||
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
|
||||
} from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
export type SshCliRuntimeAuthority = {
|
||||
kind: 'ssh'
|
||||
targetId: string
|
||||
connectionIncarnation: string
|
||||
attachmentId: string
|
||||
}
|
||||
|
||||
export type RemoteOrcaCliRequest = {
|
||||
argv: string[]
|
||||
cwd: string
|
||||
env: Record<string, string>
|
||||
stdin?: string
|
||||
runtimeAuthority?: SshCliRuntimeAuthority
|
||||
}
|
||||
|
||||
export type RemoteOrcaCliResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
postOutput?: RemoteOrcaCliPostOutput
|
||||
}
|
||||
|
||||
export type RemoteOrcaCliPostOutput =
|
||||
| {
|
||||
kind: 'legacy_check_ack'
|
||||
terminal: string
|
||||
messageIds: string[]
|
||||
types?: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'legacy_question_ack'
|
||||
terminal: string
|
||||
questionId: string
|
||||
answerMessageId: string
|
||||
}
|
||||
|
||||
export type HostCliPassthroughOptions = {
|
||||
execPath?: string
|
||||
cliEntryPath?: string
|
||||
@@ -54,6 +83,7 @@ const REMOTE_CONTEXT_ENV_VARS = [
|
||||
'ORCA_TERMINAL_HANDLE',
|
||||
'ORCA_WORKTREE_ID',
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_AGENT_LAUNCH_TOKEN',
|
||||
'ORCA_WORKSPACE_ID'
|
||||
] as const
|
||||
|
||||
@@ -106,6 +136,7 @@ export function buildHostCliEnv(args: {
|
||||
remoteEnv: Record<string, string>
|
||||
userDataPath: string
|
||||
remoteCwd: string
|
||||
runtimeAuthority?: SshCliRuntimeAuthority
|
||||
}): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...args.hostEnv }
|
||||
for (const key of REMOTE_CONTEXT_ENV_VARS) {
|
||||
@@ -127,6 +158,17 @@ export function buildHostCliEnv(args: {
|
||||
env.ORCA_NODE_REPL_EXTERNAL_MODULE = args.hostEnv.NODE_REPL_EXTERNAL_MODULE ?? ''
|
||||
delete env.NODE_OPTIONS
|
||||
delete env.NODE_REPL_EXTERNAL_MODULE
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]
|
||||
delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]
|
||||
if (args.runtimeAuthority) {
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh'
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId
|
||||
env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] =
|
||||
args.runtimeAuthority.connectionIncarnation
|
||||
env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] = args.runtimeAuthority.attachmentId
|
||||
}
|
||||
env.ELECTRON_RUN_AS_NODE = '1'
|
||||
return env
|
||||
}
|
||||
@@ -177,7 +219,8 @@ export async function runHostOrcaCliPassthrough(
|
||||
hostEnv,
|
||||
remoteEnv: request.env,
|
||||
userDataPath,
|
||||
remoteCwd: request.cwd
|
||||
remoteCwd: request.cwd,
|
||||
runtimeAuthority: request.runtimeAuthority
|
||||
})
|
||||
|
||||
return await new Promise<RemoteOrcaCliResult>((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error'
|
||||
import type { RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
import {
|
||||
formatRemoteOrchestrationAsk,
|
||||
getRemoteCliExitCode
|
||||
} from './ssh-remote-orchestration-ask-output'
|
||||
import { formatRemoteOrchestrationCheck } from './ssh-remote-orchestration-check-output'
|
||||
import { getRemoteCliPostOutput } from './ssh-remote-orchestration-post-output'
|
||||
import { resolveRemoteCliHandle } from './ssh-remote-cli-args'
|
||||
|
||||
export function formatInProcessRemoteCliResult(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
response: RpcResponse,
|
||||
json: boolean
|
||||
): RemoteOrcaCliResult {
|
||||
const command = parsed.commandPath.join(' ')
|
||||
const formatted =
|
||||
command === 'orchestration check'
|
||||
? formatRemoteOrchestrationCheck(
|
||||
response,
|
||||
json,
|
||||
resolveRemoteCliHandle(parsed.flags, env, 'terminal'),
|
||||
parsed.flags.has('format')
|
||||
)
|
||||
: command === 'orchestration ask'
|
||||
? formatRemoteOrchestrationAsk(response, json)
|
||||
: json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
const postOutput = getRemoteCliPostOutput(parsed, env, response)
|
||||
return {
|
||||
stdout: formatted.stdout,
|
||||
stderr: formatted.stderr,
|
||||
exitCode: getRemoteCliExitCode(command, response),
|
||||
...(postOutput ? { postOutput } : {})
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = {
|
||||
userDataPath: '/host/user-data',
|
||||
entryExists: () => false
|
||||
}
|
||||
|
||||
type FakeChild = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
@@ -80,6 +79,7 @@ describe('runRemoteOrcaCli', () => {
|
||||
}
|
||||
}
|
||||
}),
|
||||
getLegacyAdoption: vi.fn(() => undefined),
|
||||
getActiveDispatchForIdentity: vi.fn(() => undefined),
|
||||
getCurrentRunForPane: vi.fn(() => undefined),
|
||||
findActiveRemoteAttachmentForPane: vi.fn(() => undefined)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
import {
|
||||
HostCliUnavailableError,
|
||||
runHostOrcaCliPassthrough,
|
||||
@@ -24,9 +25,9 @@ import { buildRemoteCliError } from './ssh-remote-cli-error-response'
|
||||
import { getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli'
|
||||
import {
|
||||
getRemoteOrchestrationPayload,
|
||||
hasRemoteLifecycleRejection,
|
||||
resolveRemoteOrchestrationSender
|
||||
} from './ssh-remote-orchestration-send'
|
||||
import { formatInProcessRemoteCliResult } from './ssh-remote-cli-in-process-result'
|
||||
|
||||
export type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough'
|
||||
|
||||
@@ -49,6 +50,7 @@ export async function runRemoteOrcaCli(
|
||||
): Promise<RemoteOrcaCliResult> {
|
||||
const parsed = parseRemoteCliArgs(request.argv)
|
||||
const json = parsed.flags.has('json')
|
||||
const command = parsed.commandPath.join(' ')
|
||||
|
||||
const interactiveMessage = HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? '']
|
||||
if (interactiveMessage) {
|
||||
@@ -62,6 +64,17 @@ export async function runRemoteOrcaCli(
|
||||
return { stdout: '', stderr: `${interactiveMessage}\n`, exitCode: 1 }
|
||||
}
|
||||
|
||||
if (command === 'orchestration check' || command === 'orchestration ask') {
|
||||
// Why: compatibility ACKs must wait until relay stdout is observable; a host CLI child can only flush into main's capture pipe.
|
||||
return await runLegacyRemoteOrcaCli(
|
||||
runtime,
|
||||
request,
|
||||
parsed,
|
||||
json,
|
||||
new HostCliUnavailableError('output-ordered orchestration bridge required')
|
||||
)
|
||||
}
|
||||
|
||||
let passthroughFailure: HostCliUnavailableError | null = null
|
||||
try {
|
||||
return await runHostOrcaCliPassthrough(request, passthroughOptions)
|
||||
@@ -96,18 +109,10 @@ async function runLegacyRemoteOrcaCli(
|
||||
parsed,
|
||||
request.env,
|
||||
request.stdin,
|
||||
passthroughFailure.message
|
||||
passthroughFailure.message,
|
||||
request.runtimeAuthority
|
||||
)
|
||||
const formatted = json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
return {
|
||||
stdout: formatted.stdout,
|
||||
stderr: formatted.stderr,
|
||||
// Why: the legacy SSH bridge bypasses the local CLI handler that turns
|
||||
// a persisted lifecycle rejection into an unsuccessful command.
|
||||
exitCode: response.ok && !hasRemoteLifecycleRejection(response.result) ? 0 : 1
|
||||
}
|
||||
return formatInProcessRemoteCliResult(parsed, request.env, response, json)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const code =
|
||||
@@ -134,9 +139,23 @@ async function dispatchRemoteCli(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
stdin: string | undefined,
|
||||
passthroughFailureReason: string
|
||||
passthroughFailureReason: string,
|
||||
runtimeAuthority: RemoteOrcaCliRequest['runtimeAuthority']
|
||||
): Promise<RpcResponse> {
|
||||
const command = parsed.commandPath.join(' ')
|
||||
const inheritedEvidence = readOrchestrationCompatibilityEvidence(env)
|
||||
const orchestrationCompatibilityEvidence = runtimeAuthority
|
||||
? { ...inheritedEvidence, host: runtimeAuthority }
|
||||
: inheritedEvidence
|
||||
const compatibilityEnvelope: RuntimeOrchestrationEnvelope = {
|
||||
compatibilityInvocationId: randomUUID(),
|
||||
orchestrationRequestId:
|
||||
optionalRemoteCliString(parsed.flags, 'retry-request') ??
|
||||
(command === 'orchestration check' || command === 'orchestration ask'
|
||||
? randomUUID()
|
||||
: undefined),
|
||||
orchestrationCompatibilityEvidence
|
||||
}
|
||||
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env, stdin)
|
||||
if (linearResponse) {
|
||||
return linearResponse
|
||||
@@ -187,33 +206,74 @@ async function dispatchRemoteCli(
|
||||
senderPaneKey: env.ORCA_PANE_KEY || undefined
|
||||
},
|
||||
{
|
||||
orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability'),
|
||||
orchestrationRequestId: optionalRemoteCliString(parsed.flags, 'retry-request')
|
||||
...compatibilityEnvelope,
|
||||
orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability')
|
||||
}
|
||||
)
|
||||
}
|
||||
case 'orchestration check':
|
||||
return await call(dispatcher, 'orchestration.check', {
|
||||
terminal: resolveRemoteCliHandle(parsed.flags, env, 'terminal'),
|
||||
terminalPaneKey: parsed.flags.has('terminal') ? undefined : env.ORCA_PANE_KEY || undefined,
|
||||
unread: parsed.flags.has('unread') ? true : undefined,
|
||||
all: parsed.flags.has('all') ? true : undefined,
|
||||
types: optionalRemoteCliString(parsed.flags, 'types'),
|
||||
inject: parsed.flags.has('inject') ? true : undefined,
|
||||
wait: parsed.flags.has('wait') ? true : undefined,
|
||||
timeoutMs: optionalRemoteCliNumber(parsed.flags, 'timeout-ms')
|
||||
})
|
||||
return await call(
|
||||
dispatcher,
|
||||
'orchestration.check',
|
||||
{
|
||||
terminal: resolveRemoteCliHandle(parsed.flags, env, 'terminal'),
|
||||
terminalPaneKey: parsed.flags.has('terminal')
|
||||
? undefined
|
||||
: env.ORCA_PANE_KEY || undefined,
|
||||
unread: parsed.flags.has('unread') ? true : parsed.flags.has('peek') ? false : undefined,
|
||||
peek: parsed.flags.has('peek') ? true : undefined,
|
||||
all: parsed.flags.has('all') ? true : undefined,
|
||||
types: optionalRemoteCliString(parsed.flags, 'types'),
|
||||
format: parsed.flags.has('format') ? true : undefined,
|
||||
inject: parsed.flags.has('inject') ? true : undefined,
|
||||
compatibilityCliCommand: 'orca',
|
||||
run: optionalRemoteCliString(parsed.flags, 'run'),
|
||||
ack: optionalRemoteCliString(parsed.flags, 'ack'),
|
||||
wait: parsed.flags.has('wait') ? true : undefined,
|
||||
timeoutMs: optionalRemoteCliNumber(parsed.flags, 'timeout-ms')
|
||||
},
|
||||
compatibilityEnvelope
|
||||
)
|
||||
case 'orchestration ask':
|
||||
return await call(
|
||||
dispatcher,
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: optionalRemoteCliString(parsed.flags, 'to'),
|
||||
question: optionalRemoteCliString(parsed.flags, 'question'),
|
||||
resume: optionalRemoteCliString(parsed.flags, 'resume'),
|
||||
options: optionalRemoteCliString(parsed.flags, 'options'),
|
||||
timeoutMs: optionalRemoteCliNumber(parsed.flags, 'timeout-ms'),
|
||||
from: resolveRemoteCliHandle(parsed.flags, env, 'from'),
|
||||
run: optionalRemoteCliString(parsed.flags, 'run'),
|
||||
compatibilityCliCommand: 'orca'
|
||||
},
|
||||
{
|
||||
...compatibilityEnvelope,
|
||||
orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability')
|
||||
}
|
||||
)
|
||||
case 'orchestration reply':
|
||||
return await call(dispatcher, 'orchestration.reply', {
|
||||
id: requiredRemoteCliString(parsed.flags, 'id'),
|
||||
body: requiredRemoteCliString(parsed.flags, 'body'),
|
||||
from: resolveRemoteCliHandle(parsed.flags, env, 'from')
|
||||
})
|
||||
return await call(
|
||||
dispatcher,
|
||||
'orchestration.reply',
|
||||
{
|
||||
id: requiredRemoteCliString(parsed.flags, 'id'),
|
||||
body: requiredRemoteCliString(parsed.flags, 'body'),
|
||||
from: resolveRemoteCliHandle(parsed.flags, env, 'from')
|
||||
},
|
||||
compatibilityEnvelope
|
||||
)
|
||||
case 'orchestration inbox':
|
||||
return await call(dispatcher, 'orchestration.inbox', {
|
||||
limit: optionalRemoteCliNumber(parsed.flags, 'limit'),
|
||||
terminal: optionalRemoteCliString(parsed.flags, 'terminal')
|
||||
})
|
||||
return await call(
|
||||
dispatcher,
|
||||
'orchestration.inbox',
|
||||
{
|
||||
limit: optionalRemoteCliNumber(parsed.flags, 'limit'),
|
||||
terminal: optionalRemoteCliString(parsed.flags, 'terminal')
|
||||
},
|
||||
compatibilityEnvelope
|
||||
)
|
||||
default:
|
||||
// Why: only reachable when the full host CLI could not be launched;
|
||||
// include that root cause so users can fix the install instead of
|
||||
@@ -239,6 +299,9 @@ async function call(
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
: undefined,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId,
|
||||
compatibilityInvocationId:
|
||||
envelope?.orchestrationRequestId ?? envelope?.compatibilityInvocationId,
|
||||
orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
import { hasRemoteLifecycleRejection } from './ssh-remote-orchestration-send'
|
||||
|
||||
export function formatRemoteOrchestrationAsk(
|
||||
response: RpcResponse,
|
||||
json: boolean
|
||||
): { stdout: string; stderr: string } {
|
||||
if (!response.ok || !isRecord(response.result)) {
|
||||
return json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
}
|
||||
if (json) {
|
||||
return { stdout: `${JSON.stringify(response.result)}\n`, stderr: '' }
|
||||
}
|
||||
if (isRecord(response.result.legacyCompatibility)) {
|
||||
const compatibility = response.result.legacyCompatibility
|
||||
if (compatibility.resumeRequired === true && typeof compatibility.resumeCommand === 'string') {
|
||||
return {
|
||||
stdout:
|
||||
`Question ${String(response.result.messageId ?? 'unknown')} committed.\n` +
|
||||
`Resume with: ${compatibility.resumeCommand}\n`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
const answer = typeof response.result.answer === 'string' ? response.result.answer : ''
|
||||
const thread = typeof response.result.threadId === 'string' ? response.result.threadId : 'unknown'
|
||||
const timeoutMs =
|
||||
typeof response.result.timeoutMs === 'number' ? response.result.timeoutMs : undefined
|
||||
const stderr = response.result.timedOut
|
||||
? `ask timeout after ${timeoutMs ?? 0}ms (thread ${thread})\n`
|
||||
: response.result.cancelled
|
||||
? response.result.connectionLost
|
||||
? `ask connection closed (question ${String(response.result.messageId ?? 'unknown')})\n`
|
||||
: `ask cancelled (question ${String(response.result.messageId ?? 'unknown')})\n`
|
||||
: ''
|
||||
return { stdout: answer ? `${answer}\n` : '', stderr }
|
||||
}
|
||||
|
||||
export function getRemoteCliExitCode(command: string, response: RpcResponse): number {
|
||||
if (!response.ok || hasRemoteLifecycleRejection(response.result)) {
|
||||
return 1
|
||||
}
|
||||
if (
|
||||
command === 'orchestration ask' &&
|
||||
isRecord(response.result) &&
|
||||
isRecord(response.result.legacyCompatibility) &&
|
||||
response.result.legacyCompatibility.resumeRequired === true
|
||||
) {
|
||||
return 75
|
||||
}
|
||||
if (
|
||||
command === 'orchestration ask' &&
|
||||
isRecord(response.result) &&
|
||||
(response.result.timedOut === true || response.result.cancelled === true)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
formatOrchestrationCheckText,
|
||||
prepareOrchestrationCheckOutput,
|
||||
type OrchestrationCheckOutput,
|
||||
type OrchestrationMessageSummary
|
||||
} from '../../shared/orchestration-check-output'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import { formatRemoteCli } from './ssh-remote-cli-format'
|
||||
|
||||
export function formatRemoteOrchestrationCheck(
|
||||
response: RpcResponse,
|
||||
json: boolean,
|
||||
terminal: string,
|
||||
formattedRequested: boolean
|
||||
): { stdout: string; stderr: string } {
|
||||
if (!response.ok || !isRecord(response.result)) {
|
||||
return json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
}
|
||||
const messages = Array.isArray(response.result.messages)
|
||||
? response.result.messages.filter(isMessageSummary)
|
||||
: []
|
||||
const count = typeof response.result.count === 'number' ? response.result.count : messages.length
|
||||
const result = prepareOrchestrationCheckOutput(
|
||||
{ ...(response.result as OrchestrationCheckOutput), messages, count },
|
||||
terminal,
|
||||
formattedRequested
|
||||
)
|
||||
if (json) {
|
||||
return {
|
||||
stdout: `${JSON.stringify({ ...response, result }, null, 2)}\n`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
return {
|
||||
stdout: `${formatOrchestrationCheckText(result, terminal)}\n`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
|
||||
function isMessageSummary(value: unknown): value is OrchestrationMessageSummary {
|
||||
return isRecord(value) && typeof value.id === 'string' && typeof value.from_handle === 'string'
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => '/host/app'
|
||||
}
|
||||
}))
|
||||
vi.mock('../persistence', () => ({
|
||||
getCanonicalUserDataPath: () => '/host/user-data'
|
||||
}))
|
||||
|
||||
import { OrchestrationDb } from '../runtime/orchestration/db'
|
||||
import { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type Database from '../sqlite/sync-database'
|
||||
import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
import { acknowledgeRemoteOrcaCliPostOutput } from './ssh-remote-orchestration-post-output'
|
||||
|
||||
const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = {
|
||||
execPath: '/host/electron',
|
||||
cliEntryPath: '/host/app/out/cli/index.js',
|
||||
userDataPath: '/host/user-data',
|
||||
entryExists: () => false
|
||||
}
|
||||
const WORKER_HANDLE = 'term_legacy_ssh_worker'
|
||||
const WORKER_PANE = 'tab_legacy_ssh:33333333-3333-4333-8333-333333333333'
|
||||
const COORDINATOR_HANDLE = 'term_legacy_ssh_coord'
|
||||
const COORDINATOR_PANE = 'tab_legacy_coord:44444444-4444-4444-8444-444444444444'
|
||||
const WORKER_ENV = {
|
||||
ORCA_TERMINAL_HANDLE: WORKER_HANDLE,
|
||||
ORCA_PANE_KEY: WORKER_PANE,
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'legacy-ssh-token'
|
||||
}
|
||||
const COORDINATOR_ENV = {
|
||||
ORCA_TERMINAL_HANDLE: COORDINATOR_HANDLE,
|
||||
ORCA_PANE_KEY: COORDINATOR_PANE,
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 'legacy-ssh-coordinator-token'
|
||||
}
|
||||
const RUNTIME_AUTHORITY = {
|
||||
kind: 'ssh' as const,
|
||||
targetId: 'saved-target',
|
||||
connectionIncarnation: 'connection-1',
|
||||
attachmentId: 'attachment-1'
|
||||
}
|
||||
|
||||
function createLegacyRuntime() {
|
||||
const db = new OrchestrationDb(':memory:')
|
||||
const run = db.createRun({
|
||||
objective: 'Adopted legacy SSH work',
|
||||
coordinatorHandle: COORDINATOR_HANDLE,
|
||||
coordinatorPaneKey: COORDINATOR_PANE
|
||||
})
|
||||
const task = db.createTask({
|
||||
spec: 'legacy SSH assignment',
|
||||
runId: run.id,
|
||||
createdByTerminalHandle: COORDINATOR_HANDLE
|
||||
})
|
||||
const dispatch = db.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET contract_version = 0, process_incarnation = 'process-1'
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(dispatch.id)
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO legacy_adoptions (source_run_id, adopted_run_id, scheduler_state_lost)
|
||||
VALUES ('run_legacy_local', ?, 1)`
|
||||
)
|
||||
.run(run.id)
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE runs
|
||||
SET coordinator_handle = NULL, coordinator_pane_key = NULL, consumer_generation = 0
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(run.id)
|
||||
|
||||
const runtime = new OrcaRuntimeService()
|
||||
runtime.setOrchestrationDb(db)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === WORKER_HANDLE ? WORKER_PANE : handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : null
|
||||
)
|
||||
vi.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller').mockImplementation((evidence) => {
|
||||
const worker =
|
||||
evidence?.terminalHandle === WORKER_HANDLE &&
|
||||
evidence.paneKey === WORKER_PANE &&
|
||||
evidence.launchToken === WORKER_ENV.ORCA_AGENT_LAUNCH_TOKEN
|
||||
const coordinator =
|
||||
evidence?.terminalHandle === COORDINATOR_HANDLE &&
|
||||
evidence.paneKey === COORDINATOR_PANE &&
|
||||
evidence.launchToken === COORDINATOR_ENV.ORCA_AGENT_LAUNCH_TOKEN
|
||||
if (
|
||||
(!worker && !coordinator) ||
|
||||
evidence.host?.kind !== 'ssh' ||
|
||||
evidence.host.targetId !== RUNTIME_AUTHORITY.targetId ||
|
||||
evidence.host.connectionIncarnation !== RUNTIME_AUTHORITY.connectionIncarnation
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const identity = worker
|
||||
? {
|
||||
terminalHandle: WORKER_HANDLE,
|
||||
paneKey: WORKER_PANE,
|
||||
launchToken: WORKER_ENV.ORCA_AGENT_LAUNCH_TOKEN
|
||||
}
|
||||
: {
|
||||
terminalHandle: COORDINATOR_HANDLE,
|
||||
paneKey: COORDINATOR_PANE,
|
||||
launchToken: COORDINATOR_ENV.ORCA_AGENT_LAUNCH_TOKEN
|
||||
}
|
||||
return {
|
||||
hostScope: { kind: 'ssh', targetId: RUNTIME_AUTHORITY.targetId },
|
||||
terminalHandle: identity.terminalHandle,
|
||||
paneKey: identity.paneKey,
|
||||
processIncarnation: 'process-1',
|
||||
launchTokenHash: createHash('sha256').update(identity.launchToken).digest('hex')
|
||||
}
|
||||
})
|
||||
vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
return { db, runtime, run: db.getRun(run.id)!, dispatch }
|
||||
}
|
||||
|
||||
describe('legacy SSH orchestration fallback', () => {
|
||||
it('acknowledges a consuming check only after remote output', async () => {
|
||||
const { db, runtime, run } = createLegacyRuntime()
|
||||
const message = db.insertMessage({
|
||||
runId: run.id,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'retained SSH mail'
|
||||
})
|
||||
const request = {
|
||||
argv: ['orchestration', 'check', '--unread', '--inject', '--json'],
|
||||
cwd: '/home/alice/repo',
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
}
|
||||
|
||||
try {
|
||||
const first = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
const replay = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
|
||||
expect(JSON.parse(first.stdout)).toMatchObject({
|
||||
result: {
|
||||
messages: [{ id: message.id }],
|
||||
formatted: expect.stringContaining('retained SSH mail')
|
||||
}
|
||||
})
|
||||
expect(first.postOutput).toEqual({
|
||||
kind: 'legacy_check_ack',
|
||||
terminal: WORKER_HANDLE,
|
||||
messageIds: [message.id]
|
||||
})
|
||||
expect(JSON.parse(replay.stdout)).toMatchObject({
|
||||
result: { messages: [{ id: message.id }] }
|
||||
})
|
||||
expect(db.getMessageById(message.id)?.read).toBe(0)
|
||||
|
||||
await acknowledgeRemoteOrcaCliPostOutput(runtime, {
|
||||
postOutput: first.postOutput!,
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
})
|
||||
const afterAck = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
|
||||
expect(JSON.parse(afterAck.stdout)).toMatchObject({ result: { messages: [], count: 0 } })
|
||||
expect(afterAck.postOutput).toBeUndefined()
|
||||
expect(db.getMessageById(message.id)?.read).toBe(1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps peek formatted and non-consuming', async () => {
|
||||
const { db, runtime, run } = createLegacyRuntime()
|
||||
const message = db.insertMessage({
|
||||
runId: run.id,
|
||||
deliveryContract: 'legacy_direct',
|
||||
from: COORDINATOR_HANDLE,
|
||||
to: WORKER_HANDLE,
|
||||
subject: 'inspect retained SSH mail'
|
||||
})
|
||||
|
||||
try {
|
||||
const peek = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
argv: ['orchestration', 'check', '--peek', '--format', '--json'],
|
||||
cwd: '/home/alice/repo',
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
},
|
||||
LEGACY_FALLBACK_OPTIONS
|
||||
)
|
||||
|
||||
const output = JSON.parse(peek.stdout) as {
|
||||
result: { formatted: string }
|
||||
}
|
||||
expect(output).toMatchObject({
|
||||
result: {
|
||||
messages: [{ id: message.id }],
|
||||
formatted: expect.stringContaining('inspect retained SSH mail'),
|
||||
legacyCompatibility: { readOnly: true }
|
||||
}
|
||||
})
|
||||
expect(output.result.formatted).toContain(`${message.id} [legacy, read-only]`)
|
||||
expect(output.result.formatted).toContain(
|
||||
'Inspection only: reply and acknowledgment are unavailable.'
|
||||
)
|
||||
expect(output.result.formatted).not.toContain('orchestration reply')
|
||||
expect(peek.postOutput).toBeUndefined()
|
||||
expect(db.getMessageById(message.id)?.read).toBe(0)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads and acknowledges current Run delivery', async () => {
|
||||
const { db, runtime, run } = createLegacyRuntime()
|
||||
const message = db.insertMessage({
|
||||
runId: run.id,
|
||||
from: WORKER_HANDLE,
|
||||
to: `run:${run.id}`,
|
||||
subject: 'current Run mail'
|
||||
})
|
||||
const baseRequest = {
|
||||
cwd: '/home/alice/repo',
|
||||
env: COORDINATOR_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
}
|
||||
const spawn = vi.fn()
|
||||
const hostCliAvailable = {
|
||||
...LEGACY_FALLBACK_OPTIONS,
|
||||
entryExists: () => true,
|
||||
spawn: spawn as HostCliPassthroughOptions['spawn']
|
||||
}
|
||||
|
||||
try {
|
||||
const checked = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
...baseRequest,
|
||||
argv: ['orchestration', 'check', '--run', run.id]
|
||||
},
|
||||
hostCliAvailable
|
||||
)
|
||||
expect(checked.stdout).toContain('Delivery ')
|
||||
expect(checked.stdout).toContain(`${message.id} [status] from=${WORKER_HANDLE}`)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
|
||||
const checkedJson = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
...baseRequest,
|
||||
argv: ['orchestration', 'check', '--run', run.id, '--json']
|
||||
},
|
||||
hostCliAvailable
|
||||
)
|
||||
const result = JSON.parse(checkedJson.stdout) as {
|
||||
result: { deliveryId: string; messages: { id: string }[] }
|
||||
}
|
||||
expect(result.result.messages).toEqual([expect.objectContaining({ id: message.id })])
|
||||
|
||||
const acknowledged = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
...baseRequest,
|
||||
argv: [
|
||||
'orchestration',
|
||||
'check',
|
||||
'--run',
|
||||
run.id,
|
||||
'--ack',
|
||||
result.result.deliveryId,
|
||||
'--json'
|
||||
]
|
||||
},
|
||||
hostCliAvailable
|
||||
)
|
||||
|
||||
expect(JSON.parse(acknowledged.stdout)).toMatchObject({
|
||||
result: { acknowledged: result.result.deliveryId }
|
||||
})
|
||||
expect(db.getMessageById(message.id)?.read).toBe(1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('replays an SSH legacy ask by retry request without creating another question', async () => {
|
||||
const { db, runtime } = createLegacyRuntime()
|
||||
const argv = [
|
||||
'orchestration',
|
||||
'ask',
|
||||
'--to',
|
||||
COORDINATOR_HANDLE,
|
||||
'--question',
|
||||
'Continue?',
|
||||
'--timeout-ms',
|
||||
'1',
|
||||
'--retry-request',
|
||||
'ssh-question-1',
|
||||
'--json'
|
||||
]
|
||||
const request = {
|
||||
argv,
|
||||
cwd: '/home/alice/repo',
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
}
|
||||
|
||||
try {
|
||||
const first = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
const replay = await runRemoteOrcaCli(runtime, request, LEGACY_FALLBACK_OPTIONS)
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
|
||||
const firstResult = JSON.parse(first.stdout) as { messageId: string; timedOut: boolean }
|
||||
const replayResult = JSON.parse(replay.stdout) as { messageId: string; timedOut: boolean }
|
||||
expect(first).toMatchObject({ exitCode: 1 })
|
||||
expect(firstResult).toMatchObject({ messageId: expect.any(String), timedOut: true })
|
||||
expect(replay).toMatchObject({ exitCode: 1 })
|
||||
expect(replayResult).toMatchObject({ messageId: firstResult.messageId, timedOut: true })
|
||||
expect(
|
||||
(
|
||||
sqlite.prepare('SELECT COUNT(*) AS count FROM question_threads').get() as {
|
||||
count: number
|
||||
}
|
||||
).count
|
||||
).toBe(1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('resumes and acknowledges a question answer', async () => {
|
||||
const { db, runtime, run, dispatch } = createLegacyRuntime()
|
||||
const pending = db.createQuestion({
|
||||
runId: run.id,
|
||||
dispatchId: dispatch.id,
|
||||
askerHandle: WORKER_HANDLE,
|
||||
question: 'Proceed?'
|
||||
})
|
||||
const answer = db.answerQuestion({
|
||||
messageId: pending.question.message_id,
|
||||
runId: run.id,
|
||||
consumerGeneration: run.consumer_generation,
|
||||
body: 'yes'
|
||||
})
|
||||
const sqlite = (db as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET delivery_contract = 'legacy_direct', read = 0
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(answer.message.id)
|
||||
|
||||
try {
|
||||
const result = await runRemoteOrcaCli(
|
||||
runtime,
|
||||
{
|
||||
argv: ['orchestration', 'ask', '--resume', pending.question.message_id, '--json'],
|
||||
cwd: '/home/alice/repo',
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
},
|
||||
LEGACY_FALLBACK_OPTIONS
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
answer: 'yes',
|
||||
messageId: pending.question.message_id,
|
||||
timedOut: false
|
||||
})
|
||||
expect(result.postOutput).toEqual({
|
||||
kind: 'legacy_question_ack',
|
||||
terminal: WORKER_HANDLE,
|
||||
questionId: pending.question.message_id,
|
||||
answerMessageId: answer.message.id
|
||||
})
|
||||
expect(db.getMessageById(answer.message.id)?.read).toBe(0)
|
||||
|
||||
await acknowledgeRemoteOrcaCliPostOutput(runtime, {
|
||||
postOutput: result.postOutput!,
|
||||
env: WORKER_ENV,
|
||||
runtimeAuthority: RUNTIME_AUTHORITY
|
||||
})
|
||||
|
||||
expect(db.getMessageById(answer.message.id)?.read).toBe(1)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import type {
|
||||
RemoteOrcaCliPostOutput,
|
||||
RemoteOrcaCliRequest
|
||||
} from './ssh-remote-cli-host-passthrough'
|
||||
import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error'
|
||||
import { optionalRemoteCliString, resolveRemoteCliHandle } from './ssh-remote-cli-args'
|
||||
|
||||
export async function acknowledgeRemoteOrcaCliPostOutput(
|
||||
runtime: OrcaRuntimeService,
|
||||
args: {
|
||||
postOutput: RemoteOrcaCliPostOutput
|
||||
env: Record<string, string>
|
||||
runtimeAuthority?: RemoteOrcaCliRequest['runtimeAuthority']
|
||||
}
|
||||
): Promise<void> {
|
||||
const inheritedEvidence = readOrchestrationCompatibilityEvidence(args.env)
|
||||
const orchestrationCompatibilityEvidence = args.runtimeAuthority
|
||||
? { ...inheritedEvidence, host: args.runtimeAuthority }
|
||||
: inheritedEvidence
|
||||
const params =
|
||||
args.postOutput.kind === 'legacy_check_ack'
|
||||
? {
|
||||
terminal: args.postOutput.terminal,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: args.postOutput.messageIds,
|
||||
types: args.postOutput.types
|
||||
})
|
||||
}
|
||||
: {
|
||||
terminal: args.postOutput.terminal,
|
||||
compatibilityQuestionAck: JSON.stringify({
|
||||
questionId: args.postOutput.questionId,
|
||||
answerMessageId: args.postOutput.answerMessageId
|
||||
})
|
||||
}
|
||||
const response = await new RpcDispatcher({ runtime }).dispatch({
|
||||
id: `remote-cli-post-output-${randomUUID()}`,
|
||||
authToken: 'remote-cli',
|
||||
method: 'orchestration.check',
|
||||
params,
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
compatibilityInvocationId: randomUUID(),
|
||||
orchestrationCompatibilityEvidence
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRemoteOrcaCliPostOutput(value: unknown): RemoteOrcaCliPostOutput {
|
||||
if (!isRecord(value) || typeof value.kind !== 'string' || typeof value.terminal !== 'string') {
|
||||
throw invalidPostOutput()
|
||||
}
|
||||
if (
|
||||
value.kind === 'legacy_check_ack' &&
|
||||
Array.isArray(value.messageIds) &&
|
||||
value.messageIds.every((id) => typeof id === 'string') &&
|
||||
(value.types === undefined ||
|
||||
(Array.isArray(value.types) && value.types.every((type) => typeof type === 'string')))
|
||||
) {
|
||||
return {
|
||||
kind: value.kind,
|
||||
terminal: value.terminal,
|
||||
messageIds: value.messageIds,
|
||||
...(value.types === undefined ? {} : { types: value.types })
|
||||
}
|
||||
}
|
||||
if (
|
||||
value.kind === 'legacy_question_ack' &&
|
||||
typeof value.questionId === 'string' &&
|
||||
typeof value.answerMessageId === 'string'
|
||||
) {
|
||||
return {
|
||||
kind: value.kind,
|
||||
terminal: value.terminal,
|
||||
questionId: value.questionId,
|
||||
answerMessageId: value.answerMessageId
|
||||
}
|
||||
}
|
||||
throw invalidPostOutput()
|
||||
}
|
||||
|
||||
export function getRemoteCliPostOutput(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
response: RpcResponse
|
||||
): RemoteOrcaCliPostOutput | undefined {
|
||||
if (!response.ok || !isRecord(response.result)) {
|
||||
return undefined
|
||||
}
|
||||
const compatibility = response.result.legacyCompatibility
|
||||
if (!isRecord(compatibility)) {
|
||||
return undefined
|
||||
}
|
||||
const command = parsed.commandPath.join(' ')
|
||||
if (
|
||||
command === 'orchestration check' &&
|
||||
Array.isArray(compatibility.ackMessageIds) &&
|
||||
compatibility.ackMessageIds.length > 0 &&
|
||||
compatibility.ackMessageIds.every((id) => typeof id === 'string')
|
||||
) {
|
||||
const types = optionalRemoteCliString(parsed.flags, 'types')
|
||||
?.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
return {
|
||||
kind: 'legacy_check_ack',
|
||||
terminal: resolveRemoteCliHandle(parsed.flags, env, 'terminal'),
|
||||
messageIds: compatibility.ackMessageIds,
|
||||
...(types ? { types } : {})
|
||||
}
|
||||
}
|
||||
const acknowledgement = compatibility.answerAcknowledgement
|
||||
if (
|
||||
command === 'orchestration ask' &&
|
||||
response.result.answer !== null &&
|
||||
isRecord(acknowledgement) &&
|
||||
typeof acknowledgement.questionId === 'string' &&
|
||||
typeof acknowledgement.answerMessageId === 'string'
|
||||
) {
|
||||
return {
|
||||
kind: 'legacy_question_ack',
|
||||
terminal: resolveRemoteCliHandle(parsed.flags, env, 'from'),
|
||||
questionId: acknowledgement.questionId,
|
||||
answerMessageId: acknowledgement.answerMessageId
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function invalidPostOutput(): RemoteCliArgumentError {
|
||||
return new RemoteCliArgumentError('invalid_argument', 'Invalid SSH CLI post-output action.')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -73,6 +73,13 @@ describe('startup ordering', () => {
|
||||
expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady')
|
||||
expect(barrier).toContain('managedWslCliStartupBarrierReady')
|
||||
expect(barrier).not.toContain('managedWslCliReconciliationReady')
|
||||
expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'")
|
||||
expect(barrier).toContain('recoverLegacyWorkerTerminalsForRendererStartup({')
|
||||
expect(barrier).toContain('localPtyProviderStartupReady,')
|
||||
expect(barrier).toContain('await runtime?.refreshRestoredOrchestrationAuthority()')
|
||||
expect(barrier).toContain(
|
||||
'return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })'
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery'
|
||||
|
||||
describe('legacy worker renderer recovery', () => {
|
||||
it('hydrates after bounded barriers while provider startup remains pending', async () => {
|
||||
let resolveFirstWindow!: () => void
|
||||
let resolveWslBarrier!: () => void
|
||||
let resolveProvider!: () => void
|
||||
const firstWindowReady = new Promise<void>((resolve) => {
|
||||
resolveFirstWindow = resolve
|
||||
})
|
||||
const wslBarrierReady = new Promise<void>((resolve) => {
|
||||
resolveWslBarrier = resolve
|
||||
})
|
||||
const providerReady = new Promise<void>((resolve) => {
|
||||
resolveProvider = resolve
|
||||
})
|
||||
const reconcile = vi.fn().mockResolvedValue(undefined)
|
||||
const startup = recoverLegacyWorkerTerminalsForRendererStartup({
|
||||
firstWindowStartupServicesReady: firstWindowReady,
|
||||
managedWslCliStartupBarrierReady: wslBarrierReady,
|
||||
localPtyProviderStartupReady: providerReady,
|
||||
reconcile,
|
||||
onDeferredRecoveryError: vi.fn()
|
||||
})
|
||||
|
||||
resolveFirstWindow()
|
||||
await Promise.resolve()
|
||||
expect(reconcile).not.toHaveBeenCalled()
|
||||
|
||||
resolveWslBarrier()
|
||||
await startup
|
||||
expect(reconcile).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveProvider()
|
||||
await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('retries after initial recovery when the provider is already ready', async () => {
|
||||
const reconcile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await recoverLegacyWorkerTerminalsForRendererStartup({
|
||||
firstWindowStartupServicesReady: Promise.resolve(),
|
||||
managedWslCliStartupBarrierReady: Promise.resolve(),
|
||||
localPtyProviderStartupReady: Promise.resolve(),
|
||||
reconcile,
|
||||
onDeferredRecoveryError: vi.fn()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('contains provider startup rejection after initial recovery', async () => {
|
||||
const providerError = new Error('provider failed')
|
||||
const reconcile = vi.fn().mockResolvedValue(undefined)
|
||||
let reportError!: (error: unknown) => void
|
||||
const reportedError = new Promise<unknown>((resolve) => {
|
||||
reportError = resolve
|
||||
})
|
||||
|
||||
await recoverLegacyWorkerTerminalsForRendererStartup({
|
||||
firstWindowStartupServicesReady: Promise.resolve(),
|
||||
managedWslCliStartupBarrierReady: Promise.resolve(),
|
||||
localPtyProviderStartupReady: Promise.reject(providerError),
|
||||
reconcile,
|
||||
onDeferredRecoveryError: reportError
|
||||
})
|
||||
|
||||
await expect(reportedError).resolves.toBe(providerError)
|
||||
expect(reconcile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('contains deferred recovery rejection', async () => {
|
||||
const recoveryError = new Error('recovery failed')
|
||||
const reconcile = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(recoveryError)
|
||||
let reportError!: (error: unknown) => void
|
||||
const reportedError = new Promise<unknown>((resolve) => {
|
||||
reportError = resolve
|
||||
})
|
||||
|
||||
await recoverLegacyWorkerTerminalsForRendererStartup({
|
||||
firstWindowStartupServicesReady: Promise.resolve(),
|
||||
managedWslCliStartupBarrierReady: Promise.resolve(),
|
||||
localPtyProviderStartupReady: Promise.resolve(),
|
||||
reconcile,
|
||||
onDeferredRecoveryError: reportError
|
||||
})
|
||||
|
||||
await expect(reportedError).resolves.toBe(recoveryError)
|
||||
expect(reconcile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('contains initial recovery rejection and still retries when the provider becomes ready', async () => {
|
||||
const initialError = new Error('initial recovery failed')
|
||||
let resolveProvider!: () => void
|
||||
const providerReady = new Promise<void>((resolve) => {
|
||||
resolveProvider = resolve
|
||||
})
|
||||
const reconcile = vi.fn().mockRejectedValueOnce(initialError).mockResolvedValueOnce(undefined)
|
||||
const onDeferredRecoveryError = vi.fn()
|
||||
|
||||
await expect(
|
||||
recoverLegacyWorkerTerminalsForRendererStartup({
|
||||
firstWindowStartupServicesReady: Promise.resolve(),
|
||||
managedWslCliStartupBarrierReady: Promise.resolve(),
|
||||
localPtyProviderStartupReady: providerReady,
|
||||
reconcile,
|
||||
onDeferredRecoveryError
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(onDeferredRecoveryError).toHaveBeenCalledWith(initialError)
|
||||
expect(reconcile).toHaveBeenCalledTimes(1)
|
||||
resolveProvider()
|
||||
await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
type LegacyWorkerRendererRecoveryOptions = {
|
||||
firstWindowStartupServicesReady: Promise<void>
|
||||
managedWslCliStartupBarrierReady: Promise<void>
|
||||
localPtyProviderStartupReady: Promise<void>
|
||||
reconcile: () => Promise<unknown> | undefined
|
||||
onDeferredRecoveryError: (error: unknown) => void
|
||||
}
|
||||
|
||||
export async function recoverLegacyWorkerTerminalsForRendererStartup(
|
||||
options: LegacyWorkerRendererRecoveryOptions
|
||||
): Promise<void> {
|
||||
const providerStartupResult = options.localPtyProviderStartupReady.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: unknown) => ({ ok: false as const, error })
|
||||
)
|
||||
await Promise.all([
|
||||
options.firstWindowStartupServicesReady,
|
||||
options.managedWslCliStartupBarrierReady
|
||||
])
|
||||
void providerStartupResult
|
||||
.then(async (result) => {
|
||||
if (!result.ok) {
|
||||
throw result.error
|
||||
}
|
||||
await options.reconcile()
|
||||
})
|
||||
.catch(options.onDeferredRecoveryError)
|
||||
try {
|
||||
await options.reconcile()
|
||||
} catch (error) {
|
||||
options.onDeferredRecoveryError(error)
|
||||
}
|
||||
}
|
||||
@@ -829,4 +829,73 @@ describe('attachMainWindowServices', () => {
|
||||
await expect(revealPromise).resolves.toEqual({ tabId: 'tab-1', title: 'SSH tmux' })
|
||||
expect(removeListenerMock).toHaveBeenCalledWith('terminal:tabCreateReply', handler)
|
||||
})
|
||||
|
||||
it('requires an exact renderer identity receipt for recovered worker reveals', async () => {
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = createMainWindow({ send: sendMock })
|
||||
const runtime = createRuntime()
|
||||
|
||||
attachMainWindowServices(mainWindow as never, createStore(), runtime as never)
|
||||
|
||||
const notifier = runtime.setNotifier.mock.calls[0][0] as {
|
||||
revealTerminalSession: (
|
||||
worktreeId: string,
|
||||
opts: {
|
||||
ptyId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
expectedProcessIdentity: { terminalHandle: string; incarnationId: string }
|
||||
}
|
||||
) => Promise<unknown>
|
||||
}
|
||||
const opts = {
|
||||
ptyId: 'pty-worker',
|
||||
tabId: 'tab-worker',
|
||||
leafId: 'leaf-worker',
|
||||
expectedProcessIdentity: {
|
||||
terminalHandle: 'term_worker',
|
||||
incarnationId: 'inc-worker'
|
||||
}
|
||||
}
|
||||
const mismatch = notifier.revealTerminalSession('worktree-1', opts)
|
||||
const mismatchPayload = sendMock.mock.calls.at(-1)?.[1]
|
||||
const mismatchHandler = onMock.mock.calls.findLast(
|
||||
([channel]) => channel === 'terminal:tabCreateReply'
|
||||
)?.[1]
|
||||
mismatchHandler?.(
|
||||
{ sender: mainWindow.webContents },
|
||||
{
|
||||
requestId: mismatchPayload.requestId,
|
||||
tabId: 'tab-worker',
|
||||
identity: {
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-worker',
|
||||
leafId: 'leaf-worker',
|
||||
ptyId: 'pty-replacement'
|
||||
}
|
||||
}
|
||||
)
|
||||
await expect(mismatch).rejects.toThrow('terminal_reveal_identity_mismatch')
|
||||
|
||||
const exact = notifier.revealTerminalSession('worktree-1', opts)
|
||||
const exactPayload = sendMock.mock.calls.at(-1)?.[1]
|
||||
const exactHandler = onMock.mock.calls.findLast(
|
||||
([channel]) => channel === 'terminal:tabCreateReply'
|
||||
)?.[1]
|
||||
const identity = {
|
||||
worktreeId: 'worktree-1',
|
||||
tabId: 'tab-worker',
|
||||
leafId: 'leaf-worker',
|
||||
ptyId: 'pty-worker'
|
||||
}
|
||||
exactHandler?.(
|
||||
{ sender: mainWindow.webContents },
|
||||
{ requestId: exactPayload.requestId, tabId: 'tab-worker', identity }
|
||||
)
|
||||
await expect(exact).resolves.toEqual({
|
||||
tabId: 'tab-worker',
|
||||
title: undefined,
|
||||
identity
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
RuntimeMarkdownSaveTabResult
|
||||
} from '../../shared/mobile-markdown-document'
|
||||
import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types'
|
||||
import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity'
|
||||
import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../shared/native-file-drop'
|
||||
import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay'
|
||||
import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay'
|
||||
@@ -347,14 +348,20 @@ function registerRuntimeWindowLifecycle(
|
||||
revealTerminalSession: (worktreeId, opts) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const requestId = randomUUID()
|
||||
const expectedIdentity = opts.expectedProcessIdentity
|
||||
? opts.tabId && opts.leafId
|
||||
? { worktreeId, tabId: opts.tabId, leafId: opts.leafId, ptyId: opts.ptyId }
|
||||
: null
|
||||
: undefined
|
||||
if (expectedIdentity === null) {
|
||||
reject(new Error('terminal_reveal_identity_required'))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
||||
reject(new Error('Terminal reveal timed out'))
|
||||
}, 10_000)
|
||||
const handler = (
|
||||
event: Electron.IpcMainEvent,
|
||||
reply: { requestId: string; tabId?: string; title?: string; error?: string }
|
||||
): void => {
|
||||
const handler = (event: Electron.IpcMainEvent, reply: TerminalTabCreateReply): void => {
|
||||
// Why: requestId is renderer-supplied, so only the targeted main window may satisfy the reveal.
|
||||
if (event.sender !== mainWindow.webContents || reply.requestId !== requestId) {
|
||||
return
|
||||
@@ -365,7 +372,22 @@ function registerRuntimeWindowLifecycle(
|
||||
reject(new Error(reply.error))
|
||||
return
|
||||
}
|
||||
resolve({ tabId: reply.tabId!, title: reply.title })
|
||||
if (
|
||||
expectedIdentity &&
|
||||
(!reply.identity ||
|
||||
reply.identity.worktreeId !== expectedIdentity.worktreeId ||
|
||||
reply.identity.tabId !== expectedIdentity.tabId ||
|
||||
reply.identity.leafId !== expectedIdentity.leafId ||
|
||||
reply.identity.ptyId !== expectedIdentity.ptyId)
|
||||
) {
|
||||
reject(new Error('terminal_reveal_identity_mismatch'))
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
tabId: reply.tabId!,
|
||||
title: reply.title,
|
||||
...(reply.identity ? { identity: reply.identity } : {})
|
||||
})
|
||||
}
|
||||
ipcMain.on('terminal:tabCreateReply', handler)
|
||||
send('ui:createTerminal', {
|
||||
@@ -387,9 +409,16 @@ function registerRuntimeWindowLifecycle(
|
||||
...(opts.splitDirection !== undefined ? { splitDirection: opts.splitDirection } : {}),
|
||||
...(opts.splitTelemetrySource !== undefined
|
||||
? { splitTelemetrySource: opts.splitTelemetrySource }
|
||||
: {})
|
||||
: {}),
|
||||
...(opts.focus !== undefined ? { focus: opts.focus } : {})
|
||||
})
|
||||
}),
|
||||
resolveLegacyWorkerTerminalRecovery: (paneKey, resolution, ptyId) =>
|
||||
send('agentStatus:legacyWorkerTerminalRecovery', {
|
||||
paneKey,
|
||||
resolution,
|
||||
...(ptyId ? { ptyId } : {})
|
||||
}),
|
||||
splitTerminal: (tabId, paneRuntimeId, opts) => {
|
||||
send('ui:splitTerminal', {
|
||||
tabId,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
TerminalTabCloseRequest,
|
||||
TerminalTabCloseResponse
|
||||
} from '../shared/terminal-tab-close'
|
||||
import type { TerminalTabCreateReply } from '../shared/terminal-reveal-identity'
|
||||
import type {
|
||||
LocalLogTailChangedPayload,
|
||||
LocalLogTailReadArgs,
|
||||
@@ -971,6 +972,8 @@ export type AppApi = {
|
||||
/** Resolves when the daemon PTY provider and hook receiver have either
|
||||
* started or failed open for the first BrowserWindow. */
|
||||
awaitFirstWindowStartupServices: () => Promise<void>
|
||||
/** Reconciles legacy worker authority around persisted terminal reconnect. */
|
||||
recoverLegacyWorkerTerminalsForRendererStartup: () => Promise<void>
|
||||
/** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */
|
||||
startupDiagnostic: (event: string, details?: Record<string, unknown>) => Promise<void>
|
||||
/** macOS active input mode, or layout ID when no IME is selected (e.g. `com.apple.keylayout.PolishPro`).
|
||||
@@ -3135,6 +3138,7 @@ export type PreloadApi = {
|
||||
title?: string
|
||||
ptyId?: string
|
||||
activate?: boolean
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
@@ -3149,12 +3153,7 @@ export type PreloadApi = {
|
||||
onRequestTerminalTabMount: (
|
||||
callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void
|
||||
) => () => void
|
||||
replyTerminalCreate: (reply: {
|
||||
requestId: string
|
||||
tabId?: string
|
||||
title?: string
|
||||
error?: string
|
||||
}) => void
|
||||
replyTerminalCreate: (reply: TerminalTabCreateReply) => void
|
||||
onSplitTerminal: (
|
||||
callback: (data: {
|
||||
tabId: string
|
||||
@@ -3517,6 +3516,13 @@ export type PreloadApi = {
|
||||
/** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */
|
||||
onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void
|
||||
onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void
|
||||
onLegacyWorkerTerminalRecovery: (
|
||||
callback: (data: {
|
||||
paneKey: string
|
||||
resolution: 'adopted' | 'exited' | 'rolled_back'
|
||||
ptyId?: string
|
||||
}) => void
|
||||
) => () => void
|
||||
getMigrationUnsupportedSnapshot: () => Promise<MigrationUnsupportedPtyEntry[]>
|
||||
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
|
||||
drop: (paneKey: string) => void
|
||||
|
||||
+24
-6
@@ -13,6 +13,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types'
|
||||
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
|
||||
import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types'
|
||||
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
|
||||
import type { TerminalTabCreateReply } from '../shared/terminal-reveal-identity'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
|
||||
import type { StartupCommandDelivery } from '../shared/codex-startup-delivery'
|
||||
import type {
|
||||
@@ -487,6 +488,8 @@ const api = {
|
||||
},
|
||||
awaitFirstWindowStartupServices: (): Promise<void> =>
|
||||
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
|
||||
recoverLegacyWorkerTerminalsForRendererStartup: (): Promise<void> =>
|
||||
ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'),
|
||||
startupDiagnostic: (event: string, details?: Record<string, unknown>): Promise<void> =>
|
||||
startupDiagnosticsEnabled
|
||||
? ipcRenderer.invoke('app:startupDiagnostic', event, details)
|
||||
@@ -3696,6 +3699,7 @@ const api = {
|
||||
title?: string
|
||||
ptyId?: string
|
||||
activate?: boolean
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
@@ -3720,6 +3724,7 @@ const api = {
|
||||
title?: string
|
||||
ptyId?: string
|
||||
activate?: boolean
|
||||
focus?: boolean
|
||||
presentation?: RuntimeTerminalPresentation
|
||||
tabId?: string
|
||||
leafId?: string
|
||||
@@ -3751,12 +3756,7 @@ const api = {
|
||||
ipcRenderer.on('terminal:requestTabMount', listener)
|
||||
return () => ipcRenderer.removeListener('terminal:requestTabMount', listener)
|
||||
},
|
||||
replyTerminalCreate: (reply: {
|
||||
requestId: string
|
||||
tabId?: string
|
||||
title?: string
|
||||
error?: string
|
||||
}): void => {
|
||||
replyTerminalCreate: (reply: TerminalTabCreateReply): void => {
|
||||
ipcRenderer.send('terminal:tabCreateReply', reply)
|
||||
},
|
||||
onSplitTerminal: (
|
||||
@@ -4646,6 +4646,24 @@ const api = {
|
||||
ipcRenderer.on('agentStatus:migrationUnsupportedClear', listener)
|
||||
return () => ipcRenderer.removeListener('agentStatus:migrationUnsupportedClear', listener)
|
||||
},
|
||||
onLegacyWorkerTerminalRecovery: (
|
||||
callback: (data: {
|
||||
paneKey: string
|
||||
resolution: 'adopted' | 'exited' | 'rolled_back'
|
||||
ptyId?: string
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: {
|
||||
paneKey: string
|
||||
resolution: 'adopted' | 'exited' | 'rolled_back'
|
||||
ptyId?: string
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener)
|
||||
return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener)
|
||||
},
|
||||
getMigrationUnsupportedSnapshot: (): Promise<MigrationUnsupportedPtyEntry[]> =>
|
||||
ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'),
|
||||
/** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */
|
||||
|
||||
+85
-11
@@ -198,6 +198,8 @@ async function runOrcaCliMode(sockPath: string, argv: string[]): Promise<void> {
|
||||
let nextSeq = 1
|
||||
let highestReceivedSeq = 0
|
||||
const requestId = 1
|
||||
const postOutputRequestId = 2
|
||||
let initialExitCode = 0
|
||||
|
||||
const sendRequest = (): void => {
|
||||
const env = pickRemoteCliEnv(process.env)
|
||||
@@ -219,6 +221,59 @@ async function runOrcaCliMode(sockPath: string, argv: string[]): Promise<void> {
|
||||
sock.write(frame)
|
||||
}
|
||||
|
||||
const finish = (exitCode: number): void => {
|
||||
sock.destroy()
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
const sendPostOutput = (postOutput: unknown): void => {
|
||||
sock.write(
|
||||
encodeJsonRpcFrame(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: postOutputRequestId,
|
||||
method: 'orca.cli.postOutput',
|
||||
params: { postOutput, env: pickRemoteCliEnv(process.env) }
|
||||
},
|
||||
nextSeq++,
|
||||
highestReceivedSeq
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const writeOutput = (
|
||||
result: { stdout?: unknown; stderr?: unknown },
|
||||
onFlushed: (error?: Error) => void
|
||||
): void => {
|
||||
const writes = [
|
||||
[process.stdout, result.stdout],
|
||||
[process.stderr, result.stderr]
|
||||
].filter((entry): entry is [NodeJS.WriteStream, string] => typeof entry[1] === 'string')
|
||||
if (writes.length === 0) {
|
||||
onFlushed()
|
||||
return
|
||||
}
|
||||
let pending = writes.length
|
||||
let completed = false
|
||||
for (const [stream, output] of writes) {
|
||||
stream.write(output, (error) => {
|
||||
if (completed) {
|
||||
return
|
||||
}
|
||||
if (error) {
|
||||
completed = true
|
||||
onFlushed(error)
|
||||
return
|
||||
}
|
||||
pending -= 1
|
||||
if (pending === 0) {
|
||||
completed = true
|
||||
onFlushed()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const decoder = new FrameDecoder((frame: DecodedFrame) => {
|
||||
if (frame.id > highestReceivedSeq) {
|
||||
highestReceivedSeq = frame.id
|
||||
@@ -227,28 +282,41 @@ async function runOrcaCliMode(sockPath: string, argv: string[]): Promise<void> {
|
||||
return
|
||||
}
|
||||
const msg = parseJsonRpcMessage(frame.payload)
|
||||
if (!('id' in msg) || msg.id !== requestId || !('result' in msg || 'error' in msg)) {
|
||||
if (
|
||||
!('id' in msg) ||
|
||||
(msg.id !== requestId && msg.id !== postOutputRequestId) ||
|
||||
!('result' in msg || 'error' in msg)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const response = msg as JsonRpcResponse
|
||||
if (response.error) {
|
||||
process.stderr.write(`${response.error.message}\n`)
|
||||
sock.destroy()
|
||||
process.exit(1)
|
||||
finish(1)
|
||||
return
|
||||
}
|
||||
if (response.id === postOutputRequestId) {
|
||||
finish(initialExitCode)
|
||||
return
|
||||
}
|
||||
const result = (response.result ?? {}) as {
|
||||
stdout?: unknown
|
||||
stderr?: unknown
|
||||
exitCode?: unknown
|
||||
postOutput?: unknown
|
||||
}
|
||||
if (typeof result.stdout === 'string' && result.stdout.length > 0) {
|
||||
process.stdout.write(result.stdout)
|
||||
}
|
||||
if (typeof result.stderr === 'string' && result.stderr.length > 0) {
|
||||
process.stderr.write(result.stderr)
|
||||
}
|
||||
sock.destroy()
|
||||
process.exit(typeof result.exitCode === 'number' ? result.exitCode : 0)
|
||||
initialExitCode = typeof result.exitCode === 'number' ? result.exitCode : 0
|
||||
writeOutput(result, (error) => {
|
||||
if (error) {
|
||||
finish(1)
|
||||
return
|
||||
}
|
||||
if (result.postOutput === undefined) {
|
||||
finish(initialExitCode)
|
||||
return
|
||||
}
|
||||
sendPostOutput(result.postOutput)
|
||||
})
|
||||
})
|
||||
|
||||
const connectTimeout = setTimeout(() => {
|
||||
@@ -443,6 +511,12 @@ async function main(): Promise<void> {
|
||||
timeoutMs: remoteCliRequestTimeoutMs(params)
|
||||
})
|
||||
})
|
||||
dispatcher.onRequest('orca.cli.postOutput', async (params, context) => {
|
||||
return await dispatcher.requestAnyClient('orca.cli.postOutput', params, {
|
||||
excludeClientId: context.clientId,
|
||||
timeoutMs: remoteCliRequestTimeoutMs(params)
|
||||
})
|
||||
})
|
||||
|
||||
function configureRelayGraceTime(params: Record<string, unknown>): { graceTimeMs: number } {
|
||||
const seconds = Number(params.graceTimeSeconds)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user