mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* reland(opencode): session continuity without the command-finished deferral (STA-4557) Relands #14866 (reverted in #14943) minus its `orca-runtime.ts` change, which is what caused the revert. ## Why the original runtime change was wrong `retirePtyAgentLaunchAuthorityAfterCommandFinished` deferred launch-authority retirement behind an async foreground read, on the premise that OpenCode emits `command-finished` while still in the foreground. Raw PTY capture disproves it: OpenCode emits no OSC 133 of its own, and Orca's shell wrappers emit exactly one `133;D` per pane — at OpenCode's exit — under both zsh and bash. The event being deferred past only ever fires at exit, which is exactly when authority should be retired. Both call sites stay on the synchronous `retirePtyAgentLaunchAuthority`. ## Why the deferral was unsafe `confirmPtyAgentExit` uses the same async-foreground pattern four lines away, but its early return means "don't record an exit" — conservative. The deferral copied that shape into a site where the early return means "don't revoke a secret". Same code, inverted consequence: every guard failed open, so a stale or racing read silently kept a finished session's authority alive, and the pane's persisted `launchTokenHash` was never scrubbed — so it rehydrated as `restored` authority after an app restart. ## Why the deferral's guards could not have worked `ORCA_AGENT_LAUNCH_TOKEN` lives in the PTY environment, so every process started in that shell inherits it — both sessions in a reused pane post the same token. A pane-lifetime bearer secret cannot be a session identity baseline, by construction, and `incarnationId` tracks the PTY, not the agent. The only field that separates sessions is the provider `sessionID`. ## What lands - Status/session-boundary work from #14866: opencode emits `SessionStart` for root sessions (mimo-code does not), launch-token fencing, and `SessionStart` as an opencode turn boundary. - The two `server.ts` fixes from #14941: re-fence a still-authorized pane on a tokened `SessionStart`, and restore mimo-code's explicit-prompt restart boundary (mimo emits no `SessionStart`, so opencode-only stranded its panes). #14941's re-poll hunk is dropped along with the code it patched. - Five regression tests in `opencode-finished-session-authority.test.ts`. They pass here and all five go red if the deferral is re-added. * chore: drop incidental reformatting of files unrelated to this PR
262 lines
9.5 KiB
TypeScript
262 lines
9.5 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { makePaneKey } from '../../shared/stable-pane-id'
|
|
import { AgentHookServer } from './server'
|
|
|
|
const PANE = makePaneKey('tab-opencode', '11111111-1111-4111-8111-111111111111')
|
|
const TARGET_PANE = makePaneKey('tab-opencode', '22222222-2222-4222-8222-222222222222')
|
|
|
|
describe('AgentHookServer OpenCode lifecycle', () => {
|
|
const servers: AgentHookServer[] = []
|
|
|
|
afterEach(() => {
|
|
for (const server of servers) {
|
|
server.stop()
|
|
}
|
|
servers.length = 0
|
|
})
|
|
|
|
async function setup(): Promise<{
|
|
server: AgentHookServer
|
|
post: (
|
|
payload: Record<string, unknown>,
|
|
launchToken: string,
|
|
paneKey?: string,
|
|
source?: 'opencode' | 'mimo-code'
|
|
) => Promise<Response>
|
|
}> {
|
|
const server = new AgentHookServer()
|
|
servers.push(server)
|
|
await server.start({ env: 'production' })
|
|
const env = server.buildPtyEnv()
|
|
return {
|
|
server,
|
|
post: (payload, launchToken, paneKey = PANE, source = 'opencode') =>
|
|
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
|
},
|
|
body: JSON.stringify({
|
|
paneKey,
|
|
launchToken,
|
|
tabId: 'tab-opencode',
|
|
worktreeId: 'wt-opencode',
|
|
env: 'production',
|
|
payload
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
it('accepts Busy after a retired pane receives a root SessionStart', async () => {
|
|
const { server, post } = await setup()
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
server.retirePaneAuthority(PANE)
|
|
|
|
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({
|
|
paneKey: PANE,
|
|
state: 'done',
|
|
sessionBoundary: true,
|
|
providerSession: { key: 'session_id', id: 'fresh' }
|
|
})
|
|
])
|
|
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ paneKey: PANE, state: 'working', agentType: 'opencode' })
|
|
])
|
|
})
|
|
|
|
it('accepts a resumed fresh user MessagePart but not arbitrary Busy', async () => {
|
|
const { server, post } = await setup()
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
server.retirePaneAuthority(PANE)
|
|
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'resumed' }, 'resume-token')
|
|
expect(server.getStatusSnapshot()).toEqual([])
|
|
|
|
await post(
|
|
{
|
|
hook_event_name: 'MessagePart',
|
|
role: 'user',
|
|
text: 'continue the task',
|
|
messageID: 'message-resumed',
|
|
sessionID: 'resumed'
|
|
},
|
|
'resume-token'
|
|
)
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ state: 'working', prompt: 'continue the task' })
|
|
])
|
|
})
|
|
|
|
it('maps question.asked attention to Waiting after restart', async () => {
|
|
const { server, post } = await setup()
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
server.retirePaneAuthority(PANE)
|
|
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
|
|
|
await post(
|
|
{ hook_event_name: 'AskUserQuestion', id: 'question-1', sessionID: 'fresh' },
|
|
'fresh-token'
|
|
)
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ state: 'waiting', agentType: 'opencode' })
|
|
])
|
|
})
|
|
|
|
it('suppresses stale old-token Busy after a fresh restart', async () => {
|
|
const { server, post } = await setup()
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
server.retirePaneAuthority(PANE)
|
|
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
|
|
|
|
await post(
|
|
{ hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale prompt' },
|
|
'old-token'
|
|
)
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ state: 'working', prompt: '' })
|
|
])
|
|
})
|
|
|
|
it('clears the destination token fence when pane authority transfers', async () => {
|
|
const { server, post } = await setup()
|
|
await post(
|
|
{ hook_event_name: 'SessionBusy', sessionID: 'target-old' },
|
|
'target-old-token',
|
|
TARGET_PANE
|
|
)
|
|
server.retirePaneAuthority(TARGET_PANE)
|
|
await post(
|
|
{ hook_event_name: 'SessionStart', sessionID: 'target-fresh' },
|
|
'target-fresh-token',
|
|
TARGET_PANE
|
|
)
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'source' }, 'source-token')
|
|
|
|
// The destination fence rejects the source token until the transfer removes it.
|
|
await post(
|
|
{ hook_event_name: 'SessionBusy', sessionID: 'source-before-transfer' },
|
|
'source-token',
|
|
TARGET_PANE
|
|
)
|
|
expect(
|
|
server.getStatusSnapshot().find((entry) => entry.paneKey === TARGET_PANE)?.providerSession
|
|
).toEqual({ key: 'session_id', id: 'target-fresh' })
|
|
|
|
server.transferPaneAuthority(PANE, TARGET_PANE, 'pty-opencode')
|
|
await post(
|
|
{ hook_event_name: 'SessionBusy', sessionID: 'source-after-transfer' },
|
|
'source-token',
|
|
TARGET_PANE
|
|
)
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({
|
|
paneKey: TARGET_PANE,
|
|
providerSession: { key: 'session_id', id: 'source-after-transfer' }
|
|
})
|
|
])
|
|
})
|
|
|
|
it('re-fences on a new SessionStart while the pane stays authorized', async () => {
|
|
const { server, post } = await setup()
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
server.retirePaneAuthority(PANE)
|
|
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
|
// The stale old-token follow-up stays fenced out.
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } })
|
|
])
|
|
|
|
// Why: the runtime defers retirement while an agent stays in the foreground, so a
|
|
// genuinely new process can start in a still-authorized pane. Its SessionStart must
|
|
// replace the stale fence — not be swallowed as a stale event (rowless reuse).
|
|
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ providerSession: { key: 'session_id', id: 'fresh' } })
|
|
])
|
|
})
|
|
|
|
it('keeps the fence when a live pane sees a tokenless SessionStart', async () => {
|
|
const { server, post } = await setup()
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
server.retirePaneAuthority(PANE)
|
|
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
|
|
|
// Why: an untokened boundary cannot prove which process it belongs to, so dropping
|
|
// the fence for it would reopen the pane to every stale token.
|
|
await post({ hook_event_name: 'SessionStart', sessionID: 'tokenless' }, '')
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } })
|
|
])
|
|
})
|
|
|
|
it('does not let a stale explicit prompt re-fence a live pane', async () => {
|
|
const { server, post } = await setup()
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
server.retirePaneAuthority(PANE)
|
|
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
|
|
|
|
// Why: prompts recur mid-session, so honoring one as a process boundary would hand
|
|
// the pane back to any still-live stale process.
|
|
await post(
|
|
{
|
|
hook_event_name: 'MessagePart',
|
|
role: 'user',
|
|
text: 'stale prompt',
|
|
messageID: 'message-stale',
|
|
sessionID: 'old'
|
|
},
|
|
'old-token'
|
|
)
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' }, prompt: '' })
|
|
])
|
|
})
|
|
|
|
it('restarts a retired mimo-code pane on an explicit user prompt', async () => {
|
|
const { server, post } = await setup()
|
|
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token', PANE, 'mimo-code')
|
|
server.retirePaneAuthority(PANE)
|
|
|
|
// Why: mimo-code emits no SessionStart, so the explicit prompt is its only restart
|
|
// boundary — excluding it would strand every retired mimo-code pane.
|
|
await post(
|
|
{
|
|
hook_event_name: 'MessagePart',
|
|
role: 'user',
|
|
text: 'continue the task',
|
|
messageID: 'message-resumed',
|
|
sessionID: 'resumed'
|
|
},
|
|
'resume-token',
|
|
PANE,
|
|
'mimo-code'
|
|
)
|
|
|
|
expect(server.getStatusSnapshot()).toEqual([
|
|
expect.objectContaining({ state: 'working', prompt: 'continue the task' })
|
|
])
|
|
})
|
|
})
|