mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 16:02:46 +00:00
fix(native-chat): never preempt an in-flight handoff when reclaiming a dead owner
Removing the lease-deadline precondition let the renewer evict a proven-dead native owner the instant its child exited. That is right for an idle owner, but a suspend parks a live owner at handoffStage 'preparing', and the handoff transition requires that exact stage, its operation id, and a non-null ownerProcess. An eviction landing in that window nulls the owner and bumps the fence, so the handoff then fails agent_session_ownership_unknown. The renewer now reclaims only an owner with no handoff in flight, and the eviction transition takes an optional expected handoff stage so the write is a compare-and-swap: a stage that drifted between the scan and the mutation raises agent_session_checkpoint_stale instead of clobbering the handoff. The guard lives beside the other eviction preconditions rather than in the record store, and the store now reuses the transition's input type instead of restating it. Found by review of the preceding commit, which widened a window the deadline had been masking.
This commit is contained in:
+53
@@ -6,6 +6,7 @@ import {
|
||||
agentSessionLeaseFixture,
|
||||
agentSessionRecordFixture
|
||||
} from '../../../shared/agent-session-record.test-fixture'
|
||||
import { stopStoredAgentSessionOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions'
|
||||
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer'
|
||||
|
||||
@@ -264,6 +265,58 @@ describe('structured agent-session lease renewal', () => {
|
||||
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW)
|
||||
})
|
||||
|
||||
it('does not preempt a native owner handoff after the child stops', async () => {
|
||||
const store = await liveStore()
|
||||
const initialFence = store.getRecord('session-renewal')!.lease.runtimeFence
|
||||
const probeStarted = Promise.withResolvers<void>()
|
||||
const deadProbe = Promise.withResolvers<{ outcome: 'pid-absent' }>()
|
||||
const onError = vi.fn()
|
||||
const renewer = new StructuredAgentSessionLeaseRenewer({
|
||||
store,
|
||||
probe: async () => {
|
||||
probeStarted.resolve()
|
||||
return deadProbe.promise
|
||||
},
|
||||
now: () => NOW + 10_000,
|
||||
onError
|
||||
})
|
||||
const renewal = renewer.renewNow()
|
||||
await probeStarted.promise
|
||||
await store.transitionHandoff('session-renewal', (record) => ({
|
||||
...record,
|
||||
lease: {
|
||||
...record.lease,
|
||||
handoffStage: 'preparing',
|
||||
handoffOperationId: 'handoff-1'
|
||||
}
|
||||
}))
|
||||
deadProbe.resolve({ outcome: 'pid-absent' })
|
||||
await renewal
|
||||
|
||||
expect(store.getRecord('session-renewal')?.lease).toMatchObject({
|
||||
runtimeFence: initialFence,
|
||||
handoffStage: 'preparing',
|
||||
handoffOperationId: 'handoff-1',
|
||||
claimStatus: 'live'
|
||||
})
|
||||
expect(onError).toHaveBeenCalledWith({
|
||||
sessionId: 'session-renewal',
|
||||
error: expect.objectContaining({ message: 'agent_session_checkpoint_stale' })
|
||||
})
|
||||
const stopped = await stopStoredAgentSessionOwnerForHandoff(store, {
|
||||
sessionId: 'session-renewal',
|
||||
expectedFence: initialFence,
|
||||
operationId: 'handoff-1',
|
||||
now: NOW + 10_001
|
||||
})
|
||||
expect(stopped.lease).toMatchObject({
|
||||
runtimeFence: initialFence + 1,
|
||||
handoffStage: 'old-owner-stopped',
|
||||
handoffOperationId: 'handoff-1',
|
||||
claimStatus: 'released'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a proven dead TUI owner into handoff recovery', async () => {
|
||||
const store = await liveStore()
|
||||
await store.transitionHandoff('session-renewal', (record) => ({
|
||||
|
||||
@@ -76,13 +76,18 @@ export class StructuredAgentSessionLeaseRenewer {
|
||||
if (!probe) {
|
||||
continue
|
||||
}
|
||||
// Positive death evidence settles it; the deadline only guards a live owner that has not
|
||||
// renewed yet. Matches the dead-TUI-owner branch below, which never waits for expiry.
|
||||
if (record.lease.runtimeKind === 'native' && isProvenDeadProbe(probe)) {
|
||||
// Positive death evidence settles an idle owner without waiting for expiry. An active
|
||||
// handoff owns its stop transition and fence; the renewer must not preempt it.
|
||||
if (
|
||||
record.lease.runtimeKind === 'native' &&
|
||||
record.lease.handoffStage === null &&
|
||||
isProvenDeadProbe(probe)
|
||||
) {
|
||||
try {
|
||||
await this.input.store.evictProvenDeadOwner({
|
||||
sessionId: record.sessionId,
|
||||
expectedFence: record.lease.runtimeFence,
|
||||
expectedHandoffStage: null,
|
||||
probe,
|
||||
now
|
||||
})
|
||||
|
||||
@@ -203,15 +203,25 @@ export function renewAgentSessionLease(args: {
|
||||
}
|
||||
|
||||
/** Proven eviction — the only other thing besides acquisition that may move the fence. */
|
||||
export function evictAgentSessionOwner(args: {
|
||||
export type EvictAgentSessionOwnerInput = {
|
||||
record: AgentSessionRecord
|
||||
expectedFence: number
|
||||
/** Compare-and-swap guard: a stage that drifted since the scan must not be clobbered. */
|
||||
expectedHandoffStage?: AgentSessionRecord['lease']['handoffStage']
|
||||
probe: AgentSessionOwnerProbe
|
||||
now: number
|
||||
journalSettlement: 'required' | 'not-required'
|
||||
}): AgentSessionRecord {
|
||||
}
|
||||
|
||||
export function evictAgentSessionOwner(args: EvictAgentSessionOwnerInput): AgentSessionRecord {
|
||||
const { record } = args
|
||||
assertFence(record.lease, args.expectedFence)
|
||||
if (
|
||||
args.expectedHandoffStage !== undefined &&
|
||||
record.lease.handoffStage !== args.expectedHandoffStage
|
||||
) {
|
||||
throw new Error('agent_session_checkpoint_stale')
|
||||
}
|
||||
if (record.lease.settlementRetryRequired) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
admitAgentSessionOperationRow,
|
||||
type AgentSessionOperationAdmission
|
||||
} from './agent-session-operation-admission'
|
||||
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
|
||||
import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication'
|
||||
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
|
||||
import {
|
||||
@@ -28,7 +27,8 @@ import {
|
||||
evictAgentSessionOwner,
|
||||
proveAgentSessionOwner,
|
||||
setAgentSessionJournalCheckpoint,
|
||||
type AgentSessionProcessIdentityCommit
|
||||
type AgentSessionProcessIdentityCommit,
|
||||
type EvictAgentSessionOwnerInput
|
||||
} from './agent-session-lease-transitions'
|
||||
import {
|
||||
settleFailedAgentSessionAcquisition,
|
||||
@@ -248,12 +248,9 @@ export class AgentSessionRecordStore {
|
||||
)
|
||||
}
|
||||
|
||||
async evictProvenDeadOwner(args: {
|
||||
sessionId: string
|
||||
expectedFence: number
|
||||
probe: AgentSessionOwnerProbe
|
||||
now: number
|
||||
}): Promise<AgentSessionRecord> {
|
||||
async evictProvenDeadOwner(
|
||||
args: Omit<EvictAgentSessionOwnerInput, 'record' | 'journalSettlement'> & { sessionId: string }
|
||||
): Promise<AgentSessionRecord> {
|
||||
return this.mutate(args.sessionId, (record) =>
|
||||
evictAgentSessionOwner({ ...args, record, journalSettlement: 'required' })
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user