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:
Brennan Benson
2026-09-25 18:27:58 -07:00
committed by GitHub
parent 600adba9f0
commit 19d472fad8
4 changed files with 192 additions and 20 deletions
@@ -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')
})
})