mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
fix(native-chat): report a chat's owner from its record, not its running agent (#22808)
* fix(native-chat): report a chat's owner from its record, not its running agent Released desktop clients gate worktree activation on agentSession.handoffStatus and count a chat tab as claimed only when the owner is `native`. The host answered `native` only for a live lease, and threw not_attached for a chat idle release had forgotten, so a chat at rest (idle-released, or restored after a restart) blocked its whole worktree from activating. The answer now comes from the record store for any record this host supports: the owner is the lease's runtime kind whatever its liveness, and manual recovery still answers `none`. With no map entry needed and nothing to wait for, the serialized read that kept a mid-start chat's answer honest goes too. * test(native-chat): pin the two owner answers that still refuse to vouch With liveness gone, the manual-recovery branch and the unsupported-record refusal are the only paths that keep a chat from answering native; neither had a test. * refactor(native-chat): say plainly why an unsupported chat reports no owner * test(native-chat): say what a blocked activation gate actually skips
This commit is contained in:
@@ -547,12 +547,13 @@ describe('restart', () => {
|
||||
* them. Every lease loads unreconciled, so this is the state that decides
|
||||
* whether a persisted session is reachable at all. */
|
||||
async function reboot(
|
||||
probeOwner: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
|
||||
probeOwner: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>,
|
||||
adapterOverrides: Partial<StructuredAgentSessionAdapter> = {}
|
||||
) {
|
||||
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
|
||||
host = new StructuredAgentSessionHost({
|
||||
store,
|
||||
adapter: adapter(),
|
||||
adapter: { ...adapter(), ...adapterOverrides },
|
||||
journalRoot: root,
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-b',
|
||||
@@ -633,14 +634,14 @@ describe('restart', () => {
|
||||
handoffStage: null,
|
||||
handoffOperationId: null
|
||||
})
|
||||
await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({
|
||||
expect(host.handoffStatus(SESSION)).toMatchObject({
|
||||
owner: 'native',
|
||||
phase: 'idle',
|
||||
stage: null
|
||||
})
|
||||
})
|
||||
|
||||
it('answers the owner status of a starting chat once its start settles', async () => {
|
||||
it('answers native for a chat whose start is still in flight', async () => {
|
||||
await attach()
|
||||
await reboot(async () => ({ outcome: 'pid-absent' }))
|
||||
await host.restoreReadableSessions()
|
||||
@@ -658,11 +659,26 @@ describe('restart', () => {
|
||||
|
||||
const hold = host.hold(SESSION, 'surface-1')
|
||||
await started.promise
|
||||
const claimMidStart = store.getRecord(SESSION)?.lease.claimStatus
|
||||
const status = host.handoffStatus(SESSION)
|
||||
release.resolve()
|
||||
await hold
|
||||
|
||||
await expect(status).resolves.toMatchObject({ owner: 'native', stage: null })
|
||||
// Mid-start the lease is only reserved; ownership does not wait for the agent.
|
||||
expect(claimMidStart).toBe('reserved')
|
||||
expect(status).toMatchObject({ owner: 'native' })
|
||||
})
|
||||
|
||||
it('vouches for no owner of a chat in manual recovery or one this host cannot run', async () => {
|
||||
await attach()
|
||||
await store.transitionHandoff(SESSION, (record) => ({
|
||||
...record,
|
||||
lease: { ...record.lease, handoffStage: 'manual-recovery' }
|
||||
}))
|
||||
expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'none', phase: 'failed' })
|
||||
|
||||
await reboot(async () => ({ outcome: 'pid-absent' }), { supportsCreate: () => false })
|
||||
expect(() => host.handoffStatus(SESSION)).toThrow('structured_agent_session_unsupported')
|
||||
})
|
||||
|
||||
it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => {
|
||||
|
||||
@@ -273,17 +273,9 @@ export class StructuredAgentSessionHost {
|
||||
commands: this.deps.adapter.readCommands?.(sessionId)
|
||||
})
|
||||
|
||||
async handoffStatus(sessionId: string): Promise<SessionWire.AgentSessionHandoffStatus> {
|
||||
this.requireSession(sessionId)
|
||||
// Queued behind an in-flight attach, so a starting chat answers with its settled owner.
|
||||
return this.serialize(sessionId, async () => {
|
||||
const record = this.deps.store.getRecord(sessionId)
|
||||
if (!record) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
return structuredAgentSessionOwnerStatus(record)
|
||||
})
|
||||
}
|
||||
/** From the record store, never the session map: an idle-released chat has no map entry. */
|
||||
handoffStatus = (sessionId: string): SessionWire.AgentSessionHandoffStatus =>
|
||||
structuredAgentSessionOwnerStatus(this.deps, sessionId)
|
||||
|
||||
history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) =>
|
||||
this.backgroundTasks.history(request)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
import { adapterSupportsRecord } from './structured-agent-session-provider-support'
|
||||
|
||||
/** The `agentSession.handoffStatus` answer. Released desktop clients gate worktree activation on
|
||||
* `owner`, so the method outlives the terminal handoff it was named for. */
|
||||
* `owner`, so the method outlives the terminal handoff it was named for. It reports ownership, not
|
||||
* liveness: a chat whose agent is stopped, idle-released or still starting is owned all the same. */
|
||||
export function structuredAgentSessionOwnerStatus(
|
||||
record: AgentSessionRecord
|
||||
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'adapter'>,
|
||||
sessionId: string
|
||||
): AgentSessionHandoffStatus {
|
||||
const record = deps.store.getRecord(sessionId)
|
||||
if (!record) {
|
||||
throw new Error('agent_session_identity_required')
|
||||
}
|
||||
// Same refusal as reveal: a host that cannot run this chat vouches for no owner.
|
||||
if (!adapterSupportsRecord(deps.adapter, record)) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
const { handoffStage: stage, handoffOperationId: operationId } = record.lease
|
||||
if (stage === 'manual-recovery') {
|
||||
return {
|
||||
@@ -21,7 +32,7 @@ export function structuredAgentSessionOwnerStatus(
|
||||
}
|
||||
}
|
||||
return {
|
||||
owner: record.lease.claimStatus === 'live' && record.lease.ownerProcess ? 'native' : 'none',
|
||||
owner: 'native',
|
||||
direction: stage ? 'to-native' : null,
|
||||
phase: stage ? 'switching' : 'idle',
|
||||
stage,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// A chat whose agent is not running still owns its conversation. Released desktop clients gate
|
||||
// worktree activation on the host's owner answer, so a chat at rest that answered anything but
|
||||
// `native` blocked the gate, which then skips adopting and resuming the worktree's paneless agents.
|
||||
// This drives the real host's answer through the desktop's activation gate for the two ways a chat
|
||||
// comes to rest.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { StructuredAgentSessionAdapter } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import {
|
||||
HOST_TEST_NOW as NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD,
|
||||
hostTestAttachParams,
|
||||
resetHostTestOperationIds
|
||||
} from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import { AgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store'
|
||||
import { useAppStore } from '../../src/renderer/src/store'
|
||||
import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/worktree-agent-activation-gate'
|
||||
import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
|
||||
|
||||
const WORKTREE = 'repo-1::/workspace/repo'
|
||||
const SURFACE = 'desktop-chat:1'
|
||||
|
||||
let root: string
|
||||
let store: AgentSessionRecordStore
|
||||
let host: StructuredAgentSessionHost
|
||||
let closeSession: Mock<NonNullable<StructuredAgentSessionAdapter['closeSession']>>
|
||||
|
||||
function openHost(): void {
|
||||
host = new StructuredAgentSessionHost({
|
||||
store,
|
||||
adapter: {
|
||||
acquire: async ({ fence, spawnToken }) => ({
|
||||
process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken },
|
||||
link: {
|
||||
linkId: `link-${fence}`,
|
||||
handle: { provider: 'codex', threadId: THREAD },
|
||||
origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created',
|
||||
mintedAtFence: fence,
|
||||
observedAt: NOW
|
||||
}
|
||||
}),
|
||||
closeSession,
|
||||
releaseAcquisition: async () => true,
|
||||
dispatch: async () => ({ state: 'rejected', reason: 'unused' }),
|
||||
cancelTurn: async () => ({ cancelled: false }),
|
||||
answerPrompt: async () => undefined,
|
||||
setOption: async () => undefined
|
||||
},
|
||||
journalRoot: root,
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-a',
|
||||
releaseGraceMs: 5,
|
||||
probeOwner: async () => ({ outcome: 'pid-absent' }),
|
||||
now: () => NOW
|
||||
})
|
||||
}
|
||||
|
||||
/** The desktop's runtime bridge, answering the two reads the gate makes from the real host. */
|
||||
function serveDesktopRuntime(): void {
|
||||
const tabs: RuntimeMobileSessionTabsResult = {
|
||||
worktree: WORKTREE,
|
||||
publicationEpoch: 'owner-status-test',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
{
|
||||
type: 'agent-session',
|
||||
id: `structured-agent-session-${SESSION}`,
|
||||
title: 'Codex Chat',
|
||||
sessionId: SESSION,
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
const call = async ({ method, params }: { method: string; params: { sessionId?: string } }) => {
|
||||
if (method === 'session.tabs.list') {
|
||||
return { ok: true, result: tabs }
|
||||
}
|
||||
if (method === 'agentSession.handoffStatus') {
|
||||
try {
|
||||
return { ok: true, result: host.handoffStatus(params.sessionId ?? '') }
|
||||
} catch (error) {
|
||||
return { ok: false, error: { code: String(error), message: String(error) } }
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected runtime method: ${method}`)
|
||||
}
|
||||
vi.stubGlobal('window', { api: { runtime: { call } } })
|
||||
}
|
||||
|
||||
function activate(): ReturnType<typeof runWorktreeAgentActivationGate> {
|
||||
return runWorktreeAgentActivationGate(WORKTREE, {
|
||||
getState: () => useAppStore.getState(),
|
||||
listSessions: async () => [],
|
||||
listSurfaceOwners: async () => null,
|
||||
hasStructuredSession: readWorktreeStructuredActivationInventory,
|
||||
resume: () => 0
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-owner-status-'))
|
||||
resetHostTestOperationIds()
|
||||
closeSession = vi.fn(async () => true)
|
||||
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
|
||||
openHost()
|
||||
expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
serveDesktopRuntime()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
await host.flushAllStreamedEvents()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('a chat at rest keeps its worktree activatable', () => {
|
||||
it('after idle release forgot the session', async () => {
|
||||
await host.hold(SESSION, SURFACE)
|
||||
host.release(SESSION, SURFACE)
|
||||
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
|
||||
expect(closeSession).toHaveBeenCalledWith(SESSION)
|
||||
|
||||
expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' })
|
||||
expect(await activate()).toBe('structured')
|
||||
})
|
||||
|
||||
it('after an app restart restored it for reading', async () => {
|
||||
await host.flushAllStreamedEvents()
|
||||
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
|
||||
openHost()
|
||||
await host.restoreReadableSessions()
|
||||
expect(host.hasSession(SESSION)).toBe(true)
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
ownerProcess: null
|
||||
})
|
||||
|
||||
expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' })
|
||||
expect(await activate()).toBe('structured')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user