Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record'
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
import { agentSessionProviderClaims } from '../../../shared/agent-session-provider-claims'

export type StructuredProviderSessionOwnership = {
sessionId: string
Expand All @@ -13,14 +14,12 @@ export type StructuredProviderSessionOwnership = {
export function listStructuredProviderSessionOwnership(
records: readonly AgentSessionRecord[]
): StructuredProviderSessionOwnership[] {
return records.flatMap((record) =>
record.providerHandleChain.map((link) => ({
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
provider: record.provider,
providerSessionId: link.handle.nativeId,
conversationName: record.conversationName,
lease: record.lease
}))
)
return agentSessionProviderClaims(records).map(({ record, handle }) => ({
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
provider: record.provider,
providerSessionId: handle.nativeId,
conversationName: record.conversationName,
lease: record.lease
}))
}
7 changes: 3 additions & 4 deletions src/main/runtime/agent-session-reservation-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import type { AgentSessionStoreState } from './agent-session-store-state'
import { agentSessionRecordIdentityFields } from './agent-session-record-founding'
import { agentSessionAccountHomesEqual } from '../../shared/agent-session-account-home'
import { agentSessionProviderClaims } from '../../shared/agent-session-provider-claims'

export type AgentSessionReserveRequest = {
/** Host-resolved floating directory committed with the first owner reservation. */
Expand Down Expand Up @@ -237,13 +238,11 @@ function assertAdoptedConversationUnowned(
return
}
const root = agentSessionProviderHandleRoot(adopted.handle)
for (const record of state.records.values()) {
for (const { record, handle } of agentSessionProviderClaims(state.records.values())) {
if (record.sessionId === request.sessionId) {
continue
}
const holdsSameConversation = record.providerHandleChain.some(
(link) => agentSessionProviderHandleRoot(link.handle) === root
)
const holdsSameConversation = agentSessionProviderHandleRoot(handle) === root
if (holdsSameConversation) {
throw agentSessionRefusalError('agent_session_conflict', {
reason: 'conversationHeldElsewhere'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, it } from 'vitest'
import { providerContextRecordFixture } from '../../../shared/agent-session-provider-context.test-fixture'
import { lookupOrcaAgentSession } from './structured-session-mail-address'

it('corrects a provider id to its visible owner after an older host adopted an archived context', () => {
const original = providerContextRecordFixture('codex')
const adopted = { ...providerContextRecordFixture('codex', [1]), sessionId: 'adopted-chat' }
for (const records of [
[original, adopted],
[adopted, original]
]) {
const store = {
getRecord: (id: string) => records.find((record) => record.sessionId === id) ?? null,
listRecords: () => records
}
expect(lookupOrcaAgentSession(store, 'context-0')).toEqual({
kind: 'provider-id',
orcaSessionId: adopted.sessionId
})
expect(lookupOrcaAgentSession(store, original.sessionId)).toEqual({
kind: 'found',
record: original
})
expect(lookupOrcaAgentSession(store, 'missing')).toEqual({ kind: 'unknown' })
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { agentSessionProviderClaims } from '../../../shared/agent-session-provider-claims'
import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address'
import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes'
import type { OrchestrationDb } from './db'
Expand All @@ -35,9 +36,9 @@ export function lookupOrcaAgentSession(
if (record) {
return { kind: 'found', record }
}
const owner = store
.listRecords()
.find((candidate) => candidate.providerHandleChain.some(({ handle }) => handle.nativeId === id))
const owner = agentSessionProviderClaims(store.listRecords()).find(
({ handle }) => handle.nativeId === id
)?.record
return owner ? { kind: 'provider-id', orcaSessionId: owner.sessionId } : { kind: 'unknown' }
}

Expand Down
2 changes: 2 additions & 0 deletions src/shared/agent-session-legacy-handoff-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
AgentSessionRecord
} from './agent-session-record'
import type { PersistedAgentSessionProviderHandleLink } from './agent-session-provider-handle'
import type { PersistedAgentSessionProviderContextHistory } from './agent-session-provider-context-history'

type LegacyHandoffRuntimeKind = 'tui'
type LegacyHandoffStage = 'preparing' | 'old-owner-stopped' | 'manual-recovery'
Expand All @@ -36,6 +37,7 @@ export type PersistedAgentSessionRecord = Omit<
> & {
lease: PersistedAgentSessionLease
providerHandleChain: PersistedAgentSessionProviderHandleLink[]
providerContextHistory?: PersistedAgentSessionProviderContextHistory
}

export function isPersistedAgentSessionRuntimeKind(
Expand Down
35 changes: 35 additions & 0 deletions src/shared/agent-session-provider-claims.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, it } from 'vitest'
import { agentSessionProviderClaims } from './agent-session-provider-claims'
import { providerContextRecordFixture } from './agent-session-provider-context.test-fixture'

it('keeps archives as reservations and prefers the visible owner regardless of record order', () => {
const original = providerContextRecordFixture('codex')
const adopted = { ...providerContextRecordFixture('codex', [1]), sessionId: 'adopted-chat' }
for (const records of [
[original, adopted],
[adopted, original]
]) {
expect(
agentSessionProviderClaims(records).filter((claim) => claim.handle.nativeId === 'context-0')
).toEqual([{ record: adopted, handle: adopted.providerHandleChain[0].handle, archived: false }])
}
expect(
agentSessionProviderClaims([original]).find((claim) => claim.handle.nativeId === 'context-0')
?.archived
).toBe(true)
})

it('does not hide conflicting visible claims, and picks a stable archive-only display owner', () => {
const first = { ...providerContextRecordFixture('codex'), sessionId: 'a-chat' }
const second = { ...providerContextRecordFixture('codex'), sessionId: 'z-chat' }
for (const records of [
[first, second],
[second, first]
]) {
const claims = agentSessionProviderClaims(records)
expect(claims.filter((claim) => claim.handle.nativeId === 'context-1')).toHaveLength(2)
expect(claims.find((claim) => claim.handle.nativeId === 'context-0')?.record.sessionId).toBe(
'a-chat'
)
}
})
44 changes: 44 additions & 0 deletions src/shared/agent-session-provider-claims.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
agentSessionProviderContextStart,
agentSessionProviderHandleRoot,
type AgentSessionProviderHandle
} from './agent-session-provider-handle'
import type { AgentSessionRecord } from './agent-session-record'

export type AgentSessionProviderClaim = {
record: AgentSessionRecord
handle: AgentSessionProviderHandle
archived: boolean
}

/** A downgraded host can adopt an invisible archive; its visible claim wins on upgrade. */
export function agentSessionProviderClaims(
records: Iterable<AgentSessionRecord>
): AgentSessionProviderClaim[] {
const byRoot = new Map<string, Map<string, AgentSessionProviderClaim>>()
for (const record of records) {
const start = agentSessionProviderContextStart(record.providerHandleChain)
for (const [index, link] of record.providerHandleChain.entries()) {
const root = agentSessionProviderHandleRoot(link.handle)
const owners = byRoot.get(root) ?? new Map<string, AgentSessionProviderClaim>()
const claim = { record, handle: link.handle, archived: index < start }
const prior = owners.get(record.sessionId)
if (!prior || !claim.archived) {
owners.set(record.sessionId, claim)
}
byRoot.set(root, owners)
}
}
return [...byRoot.values()].flatMap((owners) => {
const claims = [...owners.values()]
const visible = claims.filter((claim) => !claim.archived)
if (visible.length > 0) {
return visible
}
// Archives reserve identity; their display owner must not depend on database insertion order.
const first = claims.reduce((left, right) =>
left.record.sessionId < right.record.sessionId ? left : right
)
return [first]
})
}
111 changes: 111 additions & 0 deletions src/shared/agent-session-provider-context-history.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
appendAgentSessionProviderHandleLink,
isAgentSessionProviderHandleChain
} from './agent-session-provider-handle'
import {
codexProviderHandle,
agentSessionProviderHandleKey
} from './agent-session-provider-handle-encoding'
import { decodeProviderContextHistory } from './agent-session-provider-context-history'
import { providerContextRecordFixture } from './agent-session-provider-context.test-fixture'
import {
decodePersistedAgentSessionRecord,
encodeAgentSessionRecord
} from './agent-session-record-stored-form'
import { isPersistedAgentSessionRecord } from './agent-session-record'

describe.each(['claude', 'codex'] as const)('%s retained provider contexts', (provider) => {
it('stores disjoint segments and restores truthful creation reasons', () => {
const record = providerContextRecordFixture(provider, [2, 2, 3])
const stored = encodeAgentSessionRecord(record)
expect(stored.providerHandleChain).toHaveLength(3)
expect(stored.providerHandleChain[0].replaces).toBeUndefined()
expect(stored.providerContextHistory?.links).toHaveLength(4)
expect(isPersistedAgentSessionRecord(stored)).toBe(true)
const decoded = decodePersistedAgentSessionRecord(stored).record
expect(decoded).toEqual(record)
expect(decoded).not.toHaveProperty('providerContextHistory')
expect(encodeAgentSessionRecord(decoded)).toEqual(stored)
})

it('accepts independently full archived and visible contexts', () => {
const record = providerContextRecordFixture(provider, [256, 256])
const stored = encodeAgentSessionRecord(record)
expect(stored.providerHandleChain).toHaveLength(256)
expect(stored.providerContextHistory?.links).toHaveLength(256)
expect(isPersistedAgentSessionRecord(stored)).toBe(true)
expect(decodePersistedAgentSessionRecord(stored).record).toEqual(record)
expect(
isAgentSessionProviderHandleChain(
providerContextRecordFixture(provider, [257, 1]).providerHandleChain
)
).toBe(false)
expect(() =>
appendAgentSessionProviderHandleLink(record.providerHandleChain, {
...record.providerHandleChain[511],
linkId: 'overflow',
mintedAtFence: 513
})
).toThrow('agent_session_provider_handle_chain_overflow')
})

it('rejects malformed archives and disagreement across the seam', () => {
const stored = encodeAgentSessionRecord(providerContextRecordFixture(provider))
const history = stored.providerContextHistory
if (!history) {
throw new Error('fixture must archive its earlier context')
}
for (const corrupt of [
null,
{ ...history, version: 2 },
{ ...history, links: [] },
{ ...history, links: [{ version: 1, links: history.links }] },
{ ...history, replacement: { ...history.replacement, key: 'codex:"elsewhere"' } },
{ ...history, replacement: { ...history.replacement, reason: '' } },
{ ...history, links: [...history.links, history.links[0]] }
]) {
const malformed = { ...stored, providerContextHistory: corrupt }
expect(isPersistedAgentSessionRecord(malformed)).toBe(false)
expect(decodeProviderContextHistory(stored.providerHandleChain, corrupt)).toBeNull()
}
expect(isPersistedAgentSessionRecord({ ...stored, providerHandleChain: [] })).toBe(false)
const first = stored.providerHandleChain[0]
expect(
isPersistedAgentSessionRecord({
...stored,
providerHandleChain: [{ ...first, linkId: history.links[0].linkId }]
})
).toBe(false)
expect(
isPersistedAgentSessionRecord({
...stored,
providerHandleChain: [{ ...first, mintedAtFence: 0 }]
})
).toBe(false)
})
})

it('retains the replaced conversation when the fresh unsaved creation is superseded', () => {
const record = providerContextRecordFixture('codex')
const head = record.providerHandleChain.at(-1)
if (!head) {
throw new Error('fixture must have a head')
}
const chain = appendAgentSessionProviderHandleLink(record.providerHandleChain, {
linkId: 'superseding',
handle: codexProviderHandle('third-context'),
origin: 'created',
mintedAtFence: 4,
observedAt: 4000,
supersedesKey: agentSessionProviderHandleKey(head.handle)
})
expect(chain.at(-1)?.replaces).toEqual(head.replaces)
expect(isAgentSessionProviderHandleChain(chain)).toBe(true)
const next = {
...record,
providerHandleChain: chain,
lease: { ...record.lease, runtimeFence: 4, provenHandleLinkId: 'superseding' }
}
expect(decodePersistedAgentSessionRecord(encodeAgentSessionRecord(next)).record).toEqual(next)
})
78 changes: 78 additions & 0 deletions src/shared/agent-session-provider-context-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
agentSessionProviderContextStart,
decodePersistedAgentSessionProviderHandleChain,
encodePersistedAgentSessionProviderHandleChain,
isAgentSessionProviderHandleChain,
MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS,
type AgentSessionProviderHandleChain,
type AgentSessionProviderHandleLink,
type PersistedAgentSessionProviderHandleLink
} from './agent-session-provider-handle'
import type { AgentSessionProviderHandleReplacement } from './agent-session-provider-handle-replacement'

export type PersistedAgentSessionProviderContextHistory = {
version: 1
links: PersistedAgentSessionProviderHandleLink[]
replacement: AgentSessionProviderHandleReplacement
}

/** Older writers see only the current context and preserve the record's disjoint archive. */
export function encodeProviderContextHistory(chain: AgentSessionProviderHandleChain): {
providerHandleChain: PersistedAgentSessionProviderHandleLink[]
providerContextHistory?: PersistedAgentSessionProviderContextHistory
} {
const start = agentSessionProviderContextStart(chain)
const replacement = chain[start]?.replaces
if (!replacement) {
return { providerHandleChain: encodePersistedAgentSessionProviderHandleChain(chain) }
}
const providerHandleChain = encodePersistedAgentSessionProviderHandleChain(chain.slice(start))
const { replaces: _replacement, ...first } = providerHandleChain[0]
providerHandleChain[0] = first
return {
providerHandleChain,
providerContextHistory: {
version: 1,
links: encodePersistedAgentSessionProviderHandleChain(chain.slice(0, start)),
replacement
}
}
}

/** Join once, then validate the complete provenance, including the archived/visible seam. */
export function decodeProviderContextHistory(
visible: unknown,
history: unknown
): AgentSessionProviderHandleLink[] | null {
const current = decodePersistedAgentSessionProviderHandleChain(visible)
if (!current || history === undefined) {
return current
}
if (
typeof history !== 'object' ||
history === null ||
!('version' in history) ||
history.version !== 1 ||
!('links' in history) ||
!('replacement' in history)
) {
return null
}
const earlier = decodePersistedAgentSessionProviderHandleChain(history.links)
const first = current[0]
if (
!earlier?.length ||
!first ||
first.origin !== 'created' ||
current.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS ||
current.some((link) => link.replaces !== undefined)
) {
return null
}
const joined: unknown[] = [
...earlier,
{ ...first, replaces: history.replacement },
...current.slice(1)
]
return isAgentSessionProviderHandleChain(joined) ? joined : null
}
Loading
Loading