mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Extract conversation switch foundations without activating them (#34384)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
it('anchors the commit on receiver preparation, so network and queued delay consume authority without timer delivery', () => {
|
||||
let now = 10
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
const preparations = new DaemonPtySpawnPreparations(async () => {})
|
||||
const receipt = preparations.prepareSwitch('pane', 'connection', 'prepare', 'click')
|
||||
now = 20
|
||||
const commit = preparations.commitSwitch('pane', 'connection', 'commit', {
|
||||
invocationId: 'click',
|
||||
anchor: receipt.anchor,
|
||||
remainingMs: 100
|
||||
})
|
||||
expect(preparations.isCanceled(commit)).toBe(false)
|
||||
// a+r = 110; receipt-relative 120 would incorrectly allow this continuation.
|
||||
now = 111
|
||||
expect(preparations.isCanceled(commit)).toBe(true)
|
||||
preparations.finish('pane', commit)
|
||||
})
|
||||
it('rejects another connection and a reused anchor while the actual selected preparation remains owned', () => {
|
||||
const preparations = new DaemonPtySpawnPreparations(async () => {})
|
||||
const receipt = preparations.prepareSwitch('pane', 'connection', 'prepare', 'click')
|
||||
const execution = { invocationId: 'click', anchor: receipt.anchor, remainingMs: 1000 }
|
||||
expect(() => preparations.commitSwitch('pane', 'different', 'commit', execution)).toThrow()
|
||||
const selected = preparations.commitSwitch('pane', 'connection', 'commit', execution)
|
||||
expect(() => preparations.commitSwitch('pane', 'connection', 'duplicate', execution)).toThrow()
|
||||
expect(preparations.isCanceled(selected)).toBe(false)
|
||||
preparations.finish('pane', selected)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { TerminalAttachCanceledError } from './types'
|
||||
|
||||
export type PendingPtySpawnPreparation = {
|
||||
canceled: boolean
|
||||
switchAnchor?: { token: string; invocationId: string; at: number; deadline: number }
|
||||
// Unlike the polled canceled flag, the signal interrupts in-flight cwd probes on dead shares.
|
||||
readonly controller: AbortController
|
||||
cancelTimer?: ReturnType<typeof setTimeout>
|
||||
@@ -52,6 +53,72 @@ export class DaemonPtySpawnPreparations {
|
||||
return preparation
|
||||
}
|
||||
|
||||
prepareSwitch(sessionId: string, clientId: string, requestId: string, invocationId: string) {
|
||||
const preparation = this.register(sessionId, clientId, requestId, undefined)
|
||||
preparation.switchAnchor = {
|
||||
token: requestId,
|
||||
invocationId,
|
||||
at: performance.now(),
|
||||
deadline: Infinity
|
||||
}
|
||||
preparation.cancelTimer = setTimeout(() => {
|
||||
this.cancelPreparation(preparation)
|
||||
this.finish(sessionId, preparation)
|
||||
}, 720_000)
|
||||
preparation.cancelTimer.unref?.()
|
||||
return { anchor: requestId }
|
||||
}
|
||||
|
||||
commitSwitch(
|
||||
sessionId: string,
|
||||
clientId: string,
|
||||
requestId: string,
|
||||
execution: { invocationId: string; anchor?: string; remainingMs?: number }
|
||||
): PendingPtySpawnPreparation {
|
||||
const preparation = [...(this.pending.get(sessionId) ?? [])].find(
|
||||
(entry) =>
|
||||
entry.clientId === clientId &&
|
||||
entry.switchAnchor?.token === execution.anchor &&
|
||||
entry.switchAnchor?.invocationId === execution.invocationId
|
||||
)
|
||||
const anchor = preparation?.switchAnchor
|
||||
if (
|
||||
!preparation ||
|
||||
!anchor ||
|
||||
preparation.canceled ||
|
||||
!Number.isFinite(execution.remainingMs) ||
|
||||
execution.remainingMs! <= 0 ||
|
||||
execution.remainingMs! > 720_000 ||
|
||||
anchor.deadline !== Infinity
|
||||
) {
|
||||
throw new TerminalAttachCanceledError(sessionId)
|
||||
}
|
||||
anchor.deadline = anchor.at + execution.remainingMs!
|
||||
preparation.requestId = requestId
|
||||
if (performance.now() >= anchor.deadline) {
|
||||
this.cancelPreparation(preparation)
|
||||
this.finish(sessionId, preparation)
|
||||
throw new TerminalAttachCanceledError(sessionId)
|
||||
}
|
||||
if (preparation.cancelTimer) {
|
||||
clearTimeout(preparation.cancelTimer)
|
||||
}
|
||||
preparation.cancelTimer = setTimeout(
|
||||
() => this.cancelPreparation(preparation),
|
||||
anchor.deadline - performance.now()
|
||||
)
|
||||
preparation.cancelTimer.unref?.()
|
||||
return preparation
|
||||
}
|
||||
|
||||
isCanceled(preparation: PendingPtySpawnPreparation): boolean {
|
||||
return (
|
||||
preparation.canceled ||
|
||||
(preparation.switchAnchor !== undefined &&
|
||||
performance.now() >= preparation.switchAnchor.deadline)
|
||||
)
|
||||
}
|
||||
|
||||
async prepareUnlessCanceled(
|
||||
sessionId: string,
|
||||
preparation: PendingPtySpawnPreparation
|
||||
@@ -62,7 +129,7 @@ export class DaemonPtySpawnPreparations {
|
||||
? Promise.race([this.preparePtySpawn(), cancellation.promise])
|
||||
: this.preparePtySpawn()
|
||||
await preparationTask
|
||||
if (preparation.canceled) {
|
||||
if (this.isCanceled(preparation)) {
|
||||
throw new TerminalAttachCanceledError(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import type { StructuredAgentId } from '../../../shared/agent-session-provider-h
|
||||
import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import type { StructuredAgentSessionStartupLimits } from './structured-agent-session-startup-attempt-contract'
|
||||
import type { AgentChatPermissionChoice } from '../../../shared/agent-chat-permission-mode'
|
||||
import type { AgentSessionSwitchResult } from '../../../shared/agent-session-switch-execution'
|
||||
|
||||
export type StructuredAgentSessionCaller = { callerKey: string }
|
||||
|
||||
@@ -116,6 +117,10 @@ export type StructuredAgentSessionEndedChild = StructuredAgentSessionProviderChi
|
||||
|
||||
/** Conversation state outlives its provider children. */
|
||||
export type StructuredAgentSessionHostSession = {
|
||||
switchCommand?: {
|
||||
invocationId: string
|
||||
completion: Promise<AgentSessionSwitchResult>
|
||||
}
|
||||
/** Enter only through session-map set, which binds delivery. */
|
||||
readonly journal: AgentSessionJournal
|
||||
params: AgentSessionAttachParams
|
||||
@@ -161,6 +166,8 @@ export type StructuredAgentSessionHostDeps = {
|
||||
hasOpenDispatch?: (
|
||||
record: Pick<AgentSessionRecord, 'sessionId' | 'location' | 'conversationCommand'>
|
||||
) => boolean
|
||||
/** Explicit switch admission also includes direct chat assignments, even at rest. */
|
||||
hasOwedSwitchWork?: (record: AgentSessionRecord) => boolean
|
||||
/** Advisory tab-hidden notice; failures are logged. */
|
||||
onSessionTabHidden?: (sessionId: string) => void
|
||||
/** Throws while a terminal may still write the record's head conversation; run before a spawn. */
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { StructuredAgentSessionConversationLifetimeHost } from './structured-agent-session-conversation-lifetime-host'
|
||||
import {
|
||||
AgentSessionRefusalError,
|
||||
agentSessionRefusalError
|
||||
} from '../../../shared/agent-session-wire-refusals'
|
||||
import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission'
|
||||
import type {
|
||||
AgentSessionSwitchDeadline,
|
||||
AgentSessionSwitchResult,
|
||||
AgentSessionSwitchReceipt
|
||||
} from '../../../shared/agent-session-switch-execution'
|
||||
|
||||
// Commands outlive the read handle they may close themselves.
|
||||
export type SwitchCommand = {
|
||||
invocationId: string
|
||||
generation: string
|
||||
settled: boolean
|
||||
present: (view: Extract<AgentSessionSwitchResult, { status: 'present' }>) => void
|
||||
presentation: Promise<AgentSessionSwitchResult>
|
||||
shown: Promise<boolean>
|
||||
acknowledge: (shown: boolean) => void
|
||||
opened: Promise<AgentSessionSwitchResult>
|
||||
openedResult: (result: AgentSessionSwitchResult) => void
|
||||
deadline: AgentSessionSwitchDeadline
|
||||
revokeOnSend: boolean
|
||||
retain?: () => Promise<void>
|
||||
retentionAllowed: boolean
|
||||
completion: Promise<AgentSessionSwitchResult>
|
||||
physical?: Promise<void>
|
||||
cleanupTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
export function selectStructuredSessionSwitchCommand(args: {
|
||||
host: StructuredAgentSessionConversationLifetimeHost
|
||||
commands: Map<string, SwitchCommand>
|
||||
isDisposed: () => boolean
|
||||
id: string
|
||||
invocationId: string
|
||||
deadline: AgentSessionSwitchDeadline
|
||||
run: () => Promise<AgentSessionSwitchResult>
|
||||
revokeOnSend: boolean
|
||||
phase?: { observe?: boolean; receipt?: AgentSessionSwitchReceipt }
|
||||
onRetained?: (check: () => void) => Promise<void>
|
||||
allowUncreated: boolean
|
||||
}): SwitchCommand {
|
||||
const {
|
||||
host,
|
||||
commands,
|
||||
isDisposed,
|
||||
id,
|
||||
invocationId,
|
||||
deadline,
|
||||
run,
|
||||
revokeOnSend,
|
||||
phase,
|
||||
onRetained,
|
||||
allowUncreated
|
||||
} = args
|
||||
const { sessions, serialize } = host
|
||||
const deps = () => host.context().deps
|
||||
deadline.check()
|
||||
const prior = commands.get(id)
|
||||
if (prior) {
|
||||
if (prior.invocationId !== invocationId && !prior.settled) {
|
||||
throw agentSessionRefusalError('agent_session_operation_invalid', {
|
||||
reason: 'handoffInFlight'
|
||||
})
|
||||
}
|
||||
if (prior.invocationId === invocationId) {
|
||||
return prior
|
||||
}
|
||||
prior.retentionAllowed = false
|
||||
clearTimeout(prior.cleanupTimer)
|
||||
prior.deadline.revoke()
|
||||
commands.delete(id)
|
||||
}
|
||||
if (phase?.observe || phase?.receipt) {
|
||||
throw agentSessionRefusalError('agent_session_operation_unknown', {
|
||||
reason: 'outcomeUnknown'
|
||||
})
|
||||
}
|
||||
const session = sessions.get(id)
|
||||
if (!deps().store.getRecord(id) && !allowUncreated) {
|
||||
throw new AgentSessionRefusalError(AGENT_SESSION_NOT_ATTACHED)
|
||||
}
|
||||
let present!: (view: Extract<AgentSessionSwitchResult, { status: 'present' }>) => void
|
||||
let acknowledge!: (shown: boolean) => void
|
||||
let openedResult!: (result: AgentSessionSwitchResult) => void
|
||||
const presentation = new Promise<Extract<AgentSessionSwitchResult, { status: 'present' }>>(
|
||||
(resolve) => {
|
||||
present = resolve
|
||||
}
|
||||
)
|
||||
const shown = new Promise<boolean>((resolve) => {
|
||||
acknowledge = resolve
|
||||
})
|
||||
const opened = new Promise<AgentSessionSwitchResult>((resolve) => {
|
||||
openedResult = resolve
|
||||
})
|
||||
const command: SwitchCommand = {
|
||||
invocationId,
|
||||
generation: randomUUID(),
|
||||
settled: false,
|
||||
deadline,
|
||||
revokeOnSend,
|
||||
retentionAllowed: true,
|
||||
retain: async () => {
|
||||
const check = () => {
|
||||
if (!command.retentionAllowed || commands.get(id) !== command) {
|
||||
throw agentSessionRefusalError('agent_session_operation_expired', {
|
||||
reason: 'operationExpired'
|
||||
})
|
||||
}
|
||||
}
|
||||
check()
|
||||
await onRetained?.(check)
|
||||
},
|
||||
present,
|
||||
acknowledge,
|
||||
presentation,
|
||||
shown,
|
||||
opened,
|
||||
openedResult,
|
||||
completion: Promise.resolve().then(async () => {
|
||||
if (deps().store.getRecord(id)) {
|
||||
await serialize(id, async () => {
|
||||
deadline.check()
|
||||
const opened = await host.open(id)
|
||||
deadline.check()
|
||||
if (opened) {
|
||||
opened.switchCommand = command
|
||||
}
|
||||
})
|
||||
}
|
||||
try {
|
||||
const result = await run()
|
||||
if (revokeOnSend && (result.status === 'refused' || result.status === 'uncertain')) {
|
||||
await command.retain?.()
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if (revokeOnSend) {
|
||||
await command.retain?.().catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
commands.set(id, command)
|
||||
const retainOnExpiry = () => {
|
||||
if (revokeOnSend) {
|
||||
void command.retain?.().catch(() => {})
|
||||
}
|
||||
}
|
||||
deadline.signal.addEventListener('abort', retainOnExpiry, { once: true })
|
||||
if (session) {
|
||||
session.switchCommand = command
|
||||
}
|
||||
host.context().publishStatus?.(id)
|
||||
void command.completion
|
||||
.finally(() => {
|
||||
command.settled = true
|
||||
deadline.dispose()
|
||||
for (const [key, current] of commands) {
|
||||
if (current === command && sessions.get(key)?.switchCommand === command) {
|
||||
delete sessions.get(key)!.switchCommand
|
||||
}
|
||||
}
|
||||
host.context().publishStatus?.(id)
|
||||
if (isDisposed()) {
|
||||
return
|
||||
}
|
||||
command.cleanupTimer = setTimeout(
|
||||
() => {
|
||||
void Promise.resolve(command.physical).then(() => {
|
||||
command.retentionAllowed = false
|
||||
deadline.signal.removeEventListener('abort', retainOnExpiry)
|
||||
for (const [key, current] of commands) {
|
||||
if (current === command) {
|
||||
commands.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
Math.max(1, deadline.remaining())
|
||||
)
|
||||
command.cleanupTimer.unref?.()
|
||||
})
|
||||
.catch(() => {})
|
||||
return command
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { StructuredAgentSessionConversationLifetimeHost } from './structured-agent-session-conversation-lifetime-host'
|
||||
import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
|
||||
import type {
|
||||
AgentSessionSwitchDeadline,
|
||||
AgentSessionSwitchResult,
|
||||
AgentSessionSwitchReceipt
|
||||
} from '../../../shared/agent-session-switch-execution'
|
||||
import {
|
||||
selectStructuredSessionSwitchCommand,
|
||||
type SwitchCommand
|
||||
} from './structured-agent-session-switch-command-selection'
|
||||
|
||||
export function createStructuredAgentSessionSwitchCommands(
|
||||
host: StructuredAgentSessionConversationLifetimeHost,
|
||||
isDisposed: () => boolean
|
||||
) {
|
||||
const { serialize } = host
|
||||
const deps = () => host.context().deps
|
||||
const commands = new Map<string, SwitchCommand>()
|
||||
const unsubscribeAcceptance = deps().store.onAcceptedSend((id) => {
|
||||
const command = commands.get(id)
|
||||
if (!command?.revokeOnSend || command.settled) {
|
||||
return
|
||||
}
|
||||
command.deadline.revoke(
|
||||
agentSessionRefusalError('agent_session_operation_invalid', {
|
||||
reason: 'messagesUnsettled'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
controls: {
|
||||
runSwitch: async (
|
||||
id: string,
|
||||
invocationId: string,
|
||||
deadline: AgentSessionSwitchDeadline,
|
||||
run: () => Promise<AgentSessionSwitchResult>,
|
||||
revokeOnSend = true,
|
||||
phase?: {
|
||||
observe?: boolean
|
||||
receipt?: AgentSessionSwitchReceipt
|
||||
},
|
||||
onRetained?: (check: () => void) => Promise<void>,
|
||||
allowUncreated = false,
|
||||
onConverged?: (generation: string) => void
|
||||
): Promise<AgentSessionSwitchResult> => {
|
||||
const selected = await deadline.wait(
|
||||
serialize(id, async () => {
|
||||
return selectStructuredSessionSwitchCommand({
|
||||
host,
|
||||
commands,
|
||||
isDisposed,
|
||||
id,
|
||||
invocationId,
|
||||
deadline,
|
||||
run,
|
||||
revokeOnSend,
|
||||
phase,
|
||||
onRetained,
|
||||
allowUncreated
|
||||
})
|
||||
})
|
||||
)
|
||||
if (selected.deadline !== deadline) {
|
||||
deadline.dispose()
|
||||
}
|
||||
if (phase?.receipt) {
|
||||
if (phase.receipt.generation !== selected.generation) {
|
||||
throw agentSessionRefusalError('agent_session_operation_invalid', {
|
||||
reason: 'handoffInFlight'
|
||||
})
|
||||
}
|
||||
if (phase.receipt.converged) {
|
||||
onConverged?.(selected.generation)
|
||||
return { status: 'completed', sessionId: id }
|
||||
}
|
||||
if (phase.receipt.retained) {
|
||||
selected.deadline.revoke(
|
||||
agentSessionRefusalError('agent_session_operation_invalid', {
|
||||
reason: 'messagesUnsettled'
|
||||
})
|
||||
)
|
||||
await selected.retain?.()
|
||||
return { status: 'completed', sessionId: id, sourceRetained: true }
|
||||
}
|
||||
selected.deadline.check()
|
||||
selected.acknowledge(phase.receipt.shown)
|
||||
return Promise.race([selected.completion, selected.opened])
|
||||
}
|
||||
return phase?.observe
|
||||
? selected.completion
|
||||
: Promise.race([selected.completion, selected.presentation])
|
||||
},
|
||||
adoptSwitch: (from: string, to: string) =>
|
||||
serialize(to, async () => {
|
||||
const command = commands.get(from)
|
||||
const prior = commands.get(to)
|
||||
command?.deadline.check()
|
||||
if (!command || (prior && prior !== command && !prior.settled)) {
|
||||
throw agentSessionRefusalError('agent_session_operation_invalid', {
|
||||
reason: 'handoffInFlight'
|
||||
})
|
||||
}
|
||||
commands.set(to, command)
|
||||
}),
|
||||
switchGeneration: (id: string) => commands.get(id)?.generation,
|
||||
retainSwitch: async (id: string) => {
|
||||
await commands.get(id)?.retain?.()
|
||||
},
|
||||
presentSwitch: async (
|
||||
id: string,
|
||||
view: Omit<
|
||||
Extract<AgentSessionSwitchResult, { status: 'present' }>,
|
||||
'status' | 'generation'
|
||||
>
|
||||
) => {
|
||||
const command = commands.get(id)
|
||||
if (!command) {
|
||||
throw agentSessionRefusalError('agent_session_operation_expired', {
|
||||
reason: 'operationExpired'
|
||||
})
|
||||
}
|
||||
command.deadline.check()
|
||||
command.present({ ...view, status: 'present', generation: command.generation })
|
||||
if (!(await command.deadline.wait(command.shown))) {
|
||||
throw agentSessionRefusalError('agent_session_operation_unknown', {
|
||||
reason: 'tabUnconfirmed'
|
||||
})
|
||||
}
|
||||
command.deadline.check()
|
||||
},
|
||||
openedSwitch: (id: string, result: AgentSessionSwitchResult) =>
|
||||
commands.get(id)?.openedResult(result),
|
||||
switchExecuting: (id: string) => Boolean(commands.get(id) && !commands.get(id)?.settled),
|
||||
trackSwitchPhysical: <T>(id: string, work: Promise<T>): Promise<T> => {
|
||||
const command = commands.get(id)
|
||||
if (command) {
|
||||
command.physical = work.then(
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
return work
|
||||
},
|
||||
waitSwitchPhysical: async (id: string): Promise<void> => {
|
||||
await commands.get(id)?.physical
|
||||
}
|
||||
},
|
||||
revoke: (id: string) => {
|
||||
const command = commands.get(id)
|
||||
if (command) {
|
||||
command.retentionAllowed = false
|
||||
command.deadline.revoke()
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
unsubscribeAcceptance()
|
||||
for (const command of commands.values()) {
|
||||
command.retentionAllowed = false
|
||||
clearTimeout(command.cleanupTimer)
|
||||
command.deadline.revoke()
|
||||
}
|
||||
commands.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { AgentSessionStoreState } from './agent-session-store-state'
|
||||
import type { AgentSessionStoreTransactions } from './agent-session-store-transactions'
|
||||
|
||||
export abstract class AgentSessionRecordPublication {
|
||||
constructor(protected readonly transactions: AgentSessionStoreTransactions) {}
|
||||
abstract holdsRecords(): boolean
|
||||
private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>()
|
||||
|
||||
protected readonly firstRecordListeners = new Set<() => void>()
|
||||
|
||||
/** Notify committed death evidence from every transition; listeners must not throw. */
|
||||
onDeathEvidence(listener: (sessionId: string) => void): () => void {
|
||||
this.deathEvidenceListeners.add(listener)
|
||||
return () => this.deathEvidenceListeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Notify first committed record; listeners must not throw. */
|
||||
onFirstRecord(listener: () => void): () => void {
|
||||
this.firstRecordListeners.add(listener)
|
||||
return () => this.firstRecordListeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Serialize draft mutations; publish to readers only after row commit. */
|
||||
protected transact = async <T>(
|
||||
apply: (draft: AgentSessionStoreState) => T,
|
||||
options?: { inMemoryWhenReadOnly?: boolean }
|
||||
): Promise<T> => {
|
||||
let proven: string[] = []
|
||||
let heldBefore = true
|
||||
const result = await this.transactions.transact((draft) => {
|
||||
heldBefore = this.holdsRecords()
|
||||
if (this.deathEvidenceListeners.size === 0) {
|
||||
return apply(draft)
|
||||
}
|
||||
const before = new Map(
|
||||
[...draft.records].map(([id, record]) => [id, record.lease.deathEvidence])
|
||||
)
|
||||
const applied = apply(draft)
|
||||
proven = [...draft.records]
|
||||
.filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id))
|
||||
.map(([id]) => id)
|
||||
return applied
|
||||
}, options)
|
||||
for (const sessionId of proven) {
|
||||
this.deathEvidenceListeners.forEach((listener) => listener(sessionId))
|
||||
}
|
||||
if (!heldBefore && this.holdsRecords()) {
|
||||
this.firstRecordListeners.forEach((listener) => listener())
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
/** Single-writer records/operation ledger in the host chat-journal database. */
|
||||
|
||||
import { backfillAgentSessionPermissionModes } from './agent-session/ownership/agent-session-permission-backfill'
|
||||
import { createAgentSessionConversationReceipts } from './agent-session-conversation-receipts'
|
||||
import { pinAgentSessionRecordLaunchDirectory } from './agent-session-record-launch-directory'
|
||||
@@ -86,19 +84,21 @@ import {
|
||||
setAgentSessionRecordConversationName,
|
||||
setAgentSessionRecordUserNameThrough
|
||||
} from './agent-session-record-conversation-name'
|
||||
import { AgentSessionRecordPublication } from './agent-session-record-publication'
|
||||
|
||||
/** Single-writer records/operation ledger in the host chat-journal database. */
|
||||
|
||||
export const AGENT_SESSION_LEASE_TTL_MS = 30_000,
|
||||
AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000
|
||||
|
||||
export class AgentSessionRecordStore {
|
||||
private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>()
|
||||
private readonly firstRecordListeners = new Set<() => void>()
|
||||
export class AgentSessionRecordStore extends AgentSessionRecordPublication {
|
||||
readonly conversationReceipts: ReturnType<typeof createAgentSessionConversationReceipts>
|
||||
|
||||
private constructor(
|
||||
private readonly transactions: AgentSessionStoreTransactions,
|
||||
transactions: AgentSessionStoreTransactions,
|
||||
readonly hostId: string
|
||||
) {
|
||||
super(transactions)
|
||||
this.founding = createAgentSessionFoundingStore(transactions, this.firstRecordListeners)
|
||||
this.conversationReceipts = createAgentSessionConversationReceipts(transactions)
|
||||
}
|
||||
@@ -155,8 +155,36 @@ export class AgentSessionRecordStore {
|
||||
this.state.sessionTabs?.tabIdFor(sessionId) ?? null
|
||||
|
||||
/** Persist chat-tab identity separately from rollback-sensitive profile tabs; explicit tabId restores hidden tabs under their old ids. */
|
||||
setSessionTabVisibility(sessionId: string, visible: boolean, tabId?: string): Promise<void> {
|
||||
return this.transact((draft) => setAgentSessionTabVisibility(draft, sessionId, visible, tabId))
|
||||
setSessionTabVisibility(
|
||||
sessionId: string,
|
||||
visible: boolean,
|
||||
tabId?: string,
|
||||
check?: () => void
|
||||
): Promise<void> {
|
||||
return this.transact((draft) => {
|
||||
check?.()
|
||||
return setAgentSessionTabVisibility(draft, sessionId, visible, tabId)
|
||||
})
|
||||
}
|
||||
|
||||
setSwitchedSessionTabVisibility(
|
||||
sessionId: string,
|
||||
tabId: string,
|
||||
visible: boolean,
|
||||
admit: () => boolean
|
||||
): Promise<boolean> {
|
||||
return this.transact((draft) => {
|
||||
if (
|
||||
(visible
|
||||
? draft.sessionTabs?.tabIdFor(sessionId) !== undefined
|
||||
: draft.sessionTabs?.tabIdFor(sessionId) !== tabId) ||
|
||||
!admit()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
setAgentSessionTabVisibility(draft, sessionId, visible, tabId)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/** Publish the complete tab index in one write; a partial index would drop remaining chats on restart. */
|
||||
@@ -199,10 +227,14 @@ export class AgentSessionRecordStore {
|
||||
isClaimKeyVerifiable = (keyId: string, now: number): boolean =>
|
||||
isAgentSessionClaimKeyVerifiable(this.state, keyId, now)
|
||||
|
||||
reserveOwner = (request: AgentSessionReserveRequest): Promise<AgentSessionReserveResult> =>
|
||||
this.transact((draft) =>
|
||||
commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS)
|
||||
)
|
||||
reserveOwner = (
|
||||
request: AgentSessionReserveRequest,
|
||||
check?: () => void
|
||||
): Promise<AgentSessionReserveResult> =>
|
||||
this.transact((draft) => {
|
||||
check?.()
|
||||
return commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS)
|
||||
})
|
||||
|
||||
readonly founding: ReturnType<typeof createAgentSessionFoundingStore>
|
||||
|
||||
@@ -302,6 +334,13 @@ export class AgentSessionRecordStore {
|
||||
submissionReceipt = (sessionId: string) =>
|
||||
this.transactions.receipt(() => {}, { submissionSessionId: sessionId })
|
||||
|
||||
onAcceptedSend = (listener: (sessionId: string) => void): (() => void) => {
|
||||
this.transactions.acceptedSendListeners.add(listener)
|
||||
return () => {
|
||||
this.transactions.acceptedSendListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
admittedSendCount = (sessionId: string) => this.transactions.admittedSendCount(sessionId)
|
||||
|
||||
commitOperationReceipt = (receipt: JournalOperationReceipt) =>
|
||||
@@ -334,46 +373,4 @@ export class AgentSessionRecordStore {
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/** Notify committed death evidence from every transition; listeners must not throw. */
|
||||
onDeathEvidence(listener: (sessionId: string) => void): () => void {
|
||||
this.deathEvidenceListeners.add(listener)
|
||||
return () => this.deathEvidenceListeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Notify first committed record; listeners must not throw. */
|
||||
onFirstRecord(listener: () => void): () => void {
|
||||
this.firstRecordListeners.add(listener)
|
||||
return () => this.firstRecordListeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Serialize draft mutations; publish to readers only after row commit. */
|
||||
private transact = async <T>(
|
||||
apply: (draft: AgentSessionStoreState) => T,
|
||||
options?: { inMemoryWhenReadOnly?: boolean }
|
||||
): Promise<T> => {
|
||||
let proven: string[] = []
|
||||
let heldBefore = true
|
||||
const result = await this.transactions.transact((draft) => {
|
||||
heldBefore = this.holdsRecords()
|
||||
if (this.deathEvidenceListeners.size === 0) {
|
||||
return apply(draft)
|
||||
}
|
||||
const before = new Map(
|
||||
[...draft.records].map(([id, record]) => [id, record.lease.deathEvidence])
|
||||
)
|
||||
const applied = apply(draft)
|
||||
proven = [...draft.records]
|
||||
.filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id))
|
||||
.map(([id]) => id)
|
||||
return applied
|
||||
}, options)
|
||||
for (const sessionId of proven) {
|
||||
this.deathEvidenceListeners.forEach((listener) => listener(sessionId))
|
||||
}
|
||||
if (!heldBefore && this.holdsRecords()) {
|
||||
this.firstRecordListeners.forEach((listener) => listener())
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Commit validated draft rows atomically before publication; failures discard the draft.
|
||||
// The promise queue preserves FIFO and defers BEGIN beyond the caller's open journal transaction.
|
||||
|
||||
import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database'
|
||||
import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer'
|
||||
import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure'
|
||||
@@ -33,6 +30,9 @@ import {
|
||||
type AgentSessionStoreRowWrites
|
||||
} from './agent-session-store-draft'
|
||||
|
||||
// Commit validated draft rows atomically before publication; failures discard the draft.
|
||||
// The promise queue preserves FIFO and defers BEGIN beyond the caller's open journal transaction.
|
||||
|
||||
// Identity-based diffs miss in-place edits; freeze rows in dev/tests to catch them.
|
||||
const FREEZE_ROWS = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
|
||||
|
||||
@@ -86,6 +86,7 @@ export class AgentSessionStoreTransactions {
|
||||
private queue: Promise<unknown> = Promise.resolve()
|
||||
// Reads/reopens do not admit work; a count survives handle eviction without replaying history.
|
||||
private readonly admittedSends = new Map<string, number>()
|
||||
readonly acceptedSendListeners = new Set<(sessionId: string) => void>()
|
||||
private published: AgentSessionStoreState
|
||||
/** Loaded takeover evidence; immutable for this runtime. */
|
||||
readonly replacedRuntimes: ReadonlyMap<string, AgentSessionReplacedRuntime>
|
||||
@@ -179,6 +180,9 @@ export class AgentSessionStoreTransactions {
|
||||
staged?.adopt()
|
||||
if (admitted) {
|
||||
this.admittedSends.set(admitted, this.admittedSendCount(admitted) + 1)
|
||||
for (const listener of this.acceptedSendListeners) {
|
||||
listener(admitted)
|
||||
}
|
||||
}
|
||||
staged = null
|
||||
admitted = undefined
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ export type StructuredAgentSessionReadSnapshot = {
|
||||
export type StructuredAgentSessionReadOwner = {
|
||||
publishCreated: (page: AgentSessionHistoryPage) => void
|
||||
activate: () => () => void
|
||||
resync?: () => void
|
||||
dispose: () => void
|
||||
getSnapshot: () => StructuredAgentSessionReadSnapshot
|
||||
loadOlder: () => Promise<NativeChatOlderPageResult>
|
||||
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
type AgentProviderSessionMetadata,
|
||||
agentProviderSessionsEqual
|
||||
} from '../../../../shared/agent-session-resume'
|
||||
import {
|
||||
AGENT_SESSION_HISTORY_MAX_LIMIT,
|
||||
type AgentSessionHistoryResult
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
oldestStructuredAgentSessionCursor,
|
||||
reduceStructuredAgentSession,
|
||||
type StructuredAgentSessionAction
|
||||
} from '../../../../shared/structured-agent-session-reducer'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { NATIVE_CHAT_INITIAL_LIMIT, type NativeChatOlderPageResult } from './native-chat-pagination'
|
||||
import {
|
||||
OLDER_PAGE_ANCHOR_ATTEMPTS,
|
||||
readStructuredAgentSessionOlderPage
|
||||
} from './structured-agent-session-older-page-read'
|
||||
import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport'
|
||||
import { createStructuredAgentSessionPRLinkObserver } from './structured-agent-session-pr-link-observer'
|
||||
import type {
|
||||
StructuredAgentSessionReadSnapshot,
|
||||
StructuredAgentSessionReadOwner
|
||||
} from './structured-agent-session-read-owner-contract'
|
||||
import {
|
||||
adoptStructuredReadOwner,
|
||||
forgetStructuredReadOwner
|
||||
} from './structured-agent-session-read-owner-registry'
|
||||
|
||||
export function countsTowardInitialHistory(item: AgentJournalRenderItem): boolean {
|
||||
return item.body.kind !== 'status' || !item.body.providerFrame
|
||||
}
|
||||
|
||||
export function createReadOwner(
|
||||
key: string,
|
||||
sessionId: string,
|
||||
target: RuntimeClientTarget
|
||||
): StructuredAgentSessionReadOwner {
|
||||
let snapshot: StructuredAgentSessionReadSnapshot = {
|
||||
state: EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
loadingOlder: false,
|
||||
olderHistoryGeneration: 0,
|
||||
current: false
|
||||
}
|
||||
let stopActiveRun: (() => void) | null = null
|
||||
const retiredHistoryRead = (): boolean => true
|
||||
let captureActiveHistoryReadGuard = (): (() => boolean) => retiredHistoryRead
|
||||
const activations = new Set<symbol>()
|
||||
const listeners = new Set<() => void>()
|
||||
const observePRLinks = createStructuredAgentSessionPRLinkObserver(sessionId, target)
|
||||
|
||||
const emit = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
const setSnapshot = (next: StructuredAgentSessionReadSnapshot): void => {
|
||||
if (next === snapshot) {
|
||||
return
|
||||
}
|
||||
snapshot = next
|
||||
emit()
|
||||
}
|
||||
const apply = (action: StructuredAgentSessionAction): void => {
|
||||
const state = reduceStructuredAgentSession(snapshot.state, action, Date.now())
|
||||
// Published even when the frame changed nothing: a caught-up resume is an empty batch.
|
||||
const current =
|
||||
snapshot.current ||
|
||||
(action.type === 'event' &&
|
||||
action.event.type !== 'end' &&
|
||||
!(action.event.type === 'batch' && action.event.hasNewer))
|
||||
if (state !== snapshot.state || current !== snapshot.current) {
|
||||
setSnapshot({ ...snapshot, state, current })
|
||||
}
|
||||
}
|
||||
const markStale = (): void => {
|
||||
if (snapshot.current) {
|
||||
setSnapshot({ ...snapshot, current: false })
|
||||
}
|
||||
}
|
||||
const setProviderSession = (providerSession: AgentProviderSessionMetadata | undefined): void => {
|
||||
if (!agentProviderSessionsEqual(undefined, snapshot.providerSession, providerSession)) {
|
||||
setSnapshot({ ...snapshot, providerSession })
|
||||
}
|
||||
}
|
||||
const clearLoadingOlder = (): void => {
|
||||
if (snapshot.loadingOlder) {
|
||||
setSnapshot({ ...snapshot, loadingOlder: false })
|
||||
}
|
||||
}
|
||||
const invalidateOlderPages = (): void => {
|
||||
setSnapshot({
|
||||
...snapshot,
|
||||
loadingOlder: false,
|
||||
olderHistoryGeneration: snapshot.olderHistoryGeneration + 1
|
||||
})
|
||||
}
|
||||
const hydrate = async (shouldStop: () => boolean): Promise<void> => {
|
||||
const result = await callStructuredAgentSession<AgentSessionHistoryResult>(
|
||||
target,
|
||||
'agentSession.history',
|
||||
{ sessionId, direction: 'tail', limit: AGENT_SESSION_HISTORY_MAX_LIMIT }
|
||||
)
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
setProviderSession(result.providerSession)
|
||||
if (!result.ok) {
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
apply({
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'reset',
|
||||
sessionId,
|
||||
reset: result.reset,
|
||||
page: result.page,
|
||||
fence: result.fence ?? 0
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
apply({ type: 'history-page', page: result.page })
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
let restored = snapshot.state.items.filter(countsTowardInitialHistory).length
|
||||
let anchorSlides = 0
|
||||
while (snapshot.state.hasOlder && restored < NATIVE_CHAT_INITIAL_LIMIT) {
|
||||
const oldest = oldestStructuredAgentSessionCursor(snapshot.state)
|
||||
if (!oldest || shouldStop()) {
|
||||
break
|
||||
}
|
||||
const missing = NATIVE_CHAT_INITIAL_LIMIT - restored
|
||||
const older = await callStructuredAgentSession<AgentSessionHistoryResult>(
|
||||
target,
|
||||
'agentSession.history',
|
||||
{
|
||||
sessionId,
|
||||
direction: 'before',
|
||||
cursor: oldest,
|
||||
limit: Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, missing)
|
||||
}
|
||||
)
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
if (!older.ok || older.page.window.oldest?.sequence === oldest.sequence) {
|
||||
break
|
||||
}
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
// Bound re-anchoring when head trimming makes older pages discontiguous.
|
||||
if (oldestStructuredAgentSessionCursor(snapshot.state)?.sequence !== oldest.sequence) {
|
||||
anchorSlides += 1
|
||||
if (anchorSlides >= OLDER_PAGE_ANCHOR_ATTEMPTS) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
apply({ type: 'older-page', requestedCursor: oldest, page: older.page })
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
restored = snapshot.state.items.filter(countsTowardInitialHistory).length
|
||||
}
|
||||
}
|
||||
|
||||
let olderPage: {
|
||||
shouldStop: () => boolean
|
||||
promise: Promise<NativeChatOlderPageResult>
|
||||
} | null = null
|
||||
|
||||
const start = (): void => {
|
||||
if (snapshot.state.epoch === null) {
|
||||
apply({ type: 'loading' })
|
||||
}
|
||||
const transport = startStructuredAgentSessionReadTransport({
|
||||
applyRequests: apply,
|
||||
applyEvent: (event, options) => {
|
||||
apply({ type: 'event', event, ...options })
|
||||
observePRLinks(event)
|
||||
},
|
||||
applyError: (message, refusal) => {
|
||||
// After loading, transport failures retain history while retrying; host refusals are explicit answers to show.
|
||||
if (snapshot.state.epoch !== null && refusal === undefined) {
|
||||
return
|
||||
}
|
||||
apply({ type: 'error', message, refusal })
|
||||
},
|
||||
getCursor: () => snapshot.state.cursor,
|
||||
onContactLost: markStale,
|
||||
onHistoryReadInvalidated: invalidateOlderPages,
|
||||
hydrate: snapshot.state.epoch === null ? hydrate : undefined,
|
||||
sessionId,
|
||||
target
|
||||
})
|
||||
captureActiveHistoryReadGuard = transport.captureHistoryReadGuard
|
||||
stopActiveRun = () => {
|
||||
captureActiveHistoryReadGuard = () => retiredHistoryRead
|
||||
transport.dispose()
|
||||
stopActiveRun = null
|
||||
markStale()
|
||||
}
|
||||
}
|
||||
|
||||
let owner: StructuredAgentSessionReadOwner
|
||||
const deleteIfUnused = (): void => {
|
||||
if (activations.size === 0 && listeners.size === 0) {
|
||||
forgetStructuredReadOwner(key, owner)
|
||||
}
|
||||
}
|
||||
owner = {
|
||||
publishCreated: (page) => {
|
||||
if (snapshot.state.epoch === null) {
|
||||
apply({ type: 'history-page', page })
|
||||
}
|
||||
},
|
||||
activate: () => {
|
||||
// Remounts and StrictMode re-run setup on an owner whose cleanup just forgot it.
|
||||
adoptStructuredReadOwner(key, owner)
|
||||
const token = Symbol(sessionId)
|
||||
activations.add(token)
|
||||
if (activations.size === 1) {
|
||||
start()
|
||||
}
|
||||
return () => {
|
||||
activations.delete(token)
|
||||
if (activations.size === 0) {
|
||||
stopActiveRun?.()
|
||||
deleteIfUnused()
|
||||
}
|
||||
}
|
||||
},
|
||||
resync: () => {
|
||||
if (activations.size === 0) {
|
||||
return
|
||||
}
|
||||
stopActiveRun?.()
|
||||
start()
|
||||
},
|
||||
dispose: () => {
|
||||
activations.clear()
|
||||
listeners.clear()
|
||||
stopActiveRun?.()
|
||||
},
|
||||
getSnapshot: () => snapshot,
|
||||
loadOlder: () => {
|
||||
// Share concurrent page requests so another caller’s refusal cannot look like exhaustion.
|
||||
if (olderPage && !olderPage.shouldStop()) {
|
||||
return olderPage.promise
|
||||
}
|
||||
const shouldStop = captureActiveHistoryReadGuard()
|
||||
if (shouldStop()) {
|
||||
return Promise.resolve('superseded')
|
||||
}
|
||||
if (!oldestStructuredAgentSessionCursor(snapshot.state) || !snapshot.state.hasOlder) {
|
||||
return Promise.resolve('exhausted')
|
||||
}
|
||||
setSnapshot({ ...snapshot, loadingOlder: true })
|
||||
const promise = readStructuredAgentSessionOlderPage({
|
||||
target,
|
||||
sessionId,
|
||||
getState: () => snapshot.state,
|
||||
apply,
|
||||
shouldStop
|
||||
})
|
||||
const page = { shouldStop, promise }
|
||||
olderPage = page
|
||||
void page.promise.finally(() => {
|
||||
if (olderPage !== page) {
|
||||
return
|
||||
}
|
||||
olderPage = null
|
||||
if (!shouldStop()) {
|
||||
clearLoadingOlder()
|
||||
}
|
||||
})
|
||||
return page.promise
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
adoptStructuredReadOwner(key, owner)
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
deleteIfUnused()
|
||||
}
|
||||
}
|
||||
}
|
||||
return owner
|
||||
}
|
||||
@@ -1,308 +1,22 @@
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume'
|
||||
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
|
||||
import {
|
||||
AGENT_SESSION_HISTORY_MAX_LIMIT,
|
||||
type AgentSessionHistoryResult
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
oldestStructuredAgentSessionCursor,
|
||||
reduceStructuredAgentSession,
|
||||
type StructuredAgentSessionAction
|
||||
} from '../../../../shared/structured-agent-session-reducer'
|
||||
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { NATIVE_CHAT_INITIAL_LIMIT, type NativeChatOlderPageResult } from './native-chat-pagination'
|
||||
import type { StructuredAgentSessionReadOwner } from './structured-agent-session-read-owner-contract'
|
||||
import {
|
||||
OLDER_PAGE_ANCHOR_ATTEMPTS,
|
||||
readStructuredAgentSessionOlderPage
|
||||
} from './structured-agent-session-older-page-read'
|
||||
import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport'
|
||||
import { createStructuredAgentSessionPRLinkObserver } from './structured-agent-session-pr-link-observer'
|
||||
getOrCreateStructuredReadOwner,
|
||||
structuredReadOwnerKey
|
||||
} from './structured-agent-session-read-owner-registry'
|
||||
import { createReadOwner } from './structured-agent-session-read-owner-create'
|
||||
|
||||
import type {
|
||||
StructuredAgentSessionReadSnapshot,
|
||||
StructuredAgentSessionReadOwner
|
||||
} from './structured-agent-session-read-owner-contract'
|
||||
export type {
|
||||
StructuredAgentSessionReadSnapshot,
|
||||
StructuredAgentSessionReadOwner
|
||||
} from './structured-agent-session-read-owner-contract'
|
||||
|
||||
import {
|
||||
adoptStructuredReadOwner,
|
||||
getOrCreateStructuredReadOwner,
|
||||
forgetStructuredReadOwner,
|
||||
structuredReadOwnerKey
|
||||
} from './structured-agent-session-read-owner-registry'
|
||||
export {
|
||||
findStructuredAgentSessionReadOwner,
|
||||
retireStructuredAgentSessionReadOwner,
|
||||
resetStructuredAgentSessionReadOwnersForTests
|
||||
} from './structured-agent-session-read-owner-registry'
|
||||
|
||||
function countsTowardInitialHistory(item: AgentJournalRenderItem): boolean {
|
||||
return item.body.kind !== 'status' || !item.body.providerFrame
|
||||
}
|
||||
|
||||
function createReadOwner(
|
||||
key: string,
|
||||
sessionId: string,
|
||||
target: RuntimeClientTarget
|
||||
): StructuredAgentSessionReadOwner {
|
||||
let snapshot: StructuredAgentSessionReadSnapshot = {
|
||||
state: EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
loadingOlder: false,
|
||||
olderHistoryGeneration: 0,
|
||||
current: false
|
||||
}
|
||||
let stopActiveRun: (() => void) | null = null
|
||||
const retiredHistoryRead = (): boolean => true
|
||||
let captureActiveHistoryReadGuard = (): (() => boolean) => retiredHistoryRead
|
||||
const activations = new Set<symbol>()
|
||||
const listeners = new Set<() => void>()
|
||||
const observePRLinks = createStructuredAgentSessionPRLinkObserver(sessionId, target)
|
||||
|
||||
const emit = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
const setSnapshot = (next: StructuredAgentSessionReadSnapshot): void => {
|
||||
if (next === snapshot) {
|
||||
return
|
||||
}
|
||||
snapshot = next
|
||||
emit()
|
||||
}
|
||||
const apply = (action: StructuredAgentSessionAction): void => {
|
||||
const state = reduceStructuredAgentSession(snapshot.state, action, Date.now())
|
||||
// Published even when the frame changed nothing: a caught-up resume is an empty batch.
|
||||
const current =
|
||||
snapshot.current ||
|
||||
(action.type === 'event' &&
|
||||
action.event.type !== 'end' &&
|
||||
!(action.event.type === 'batch' && action.event.hasNewer))
|
||||
if (state !== snapshot.state || current !== snapshot.current) {
|
||||
setSnapshot({ ...snapshot, state, current })
|
||||
}
|
||||
}
|
||||
const markStale = (): void => {
|
||||
if (snapshot.current) {
|
||||
setSnapshot({ ...snapshot, current: false })
|
||||
}
|
||||
}
|
||||
const setProviderSession = (providerSession: AgentProviderSessionMetadata | undefined): void => {
|
||||
if (!agentProviderSessionsEqual(undefined, snapshot.providerSession, providerSession)) {
|
||||
setSnapshot({ ...snapshot, providerSession })
|
||||
}
|
||||
}
|
||||
const clearLoadingOlder = (): void => {
|
||||
if (snapshot.loadingOlder) {
|
||||
setSnapshot({ ...snapshot, loadingOlder: false })
|
||||
}
|
||||
}
|
||||
const invalidateOlderPages = (): void => {
|
||||
setSnapshot({
|
||||
...snapshot,
|
||||
loadingOlder: false,
|
||||
olderHistoryGeneration: snapshot.olderHistoryGeneration + 1
|
||||
})
|
||||
}
|
||||
const hydrate = async (shouldStop: () => boolean): Promise<void> => {
|
||||
const result = await callStructuredAgentSession<AgentSessionHistoryResult>(
|
||||
target,
|
||||
'agentSession.history',
|
||||
{ sessionId, direction: 'tail', limit: AGENT_SESSION_HISTORY_MAX_LIMIT }
|
||||
)
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
setProviderSession(result.providerSession)
|
||||
if (!result.ok) {
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
apply({
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'reset',
|
||||
sessionId,
|
||||
reset: result.reset,
|
||||
page: result.page,
|
||||
fence: result.fence ?? 0
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
apply({ type: 'history-page', page: result.page })
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
let restored = snapshot.state.items.filter(countsTowardInitialHistory).length
|
||||
let anchorSlides = 0
|
||||
while (snapshot.state.hasOlder && restored < NATIVE_CHAT_INITIAL_LIMIT) {
|
||||
const oldest = oldestStructuredAgentSessionCursor(snapshot.state)
|
||||
if (!oldest || shouldStop()) {
|
||||
break
|
||||
}
|
||||
const missing = NATIVE_CHAT_INITIAL_LIMIT - restored
|
||||
const older = await callStructuredAgentSession<AgentSessionHistoryResult>(
|
||||
target,
|
||||
'agentSession.history',
|
||||
{
|
||||
sessionId,
|
||||
direction: 'before',
|
||||
cursor: oldest,
|
||||
limit: Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, missing)
|
||||
}
|
||||
)
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
if (!older.ok || older.page.window.oldest?.sequence === oldest.sequence) {
|
||||
break
|
||||
}
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
// Bound re-anchoring when head trimming makes older pages discontiguous.
|
||||
if (oldestStructuredAgentSessionCursor(snapshot.state)?.sequence !== oldest.sequence) {
|
||||
anchorSlides += 1
|
||||
if (anchorSlides >= OLDER_PAGE_ANCHOR_ATTEMPTS) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
apply({ type: 'older-page', requestedCursor: oldest, page: older.page })
|
||||
if (shouldStop()) {
|
||||
return
|
||||
}
|
||||
restored = snapshot.state.items.filter(countsTowardInitialHistory).length
|
||||
}
|
||||
}
|
||||
|
||||
let olderPage: {
|
||||
shouldStop: () => boolean
|
||||
promise: Promise<NativeChatOlderPageResult>
|
||||
} | null = null
|
||||
|
||||
const start = (): void => {
|
||||
if (snapshot.state.epoch === null) {
|
||||
apply({ type: 'loading' })
|
||||
}
|
||||
const transport = startStructuredAgentSessionReadTransport({
|
||||
applyRequests: apply,
|
||||
applyEvent: (event, options) => {
|
||||
apply({ type: 'event', event, ...options })
|
||||
observePRLinks(event)
|
||||
},
|
||||
applyError: (message, refusal) => {
|
||||
// After loading, transport failures retain history while retrying; host refusals are explicit answers to show.
|
||||
if (snapshot.state.epoch !== null && refusal === undefined) {
|
||||
return
|
||||
}
|
||||
apply({ type: 'error', message, refusal })
|
||||
},
|
||||
getCursor: () => snapshot.state.cursor,
|
||||
onContactLost: markStale,
|
||||
onHistoryReadInvalidated: invalidateOlderPages,
|
||||
hydrate: snapshot.state.epoch === null ? hydrate : undefined,
|
||||
sessionId,
|
||||
target
|
||||
})
|
||||
captureActiveHistoryReadGuard = transport.captureHistoryReadGuard
|
||||
stopActiveRun = () => {
|
||||
captureActiveHistoryReadGuard = () => retiredHistoryRead
|
||||
transport.dispose()
|
||||
stopActiveRun = null
|
||||
markStale()
|
||||
}
|
||||
}
|
||||
|
||||
let owner: StructuredAgentSessionReadOwner
|
||||
const deleteIfUnused = (): void => {
|
||||
if (activations.size === 0 && listeners.size === 0) {
|
||||
forgetStructuredReadOwner(key, owner)
|
||||
}
|
||||
}
|
||||
owner = {
|
||||
publishCreated: (page) => {
|
||||
if (snapshot.state.epoch === null) {
|
||||
apply({ type: 'history-page', page })
|
||||
}
|
||||
},
|
||||
activate: () => {
|
||||
// Remounts and StrictMode re-run setup on an owner whose cleanup just forgot it.
|
||||
adoptStructuredReadOwner(key, owner)
|
||||
const token = Symbol(sessionId)
|
||||
activations.add(token)
|
||||
if (activations.size === 1) {
|
||||
start()
|
||||
}
|
||||
return () => {
|
||||
activations.delete(token)
|
||||
if (activations.size === 0) {
|
||||
stopActiveRun?.()
|
||||
deleteIfUnused()
|
||||
}
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
activations.clear()
|
||||
listeners.clear()
|
||||
stopActiveRun?.()
|
||||
},
|
||||
getSnapshot: () => snapshot,
|
||||
loadOlder: () => {
|
||||
// Share concurrent page requests so another caller’s refusal cannot look like exhaustion.
|
||||
if (olderPage && !olderPage.shouldStop()) {
|
||||
return olderPage.promise
|
||||
}
|
||||
const shouldStop = captureActiveHistoryReadGuard()
|
||||
if (shouldStop()) {
|
||||
return Promise.resolve('superseded')
|
||||
}
|
||||
if (!oldestStructuredAgentSessionCursor(snapshot.state) || !snapshot.state.hasOlder) {
|
||||
return Promise.resolve('exhausted')
|
||||
}
|
||||
setSnapshot({ ...snapshot, loadingOlder: true })
|
||||
const promise = readStructuredAgentSessionOlderPage({
|
||||
target,
|
||||
sessionId,
|
||||
getState: () => snapshot.state,
|
||||
apply,
|
||||
shouldStop
|
||||
})
|
||||
const page = { shouldStop, promise }
|
||||
olderPage = page
|
||||
void page.promise.finally(() => {
|
||||
if (olderPage !== page) {
|
||||
return
|
||||
}
|
||||
olderPage = null
|
||||
if (!shouldStop()) {
|
||||
clearLoadingOlder()
|
||||
}
|
||||
})
|
||||
return page.promise
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
adoptStructuredReadOwner(key, owner)
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
deleteIfUnused()
|
||||
}
|
||||
}
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
export function getStructuredAgentSessionReadOwner(
|
||||
sessionId: string,
|
||||
target: RuntimeClientTarget
|
||||
|
||||
@@ -125,6 +125,32 @@ export function agentSessionOwnerObservationVerdict(
|
||||
: 'unverifiable'
|
||||
}
|
||||
|
||||
/** Founding is not an observed death; every reservation/recovery leaves a different tuple. */
|
||||
export function agentSessionLeaseNeverAcquired(lease: AgentSessionLease): boolean {
|
||||
return (
|
||||
lease.runtimeKind === 'native' &&
|
||||
lease.runtimeFence === 1 &&
|
||||
lease.minimumNextFence === undefined &&
|
||||
!lease.unreconciled &&
|
||||
lease.claimStatus === 'released' &&
|
||||
lease.ownerProcess === null &&
|
||||
lease.reservedSpawnToken === null &&
|
||||
lease.handoffStage === null &&
|
||||
lease.handoffOperationId === null &&
|
||||
lease.provenHandleLinkId === null &&
|
||||
lease.deathEvidence === null
|
||||
)
|
||||
}
|
||||
|
||||
export function agentSessionLeasePossiblyWriting(
|
||||
lease: AgentSessionLease | AgentSessionOwnerObservation
|
||||
): boolean {
|
||||
return (
|
||||
!('runtimeKind' in lease && agentSessionLeaseNeverAcquired(lease)) &&
|
||||
agentSessionOwnerObservationVerdict(lease) !== 'exited'
|
||||
)
|
||||
}
|
||||
|
||||
/** Nothing holds this lease: released, no handoff in flight, and reconciled since the last restart. */
|
||||
export function agentSessionLeaseIsReleased(lease: AgentSessionLease): boolean {
|
||||
return !lease.unreconciled && lease.claimStatus === 'released' && lease.handoffStage === null
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { AgentJournalItemBody } from './agent-session-journal-types'
|
||||
|
||||
export function isNativeConversationMessage(body: AgentJournalItemBody): boolean {
|
||||
return body.kind === 'message' && (body.role === 'user' || body.role === 'assistant')
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { AgentSessionWireRefusal } from './agent-session-wire'
|
||||
import { agentSessionRefusalError } from './agent-session-wire-refusals'
|
||||
import { waitForPromiseWithSignal } from './abort-signal-reason'
|
||||
|
||||
export const AGENT_SESSION_SWITCH_HOST_MS = 720_000
|
||||
export const AGENT_SESSION_SWITCH_CLIENT_MS = 735_000
|
||||
|
||||
export type AgentSessionSwitchResult =
|
||||
| {
|
||||
status: 'completed'
|
||||
sessionId: string
|
||||
terminal?: string
|
||||
sourceRetained?: boolean
|
||||
startupPending?: boolean
|
||||
}
|
||||
| {
|
||||
status: 'present'
|
||||
surface?: 'terminal'
|
||||
leafId?: string
|
||||
sessionId: string
|
||||
generation: string
|
||||
workspaceId: string
|
||||
tabId: string
|
||||
agent: 'codex'
|
||||
placement?: { groupId?: string; afterTabId?: string }
|
||||
}
|
||||
| {
|
||||
status: 'refused' | 'uncertain'
|
||||
refusal: AgentSessionWireRefusal
|
||||
sessionId: string
|
||||
failureItemId?: string
|
||||
}
|
||||
|
||||
export type AgentSessionSwitchReceipt = {
|
||||
generation: string
|
||||
shown: boolean
|
||||
retained?: boolean
|
||||
converged?: boolean
|
||||
}
|
||||
|
||||
/** Expiry revokes new effects; already dispatched owners still reconcile their real outcome. */
|
||||
export function agentSessionSwitchDeadline(
|
||||
now = () => performance.now(),
|
||||
budget = AGENT_SESSION_SWITCH_HOST_MS
|
||||
) {
|
||||
let expiresAt = now() + budget
|
||||
let dispatched = false
|
||||
let delegated = false
|
||||
const controller = new AbortController()
|
||||
let timer = setTimeout(() => controller.abort(expired()), budget)
|
||||
function expired() {
|
||||
return agentSessionRefusalError('agent_session_operation_expired', {
|
||||
reason: 'operationExpired'
|
||||
})
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
delegateSend: () => {
|
||||
if (controller.signal.aborted) {
|
||||
throw expired()
|
||||
}
|
||||
const dispatchExpiresAt = expiresAt
|
||||
if (!dispatched && !delegated) {
|
||||
if (now() >= expiresAt) {
|
||||
throw expired()
|
||||
}
|
||||
delegated = true
|
||||
clearTimeout(timer)
|
||||
// Main now owns unsent expiry; allow its actual-send receipt to cross IPC.
|
||||
timer = setTimeout(() => controller.abort(expired()), expiresAt - now() + budget)
|
||||
}
|
||||
return {
|
||||
expiresAt: dispatchExpiresAt,
|
||||
responseTimeoutMs: budget,
|
||||
...(dispatched ? { responseExpiresAt: expiresAt } : {})
|
||||
}
|
||||
},
|
||||
beforeSend: (sentAt = now()) => {
|
||||
if (controller.signal.aborted || (!dispatched && sentAt >= expiresAt)) {
|
||||
throw expired()
|
||||
}
|
||||
if (dispatched) {
|
||||
return
|
||||
}
|
||||
dispatched = true
|
||||
expiresAt = sentAt + budget
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => controller.abort(expired()), Math.max(0, expiresAt - now()))
|
||||
if (now() >= expiresAt) {
|
||||
controller.abort(expired())
|
||||
}
|
||||
},
|
||||
revoke: (reason: unknown = expired()) => controller.abort(reason),
|
||||
remaining: () => Math.max(0, expiresAt - now()),
|
||||
check: () => {
|
||||
if (controller.signal.aborted) {
|
||||
throw controller.signal.reason
|
||||
}
|
||||
if (now() >= expiresAt) {
|
||||
controller.abort(expired())
|
||||
throw controller.signal.reason
|
||||
}
|
||||
},
|
||||
wait: <T>(promise: Promise<T>) => waitForPromiseWithSignal(promise, controller.signal),
|
||||
dispose: () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
export type AgentSessionSwitchDeadline = ReturnType<typeof agentSessionSwitchDeadline>
|
||||
|
||||
/** In-process authority; never serialize the callbacks as a transport permit. */
|
||||
export type AgentSessionSwitchAuthority = {
|
||||
invocationId: string
|
||||
check: () => void
|
||||
remaining: () => number
|
||||
signal: AbortSignal
|
||||
/** Existing physical acquisition only, excluding placement and presentation. */
|
||||
trackPhysical?: <T>(work: Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
export type SessionSwitchProjection = {
|
||||
invocationId: string
|
||||
generation: string
|
||||
sessionId: string
|
||||
tabId: string
|
||||
phase: 'prepare' | 'retire' | 'retain'
|
||||
}
|
||||
@@ -3,6 +3,12 @@ export type PtyFencedTerminationRequest = {
|
||||
expectedIncarnationId: string
|
||||
requireNoAgentOwner: boolean
|
||||
requireUnattached: boolean
|
||||
switchCondition?: {
|
||||
token: string
|
||||
remainingMs: number
|
||||
ownedSpawn?: true
|
||||
claimGeneration?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PtyFencedTerminationOutcome =
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Identifier, SessionId } from './structured-agent-session-identifiers'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const MutationEnvelope = z
|
||||
.object({
|
||||
sessionId: SessionId,
|
||||
clientOperationId: Identifier('Invalid client operation id'),
|
||||
/** Null is the "must not exist yet" case; every other call fences. */
|
||||
expectedRuntimeFence: z.number().int().positive().nullable(),
|
||||
payloadFingerprint: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest')
|
||||
})
|
||||
.strict()
|
||||
@@ -1,63 +1,49 @@
|
||||
import {
|
||||
SendBody,
|
||||
AgentSessionOptions,
|
||||
MAX_OPTION_LABEL,
|
||||
MAX_PROMPT_BYTES
|
||||
} from './structured-agent-session-message-params'
|
||||
export {
|
||||
SendBlock,
|
||||
MAX_BLOCKS,
|
||||
MAX_OPTION_LABEL,
|
||||
MAX_PROMPT_BYTES
|
||||
AgentSessionOptions
|
||||
} from './structured-agent-session-message-params'
|
||||
import { TerminalClose } from './terminal-unary-params'
|
||||
import {
|
||||
Identifier,
|
||||
JournalCursor,
|
||||
MAX_ID_LENGTH,
|
||||
SessionId
|
||||
} from './structured-agent-session-identifiers'
|
||||
export {
|
||||
Identifier,
|
||||
JournalCursor,
|
||||
MAX_ID_LENGTH,
|
||||
SessionId
|
||||
} from './structured-agent-session-identifiers'
|
||||
import { z } from 'zod'
|
||||
import { CREATE_TAB_FIELDS } from './structured-agent-session-create-tab-params'
|
||||
import { isStructuredAgentId } from '../agent-session-provider-handle-encoding'
|
||||
import { normalizeExecutionHostId } from '../execution-host'
|
||||
import {
|
||||
AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES,
|
||||
AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH
|
||||
} from '../agent-session-question-answer'
|
||||
import { AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH } from '../agent-session-question-answer'
|
||||
import {
|
||||
AGENT_SESSION_HISTORY_DIRECTIONS,
|
||||
AGENT_SESSION_HISTORY_MAX_LIMIT
|
||||
} from '../agent-session-wire'
|
||||
import { AGENT_SESSION_THREAD_GOAL_OBJECTIVE_MAX_LENGTH } from '../agent-session-thread-goal-wire'
|
||||
import { MutationEnvelope } from './structured-agent-session-mutation-envelope'
|
||||
|
||||
export {
|
||||
SendBlock,
|
||||
MAX_BLOCKS,
|
||||
MAX_OPTION_LABEL,
|
||||
MAX_PROMPT_BYTES
|
||||
} from './structured-agent-session-message-params'
|
||||
|
||||
export {
|
||||
Identifier,
|
||||
JournalCursor,
|
||||
MAX_ID_LENGTH,
|
||||
SessionId
|
||||
} from './structured-agent-session-identifiers'
|
||||
|
||||
// Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded.
|
||||
export const MAX_RESPONSE_OPTION_ID_LENGTH = AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH
|
||||
|
||||
/** Matches the journal's bounds on one grouped prompt. */
|
||||
const MAX_QUESTION_ANSWER_QUESTIONS = 4
|
||||
const MAX_QUESTION_ANSWER_OPTIONS = 64
|
||||
|
||||
/** One relaunch cannot offer more chats than a profile plausibly holds. */
|
||||
export const MAX_RESTART_RESUME_SESSIONS = 512
|
||||
|
||||
export const MutationEnvelope = z
|
||||
.object({
|
||||
sessionId: SessionId,
|
||||
clientOperationId: Identifier('Invalid client operation id'),
|
||||
/** Null is the "must not exist yet" case; every other call fences. */
|
||||
expectedRuntimeFence: z.number().int().positive().nullable(),
|
||||
payloadFingerprint: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest')
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const ProviderHandle = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(),
|
||||
z
|
||||
@@ -216,50 +202,6 @@ export const RespondParams = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
const QuestionAnswer = z
|
||||
.object({
|
||||
// Codex question ids are model-written and untrimmed; the host matches them exactly.
|
||||
questionId: z
|
||||
.string()
|
||||
.min(1, 'Invalid question id')
|
||||
.max(MAX_RESPONSE_OPTION_ID_LENGTH, 'Invalid question id'),
|
||||
optionIds: z
|
||||
.array(Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH))
|
||||
.max(MAX_QUESTION_ANSWER_OPTIONS),
|
||||
// Hashed verbatim by both peers, so no trim or transform here.
|
||||
other: z
|
||||
.string()
|
||||
.max(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES)
|
||||
.refine(
|
||||
(value) => Buffer.byteLength(value, 'utf8') <= AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES,
|
||||
'Answer is too large'
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const RespondToQuestionParams = z
|
||||
.object({
|
||||
envelope: MutationEnvelope,
|
||||
itemId: Identifier('Invalid item id'),
|
||||
expectedRevision: z.number().int().positive(),
|
||||
/** An answer packed into one id, from clients that predate `answers`. */
|
||||
optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH).optional(),
|
||||
answers: z.array(QuestionAnswer).min(1).max(MAX_QUESTION_ANSWER_QUESTIONS).optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if ((value.optionId === undefined) === (value.answers === undefined)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Send exactly one of an option id or answers' })
|
||||
}
|
||||
if (
|
||||
value.answers !== undefined &&
|
||||
Buffer.byteLength(JSON.stringify(value.answers), 'utf8') > MAX_PROMPT_BYTES
|
||||
) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Answer is too large' })
|
||||
}
|
||||
})
|
||||
|
||||
export const SetOptionParams = z.strictObject({
|
||||
envelope: MutationEnvelope,
|
||||
key: Identifier('Invalid option key'),
|
||||
@@ -267,6 +209,47 @@ export const SetOptionParams = z.strictObject({
|
||||
})
|
||||
|
||||
export const OptionsParams = z.object({ sessionId: SessionId }).strict()
|
||||
export const SwitchToChatParams = OptionsParams.extend({
|
||||
execution: z
|
||||
.object({
|
||||
invocationId: Identifier('Invalid invocation'),
|
||||
observe: z.boolean().optional(),
|
||||
receipt: z
|
||||
.object({
|
||||
generation: Identifier('Invalid generation'),
|
||||
shown: z.boolean(),
|
||||
retained: z.boolean().optional(),
|
||||
converged: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
create: CreateIntentParams.omit({ firstMessage: true }).optional(),
|
||||
source: TerminalClose.required().optional()
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
}).strict()
|
||||
export const SwitchToTerminalParams = OptionsParams.extend({
|
||||
execution: z
|
||||
.object({
|
||||
invocationId: Identifier('Invalid invocation'),
|
||||
observe: z.boolean().optional(),
|
||||
receipt: z
|
||||
.object({
|
||||
generation: Identifier('Invalid generation'),
|
||||
shown: z.boolean(),
|
||||
retained: z.boolean().optional(),
|
||||
converged: z.boolean().optional()
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
sourceTabId: Identifier('Invalid tab').optional(),
|
||||
groupId: Identifier('Invalid group').optional(),
|
||||
commandOverride: z.string().max(8192).optional()
|
||||
})
|
||||
.strict()
|
||||
.optional()
|
||||
}).strict()
|
||||
export const ItemParams = OptionsParams.extend({ itemId: z.string().min(1) }).strict()
|
||||
// Why optional: only a host advertising the workspace-sleep-mark capability is sent `markAsleep`.
|
||||
export const SleepWorkspaceParams = z
|
||||
@@ -365,3 +348,7 @@ export const RewindParams = z
|
||||
expectedEpoch: Identifier('Invalid journal epoch')
|
||||
})
|
||||
.strict()
|
||||
|
||||
export { RespondToQuestionParams } from './structured-agent-session-question-params'
|
||||
|
||||
export { MutationEnvelope } from './structured-agent-session-mutation-envelope'
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MAX_PROMPT_BYTES } from './structured-agent-session-message-params'
|
||||
import { Identifier } from './structured-agent-session-identifiers'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES,
|
||||
AGENT_SESSION_RESPONSE_OPTION_ID_MAX_LENGTH as MAX_RESPONSE_OPTION_ID_LENGTH
|
||||
} from '../agent-session-question-answer'
|
||||
import { MutationEnvelope } from './structured-agent-session-mutation-envelope'
|
||||
|
||||
const MAX_QUESTION_ANSWER_QUESTIONS = 4
|
||||
|
||||
const MAX_QUESTION_ANSWER_OPTIONS = 64
|
||||
|
||||
const QuestionAnswer = z
|
||||
.object({
|
||||
// Codex question ids are model-written and untrimmed; the host matches them exactly.
|
||||
questionId: z
|
||||
.string()
|
||||
.min(1, 'Invalid question id')
|
||||
.max(MAX_RESPONSE_OPTION_ID_LENGTH, 'Invalid question id'),
|
||||
optionIds: z
|
||||
.array(Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH))
|
||||
.max(MAX_QUESTION_ANSWER_OPTIONS),
|
||||
// Hashed verbatim by both peers, so no trim or transform here.
|
||||
other: z
|
||||
.string()
|
||||
.max(AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES)
|
||||
.refine(
|
||||
(value) => Buffer.byteLength(value, 'utf8') <= AGENT_SESSION_QUESTION_ANSWER_MAX_BYTES,
|
||||
'Answer is too large'
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const RespondToQuestionParams = z
|
||||
.object({
|
||||
envelope: MutationEnvelope,
|
||||
itemId: Identifier('Invalid item id'),
|
||||
expectedRevision: z.number().int().positive(),
|
||||
/** An answer packed into one id, from clients that predate `answers`. */
|
||||
optionId: Identifier('Invalid option id', MAX_RESPONSE_OPTION_ID_LENGTH).optional(),
|
||||
answers: z.array(QuestionAnswer).min(1).max(MAX_QUESTION_ANSWER_QUESTIONS).optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if ((value.optionId === undefined) === (value.answers === undefined)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Send exactly one of an option id or answers' })
|
||||
}
|
||||
if (
|
||||
value.answers !== undefined &&
|
||||
Buffer.byteLength(JSON.stringify(value.answers), 'utf8') > MAX_PROMPT_BYTES
|
||||
) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Answer is too large' })
|
||||
}
|
||||
})
|
||||
@@ -20,12 +20,38 @@ export const WindowsShellOverrideParam = z
|
||||
// Canonicalize host-side to the .exe spellings exact-matched by spawn, even if the client did not.
|
||||
.transform((shell) => canonicalizeWindowsShellOverride(shell) ?? shell)
|
||||
|
||||
const TerminalSurfaceCloseIdentity = z.object({
|
||||
worktreeId: requiredString('Missing worktree id'),
|
||||
tabId: requiredString('Missing tab id'),
|
||||
leafId: requiredString('Missing leaf id'),
|
||||
createdAt: z.number().finite().nullable(),
|
||||
generation: z.number().int().nonnegative(),
|
||||
hasLeaf: z.boolean(),
|
||||
ptyId: z.string().nullable(),
|
||||
incarnationId: z.string().nullable()
|
||||
})
|
||||
const TerminalSurfaceCloseCapture = z.object({
|
||||
identity: TerminalSurfaceCloseIdentity,
|
||||
holders: z
|
||||
.array(
|
||||
z.object({
|
||||
hostId: OptionalExecutionHostId.unwrap(),
|
||||
identity: TerminalSurfaceCloseIdentity.nullable()
|
||||
})
|
||||
)
|
||||
.max(64)
|
||||
})
|
||||
|
||||
export const TerminalHandle = z.object({
|
||||
terminal: requiredString('Missing terminal handle'),
|
||||
// Additive fence understood by newer hosts; legacy hosts safely ignore it.
|
||||
expectedIncarnationId: requiredString('Missing PTY incarnation').optional()
|
||||
})
|
||||
|
||||
export const TerminalClose = TerminalHandle.extend({
|
||||
originalPresentation: TerminalSurfaceCloseCapture.optional()
|
||||
})
|
||||
|
||||
export const TerminalReadKittyFile = TerminalHandle.extend({
|
||||
path: z
|
||||
.string()
|
||||
|
||||
@@ -98,3 +98,10 @@ export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFr
|
||||
(frame as { _keepalive: unknown })._keepalive === true
|
||||
)
|
||||
}
|
||||
|
||||
/** Same-machine IPC observation only; remote hosts own their own monotonic operation budget. */
|
||||
export type RuntimeRpcDispatchObservation = {
|
||||
expiresAt: number
|
||||
responseTimeoutMs: number
|
||||
responseExpiresAt?: number
|
||||
}
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import { insertTabIdIntoOrder, partitionPinnedTabOrder } from './workspace-layout/tab-order'
|
||||
|
||||
/** Call-local neighbors survive an anchor retiring while creation is awaited. */
|
||||
export type CapturedSessionTabPlacement = { preceding: string[]; following: string[] }
|
||||
|
||||
export function captureSessionTabPlacement(
|
||||
order: readonly string[],
|
||||
afterTabId: string | null | undefined
|
||||
): CapturedSessionTabPlacement | undefined {
|
||||
const index = afterTabId ? order.indexOf(afterTabId) : -1
|
||||
return index < 0
|
||||
? undefined
|
||||
: {
|
||||
preceding: order.slice(0, index + 1),
|
||||
following: order.slice(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply only insertion to current order, keeping unrelated concurrent edits. */
|
||||
export function placeSessionTabAtCapture(
|
||||
order: readonly string[],
|
||||
createdId: string,
|
||||
captured: CapturedSessionTabPlacement
|
||||
): string[] {
|
||||
const next = order.filter((id) => id !== createdId)
|
||||
const present = new Set(next)
|
||||
const previous = captured.preceding.toReversed().find((id) => present.has(id))
|
||||
const following = captured.following.find((id) => present.has(id))
|
||||
const index =
|
||||
following !== undefined
|
||||
? next.indexOf(following)
|
||||
: previous !== undefined
|
||||
? next.indexOf(previous) + 1
|
||||
: Math.min(captured.preceding.length, next.length)
|
||||
next.splice(index, 0, createdId)
|
||||
return next
|
||||
}
|
||||
|
||||
/** Captured placement uses the same pin partitions as an ordinary tab insertion. */
|
||||
export function placeUnpinnedSessionTabAtCapture(
|
||||
order: readonly string[],
|
||||
tabs: readonly { id: string; isPinned?: boolean }[],
|
||||
createdId: string,
|
||||
captured: CapturedSessionTabPlacement
|
||||
): string[] {
|
||||
const placed = placeSessionTabAtCapture(order, createdId, captured)
|
||||
const preceding = placed[placed.indexOf(createdId) - 1]
|
||||
return preceding
|
||||
? insertTabIdIntoOrder(placed, tabs, createdId, false, preceding)
|
||||
: partitionPinnedTabOrder(placed, tabs, createdId)
|
||||
}
|
||||
|
||||
export type SessionTabPlacementOptions = {
|
||||
afterParentGroup?: boolean
|
||||
captured?: CapturedSessionTabPlacement
|
||||
}
|
||||
|
||||
/** Places a created tab after the anchor, or after its parent group when enabled. */
|
||||
@@ -12,6 +65,18 @@ export function placeCreatedSessionTab<T extends { id: string; parentTabId?: str
|
||||
const next = tabs.filter((tab) => tab.id !== created.id)
|
||||
const anchor = afterTabId ? next.findIndex((tab) => tab.id === afterTabId) : -1
|
||||
if (anchor < 0) {
|
||||
if (options.captured) {
|
||||
const order = placeSessionTabAtCapture(
|
||||
next.map((tab) => tab.id),
|
||||
created.id,
|
||||
options.captured
|
||||
)
|
||||
const byId = new Map([...next, created].map((tab) => [tab.id, tab]))
|
||||
return order.flatMap((id) => {
|
||||
const tab = byId.get(id)
|
||||
return tab ? [tab] : []
|
||||
})
|
||||
}
|
||||
next.push(created)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -1,11 +1,119 @@
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import type { WorkspaceSessionState } from './workspace-session-state-types'
|
||||
import type { TerminalPaneLayoutNode } from './terminal-tab-types'
|
||||
|
||||
export type TerminalTabCloseRequest = {
|
||||
requestId: string
|
||||
tabId: string
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
force?: boolean
|
||||
originalSurface?: TerminalSurfaceCloseIdentity
|
||||
}
|
||||
|
||||
export type TerminalTabCloseResponse = {
|
||||
requestId: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Captured presentation facts only narrow the authenticated terminal close. */
|
||||
export type TerminalSurfaceCloseIdentity = {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
createdAt: number | null
|
||||
generation: number
|
||||
hasLeaf: boolean
|
||||
ptyId: string | null
|
||||
incarnationId: string | null
|
||||
}
|
||||
|
||||
export type TerminalSurfaceCloseCapture = {
|
||||
identity: TerminalSurfaceCloseIdentity
|
||||
holders: { hostId: ExecutionHostId; identity: TerminalSurfaceCloseIdentity | null }[]
|
||||
}
|
||||
|
||||
export function isRetainedTerminalSurfaceIdentity(
|
||||
original: TerminalSurfaceCloseIdentity,
|
||||
current: TerminalSurfaceCloseIdentity | null
|
||||
): boolean {
|
||||
return (
|
||||
current === null ||
|
||||
(current.worktreeId === original.worktreeId &&
|
||||
current.tabId === original.tabId &&
|
||||
current.leafId === original.leafId &&
|
||||
current.createdAt === original.createdAt &&
|
||||
current.generation === original.generation &&
|
||||
(!current.hasLeaf || original.hasLeaf) &&
|
||||
(current.ptyId === null || current.ptyId === original.ptyId) &&
|
||||
(current.incarnationId === null || current.incarnationId === original.incarnationId))
|
||||
)
|
||||
}
|
||||
|
||||
export function terminalTreeHasLeaf(
|
||||
node: TerminalPaneLayoutNode | null | undefined,
|
||||
leafId: string
|
||||
): boolean {
|
||||
return (
|
||||
!!node &&
|
||||
(node.type === 'leaf'
|
||||
? node.leafId === leafId
|
||||
: terminalTreeHasLeaf(node.first, leafId) || terminalTreeHasLeaf(node.second, leafId))
|
||||
)
|
||||
}
|
||||
|
||||
export function captureTerminalSurfaceIdentity(
|
||||
state:
|
||||
| (Pick<WorkspaceSessionState, 'tabsByWorktree' | 'terminalLayoutsByTabId'> &
|
||||
Partial<Pick<WorkspaceSessionState, 'terminalPtyIncarnationsByPaneKey'>>)
|
||||
| null
|
||||
| undefined,
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
leafId: string
|
||||
): TerminalSurfaceCloseIdentity | null {
|
||||
const row = state?.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
|
||||
if (!row || !state) {
|
||||
return null
|
||||
}
|
||||
const layout = state.terminalLayoutsByTabId[tabId]
|
||||
return {
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
createdAt: row.createdAt,
|
||||
generation: row.generation ?? 0,
|
||||
hasLeaf: terminalTreeHasLeaf(layout?.root, leafId),
|
||||
ptyId: layout?.ptyIdsByLeafId?.[leafId] ?? null,
|
||||
incarnationId: state.terminalPtyIncarnationsByPaneKey?.[`${tabId}:${leafId}`] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesTerminalSurfaceCapture(
|
||||
original: TerminalSurfaceCloseCapture,
|
||||
hostIds: readonly ExecutionHostId[],
|
||||
read: (hostId: ExecutionHostId) => Parameters<typeof captureTerminalSurfaceIdentity>[0]
|
||||
): boolean {
|
||||
return (
|
||||
original.identity.hasLeaf &&
|
||||
original.holders.length > 0 &&
|
||||
original.holders.every((holder) =>
|
||||
isRetainedTerminalSurfaceIdentity(original.identity, holder.identity)
|
||||
) &&
|
||||
[...new Set([...original.holders.map((holder) => holder.hostId), ...hostIds])].every(
|
||||
(hostId) => {
|
||||
const holder = original.holders.find((entry) => entry.hostId === hostId)
|
||||
const next = captureTerminalSurfaceIdentity(
|
||||
read(hostId),
|
||||
original.identity.worktreeId,
|
||||
original.identity.tabId,
|
||||
original.identity.leafId
|
||||
)
|
||||
return holder
|
||||
? holder.identity
|
||||
? isRetainedTerminalSurfaceIdentity(holder.identity, next)
|
||||
: next === null
|
||||
: next === null
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user