mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
Merge fix/settled-worker-tab-root (34a70e8f25) into fix/fence-single-source (7f36880759) for the unification pass
This commit is contained in:
@@ -18778,6 +18778,78 @@
|
||||
"Default config paths are shared with GNOME on a disposable hosted CI runner; nested mode refuses non-GitHub-Actions execution."
|
||||
],
|
||||
"demotionRule": "Keep experimental on unexplained failures; retain exact bytes and participation checks without retries, skips, or longer deadlines."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.settled-worker-main-owned-attach",
|
||||
"title": "Main owns settled worker attachment and preserves fenced pane history",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "provider-contract-and-electron-restart",
|
||||
"surfaces": ["stable pane spawn", "settled worker restart", "paired terminal create"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "macOS hidden Electron restart exercises live and absent daemon sessions. Unit contracts cover direct SSH evidence and paired host/client refusal; other operating systems and real remote topologies remain unverified.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/19358"],
|
||||
"invariant": "Main reads the persisted pane resume fence and permits attachment only. Observed exit preserves the historical tab and binding; unverifiable evidence preserves ownership and permits recovery without replacement.",
|
||||
"oracle": "A fenced stable owner gets one attach attempt and no retirement or second provider call. A restarted worker with a live or exited daemon session retains its exact tab and PTY binding without another agent launch. A fence committed during attachment or claim reconciliation prevents retirement and fresh provider spawn. Unsupported paired resolution never certifies exit; owner absence cannot create until host fenced-create semantics are negotiated.",
|
||||
"commands": [
|
||||
"ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts",
|
||||
"ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 npx playwright test tests/e2e/settled-worker-tab-survives-restart.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1 --retries=0"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts",
|
||||
"src/main/ipc/pty/pane/stable-pane-resume-fence-host.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-fenced-outcome.test.ts",
|
||||
"tests/e2e/settled-worker-tab-survives-restart.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/settled-worker-tab-survives-restart.spec.ts",
|
||||
"assertions": [
|
||||
"reveal must not tear the settled worker tab down",
|
||||
"main must report observed exit without retiring the pane"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/main/ipc/pty-attach-only-fenced-pane-spawn.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 20.18,
|
||||
"summary": "Main boundary and adjacent contracts passed 19 tests across three suites."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 120,
|
||||
"scope": "Two hidden Electron restart cases; unit contracts use injected provider failures."
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Round-2 local validation; CI soak has not begun."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Round-1 reviewers reproduced destructive retirement and false exit classification. Round-2 mutation and restart results are recorded in PR #19358."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Fence lookup reads one host-scoped workspace session. Fenced absence stops after one provider attach with zero persistence retirement or replacement calls; recovery reuses the existing bounded scheduler."
|
||||
},
|
||||
"knownGaps": [
|
||||
"No real Windows, Linux, WSL, SSH server, or paired two-process UI journey in this gate.",
|
||||
"No CI soak history; archived probes requiring renderer attachOnly or contradictory identical-message outcomes cannot serve as the new contract."
|
||||
],
|
||||
"promotionCriteria": [
|
||||
"Complete CI soak and add real paired/SSH topology evidence before promotion."
|
||||
],
|
||||
"demotionRule": "Keep experimental while any restart ordering failure is unexplained; never weaken binding retention to silence failures."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export type ClaudeStructuredSdkOptions = Pick<
|
||||
| 'sessionId'
|
||||
| 'resume'
|
||||
| 'resumeSessionAt'
|
||||
| 'resumeDropsTurn'
|
||||
>
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
adapterFor,
|
||||
fakeClaude,
|
||||
identityFor,
|
||||
PROVIDER_SESSION_ID
|
||||
} from './claude-structured-session-test-support'
|
||||
import { ClaudeRewindAttempt } from './claude-structured-rewind'
|
||||
import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
|
||||
const intent = { targetUuid: 'kept', previousLeafUuid: 'tip', dropsTurn: 'drop' }
|
||||
const proofLaunch = {
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
claudeConfigDir: '/claude',
|
||||
options: {},
|
||||
resumed: true,
|
||||
resumeLeafUuid: 'tip',
|
||||
cwd: '/workspace',
|
||||
pathToClaudeCodeExecutable: 'claude'
|
||||
}
|
||||
|
||||
describe('Claude rewind acquisition', () => {
|
||||
it('executes a cursor resume in place and proves the exact target before publication', async () => {
|
||||
const fake = fakeClaude()
|
||||
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
|
||||
const adapter = adapterFor(
|
||||
fake,
|
||||
{ resumed: true, resumeLeafUuid: 'tip' },
|
||||
[],
|
||||
[],
|
||||
undefined,
|
||||
proof
|
||||
)
|
||||
try {
|
||||
const acquired = await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn',
|
||||
rewind: intent
|
||||
})
|
||||
expect(acquired.link.handle).toMatchObject({
|
||||
provider: 'claude',
|
||||
sessionId: PROVIDER_SESSION_ID,
|
||||
leafUuid: 'kept'
|
||||
})
|
||||
expect(fake.connections[0]!.launch.options).toMatchObject({
|
||||
resume: PROVIDER_SESSION_ID,
|
||||
resumeSessionAt: 'kept',
|
||||
resumeDropsTurn: 'drop'
|
||||
})
|
||||
expect(fake.connections[0]!.launch.options).not.toHaveProperty('forkSession')
|
||||
expect(proof).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ previousLeafUuid: 'tip', intentionalRewindUuid: 'kept' })
|
||||
)
|
||||
await adapter.closeSession('session-1')
|
||||
await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-next' })
|
||||
expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn')
|
||||
expect(
|
||||
proof.mock.calls.filter(([input]) => input.intentionalRewindUuid !== undefined)
|
||||
).toHaveLength(1)
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
})
|
||||
it('recognizes the documented refusal and closes the failed child without retry', async () => {
|
||||
const fake = fakeClaude()
|
||||
const openConnection = fake.openConnection
|
||||
fake.openConnection = async (launch, handlers) => {
|
||||
const connection = await openConnection(launch, handlers)
|
||||
const initialize = connection.initializationResult
|
||||
connection.initializationResult = async (...args) => {
|
||||
const result = await initialize(...args)
|
||||
handlers?.onMessage?.({
|
||||
type: 'result',
|
||||
subtype: 'error_during_execution',
|
||||
session_id: PROVIDER_SESSION_ID,
|
||||
errors: ['Resume rejected by --resume-drops-turn: additional prompt observed']
|
||||
})
|
||||
return result
|
||||
}
|
||||
return connection
|
||||
}
|
||||
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
|
||||
const adapter = adapterFor(fake, { resumed: true }, [], [], undefined, proof)
|
||||
await expect(
|
||||
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn', rewind: intent })
|
||||
).rejects.toMatchObject({ rewindReason: 'provider-refused' })
|
||||
expect(fake.connections).toHaveLength(1)
|
||||
expect(fake.connections[0]?.closed).toBe(true)
|
||||
expect(proof).not.toHaveBeenCalled()
|
||||
await adapter.closeAll()
|
||||
})
|
||||
it('consumes proof authorization even if its first read fails', async () => {
|
||||
const proof = vi.fn(async () => {
|
||||
throw new Error('torn transcript')
|
||||
})
|
||||
const attempt = new ClaudeRewindAttempt(intent)
|
||||
const launch = {
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
claudeConfigDir: '/claude',
|
||||
options: {},
|
||||
resumed: true,
|
||||
resumeLeafUuid: 'tip',
|
||||
cwd: '/workspace',
|
||||
pathToClaudeCodeExecutable: 'claude'
|
||||
}
|
||||
await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBeInstanceOf(
|
||||
AgentSessionRewindRefusal
|
||||
)
|
||||
expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull()
|
||||
expect(proof).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('never persists success for a mismatching leaf', async () => {
|
||||
const onProved = vi.fn(async () => {})
|
||||
const attempt = new ClaudeRewindAttempt(intent, onProved)
|
||||
await expect(
|
||||
attempt.prove(proofLaunch, { readTranscriptLeaf: async () => 'other' })
|
||||
).rejects.toMatchObject({ rewindReason: 'proof-mismatch' })
|
||||
expect(onProved).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preserves commit failure as unknown and consumes the override before persisting', async () => {
|
||||
const diskError = new Error('record write failed')
|
||||
const onProved = vi.fn(async () => {
|
||||
throw diskError
|
||||
})
|
||||
const proof = vi.fn(async () => 'kept')
|
||||
const attempt = new ClaudeRewindAttempt(intent, onProved)
|
||||
const launch = {
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
claudeConfigDir: '/claude',
|
||||
options: {},
|
||||
resumed: true,
|
||||
resumeLeafUuid: 'tip',
|
||||
cwd: '/workspace',
|
||||
pathToClaudeCodeExecutable: 'claude'
|
||||
}
|
||||
await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBe(diskError)
|
||||
expect(onProved).toHaveBeenCalledWith('kept')
|
||||
expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull()
|
||||
expect(proof).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('checkpoints the proved target before late acquisition failure without persisting a stale cursor', async () => {
|
||||
const fake = fakeClaude()
|
||||
const launch = { resumed: true, resumeLeafUuid: 'tip' }
|
||||
const persisted: unknown[] = []
|
||||
const proof = vi.fn(async () => 'kept')
|
||||
const adapter = adapterFor(fake, launch, [], persisted, undefined, proof)
|
||||
const onProved = vi.fn(async (leafUuid: string) => {
|
||||
launch.resumeLeafUuid = leafUuid
|
||||
fake.connections[0]!.closed = true
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn',
|
||||
rewind: { ...intent, onProved }
|
||||
})
|
||||
).rejects.toThrow('exited while being acquired')
|
||||
expect(onProved).toHaveBeenCalledWith('kept')
|
||||
expect(persisted).toEqual([])
|
||||
const acquired = await adapter.acquire({
|
||||
identity: identityFor(),
|
||||
fence: 8,
|
||||
spawnToken: 'retry'
|
||||
})
|
||||
expect(acquired.link.handle).toMatchObject({ leafUuid: 'kept' })
|
||||
expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn')
|
||||
expect(proof).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
})
|
||||
it('restores an interrupted unproved rewind only after exact ordinary branch proof', async () => {
|
||||
const fake = fakeClaude()
|
||||
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
|
||||
const restored = vi.fn(async () => {})
|
||||
const adapter = adapterFor(
|
||||
fake,
|
||||
{ resumed: true, resumeLeafUuid: 'tip' },
|
||||
[],
|
||||
[],
|
||||
undefined,
|
||||
proof
|
||||
)
|
||||
const input = {
|
||||
identity: identityFor(),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn',
|
||||
rewindRecovery: { leafUuid: 'tip', onProved: restored }
|
||||
}
|
||||
try {
|
||||
await expect(adapter.acquire(input)).rejects.toMatchObject({ rewindReason: 'proof-mismatch' })
|
||||
expect(restored).not.toHaveBeenCalled()
|
||||
proof.mockResolvedValue('tip')
|
||||
await adapter.acquire({ ...input, fence: 8, spawnToken: 'retry' })
|
||||
expect(restored).toHaveBeenCalledOnce()
|
||||
expect(proof).toHaveBeenCalledWith(expect.objectContaining({ previousLeafUuid: 'tip' }))
|
||||
for (const [request] of proof.mock.calls) {
|
||||
expect(request).not.toHaveProperty('intentionalRewindUuid')
|
||||
}
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
|
||||
export function claudeRewindRefusalFromMessage(
|
||||
message: Record<string, unknown>
|
||||
): AgentSessionRewindRefusal | null {
|
||||
return message.type === 'result' &&
|
||||
message.subtype === 'error_during_execution' &&
|
||||
Array.isArray(message.errors) &&
|
||||
message.errors.some(
|
||||
(error) =>
|
||||
typeof error === 'string' && error.startsWith('Resume rejected by --resume-drops-turn:')
|
||||
)
|
||||
? new AgentSessionRewindRefusal('provider-refused')
|
||||
: null
|
||||
}
|
||||
|
||||
import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
|
||||
import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state'
|
||||
|
||||
type Intent = NonNullable<StructuredAgentSessionAcquireInput['rewind']>
|
||||
|
||||
/** The proof authorization exists only for this acquisition's first proof attempt. */
|
||||
export class ClaudeRewindAttempt {
|
||||
private refusal: AgentSessionRewindRefusal | null = null
|
||||
constructor(
|
||||
private intent: Intent | undefined,
|
||||
private readonly onProved?: (leafUuid: string) => Promise<void>
|
||||
) {}
|
||||
|
||||
observe(message: Record<string, unknown>): AgentSessionRewindRefusal | null {
|
||||
if (!this.intent) {
|
||||
return null
|
||||
}
|
||||
this.refusal ??= claudeRewindRefusalFromMessage(message)
|
||||
return this.refusal
|
||||
}
|
||||
|
||||
applyLaunch(
|
||||
launch: ClaudeStructuredLaunch,
|
||||
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
|
||||
): void {
|
||||
if (!this.intent) {
|
||||
return
|
||||
}
|
||||
if (!launch.resumed || !deps.readTranscriptLeaf) {
|
||||
throw new AgentSessionRewindRefusal('unsupported')
|
||||
}
|
||||
launch.options = {
|
||||
...launch.options,
|
||||
resume: launch.providerSessionId,
|
||||
resumeSessionAt: this.intent.targetUuid,
|
||||
...(this.intent.dropsTurn ? { resumeDropsTurn: this.intent.dropsTurn } : {})
|
||||
}
|
||||
launch.resumeLeafUuid = this.intent.targetUuid
|
||||
}
|
||||
|
||||
async prove(
|
||||
launch: ClaudeStructuredLaunch,
|
||||
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
|
||||
): Promise<string | null> {
|
||||
const intent = this.intent
|
||||
this.clear()
|
||||
if (this.refusal) {
|
||||
throw this.refusal
|
||||
}
|
||||
if (!intent) {
|
||||
return null
|
||||
}
|
||||
let leaf: string | null
|
||||
try {
|
||||
leaf = await deps.readTranscriptLeaf!({
|
||||
providerSessionId: launch.providerSessionId,
|
||||
previousLeafUuid: intent.previousLeafUuid,
|
||||
intentionalRewindUuid: intent.targetUuid,
|
||||
claudeConfigDir: launch.claudeConfigDir
|
||||
})
|
||||
if (leaf !== intent.targetUuid) {
|
||||
throw new AgentSessionRewindRefusal('proof-mismatch')
|
||||
}
|
||||
} catch (error) {
|
||||
throw error instanceof AgentSessionRewindRefusal
|
||||
? error
|
||||
: new AgentSessionRewindRefusal('proof-mismatch')
|
||||
}
|
||||
// Persistence failure is an unknown outcome, never evidence that the provider refused.
|
||||
await this.onProved?.(leaf)
|
||||
return leaf
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.intent = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** An interrupted, unproved rewind restores its original cursor without ancestor authorization. */
|
||||
export async function proveClaudeRewindRecovery(
|
||||
recovery: StructuredAgentSessionAcquireInput['rewindRecovery'],
|
||||
launch: ClaudeStructuredLaunch,
|
||||
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
|
||||
): Promise<string | null> {
|
||||
if (!recovery) {
|
||||
return null
|
||||
}
|
||||
if (!launch.resumed || launch.resumeLeafUuid !== recovery.leafUuid || !deps.readTranscriptLeaf) {
|
||||
throw new AgentSessionRewindRefusal('proof-mismatch')
|
||||
}
|
||||
const leaf = await deps.readTranscriptLeaf({
|
||||
providerSessionId: launch.providerSessionId,
|
||||
previousLeafUuid: recovery.leafUuid,
|
||||
claudeConfigDir: launch.claudeConfigDir
|
||||
})
|
||||
if (leaf !== recovery.leafUuid) {
|
||||
throw new AgentSessionRewindRefusal('proof-mismatch')
|
||||
}
|
||||
await recovery.onProved()
|
||||
return leaf
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ClaudeRewindAttempt, proveClaudeRewindRecovery } from './claude-structured-rewind'
|
||||
import {
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionPreSpawnError
|
||||
@@ -85,6 +86,7 @@ export async function acquireClaudeSession({
|
||||
const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS
|
||||
const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs)
|
||||
|
||||
const rewind = new ClaudeRewindAttempt(input.rewind, input.rewind?.onProved)
|
||||
const onMessage = (message: Record<string, unknown>): void => {
|
||||
const init = readClaudeInit(message)
|
||||
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
|
||||
@@ -95,6 +97,11 @@ export async function acquireClaudeSession({
|
||||
}
|
||||
return
|
||||
}
|
||||
const refusal = rewind.observe(message)
|
||||
if (refusal) {
|
||||
initDeadline.reject(refusal)
|
||||
return
|
||||
}
|
||||
if (init) {
|
||||
initDeadline.resolve(init)
|
||||
// Every turn opens with an init frame naming the model the CLI is actually
|
||||
@@ -178,6 +185,7 @@ export async function acquireClaudeSession({
|
||||
? error
|
||||
: new AgentSessionPreSpawnError(error)
|
||||
})
|
||||
rewind.applyLaunch(launch, deps)
|
||||
expectedProviderSessionId = launch.providerSessionId
|
||||
observedLeafUuid = launch.resumeLeafUuid
|
||||
acquisitions.assertCurrent(sessionId, attempt)
|
||||
@@ -241,6 +249,9 @@ export async function acquireClaudeSession({
|
||||
diagnostic: claudeAuthDiagnostic(init, settings)
|
||||
})
|
||||
)
|
||||
observedLeafUuid = (await rewind.prove(launch, deps)) ?? observedLeafUuid
|
||||
observedLeafUuid =
|
||||
(await proveClaudeRewindRecovery(input.rewindRecovery, launch, deps)) ?? observedLeafUuid
|
||||
const process = await claudeProcessIdentity(
|
||||
{ ...input, pid: connection.pid },
|
||||
deps.readProcessStartTime
|
||||
@@ -298,6 +309,7 @@ export async function acquireClaudeSession({
|
||||
acquisitions.deleteIfCurrent(sessionId, attempt)
|
||||
throw acquisitionError
|
||||
} finally {
|
||||
rewind.clear()
|
||||
attempt.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
StructuredAgentSessionAcquireInput,
|
||||
StructuredAgentSessionAdapter
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
answerClaudePrompt,
|
||||
cancelClaudeTurn,
|
||||
@@ -58,6 +57,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
|
||||
supportsLocation = supportsClaudeStructuredLocation
|
||||
|
||||
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = () =>
|
||||
this.deps.readTranscriptLeaf ? { supported: true } : { supported: false, reason: 'unsupported' }
|
||||
|
||||
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
|
||||
acquireClaudeSession({
|
||||
input,
|
||||
@@ -67,7 +69,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
exits: this.exits,
|
||||
callbacks: {
|
||||
deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event),
|
||||
emit: (session, events, event) => this.emit(session, events, event),
|
||||
emit: (session, _events, event) => this.emit(session, event),
|
||||
handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error),
|
||||
settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit)
|
||||
}
|
||||
@@ -155,7 +157,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
acquisitionGeneration: exit.session.acquisitionGeneration
|
||||
}
|
||||
try {
|
||||
this.emit(exit.session, exit.session.events, ended)
|
||||
this.emit(exit.session, ended)
|
||||
} finally {
|
||||
settleClaudeExitedSession(exit.session)
|
||||
}
|
||||
@@ -187,11 +189,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
})
|
||||
}
|
||||
|
||||
private emit(
|
||||
session: ClaudeSession | null,
|
||||
_events: StructuredAgentSessionEventSink | undefined,
|
||||
event: ClaudeStructuredSessionEvent
|
||||
): void {
|
||||
private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void {
|
||||
const backgroundTasksChanged =
|
||||
event.type === 'ended'
|
||||
? (session?.backgroundTasks.clear() ?? false)
|
||||
|
||||
@@ -88,6 +88,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
|
||||
readTranscriptLeaf?: (input: {
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
intentionalRewindUuid?: string
|
||||
/** Account-scoped Claude config root that owns this provider session. */
|
||||
claudeConfigDir: string
|
||||
}) => Promise<string | null>
|
||||
|
||||
@@ -13,7 +13,7 @@ type TranscriptNode = {
|
||||
|
||||
export type ClaudeTranscriptBranchProof = {
|
||||
leafUuid: string
|
||||
relation: 'initial' | 'same' | 'descendant'
|
||||
relation: 'initial' | 'same' | 'descendant' | 'intentional-rewind'
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
@@ -83,6 +83,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
|
||||
contents: string
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
intentionalRewindUuid?: string
|
||||
}): ClaudeTranscriptBranchProof {
|
||||
const nodes = new Map<string, TranscriptNode>()
|
||||
let leafUuid: string | null = null
|
||||
@@ -156,6 +157,21 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
|
||||
throw transcriptError('marker precedes its leaf record')
|
||||
}
|
||||
const previousLeafUuid = input.previousLeafUuid
|
||||
if (input.intentionalRewindUuid !== undefined) {
|
||||
if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) {
|
||||
throw transcriptError('rewind target does not match the observed leaf')
|
||||
}
|
||||
proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId)
|
||||
proveAppendOrder(nodes)
|
||||
let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null
|
||||
for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
|
||||
if (ancestor === leafUuid) {
|
||||
return { leafUuid, relation: 'intentional-rewind' }
|
||||
}
|
||||
ancestor = nodes.get(ancestor)?.parentUuid ?? null
|
||||
}
|
||||
throw transcriptError('rewind target is not an ancestor of the previous cursor')
|
||||
}
|
||||
if (!previousLeafUuid) {
|
||||
proveMainLineAncestry(nodes, leafUuid, input.providerSessionId)
|
||||
// A branch proof is based on an append-only snapshot. A child that appears
|
||||
@@ -210,11 +226,13 @@ export async function proveClaudeTranscriptBranch(input: {
|
||||
transcriptPath: string
|
||||
providerSessionId: string
|
||||
previousLeafUuid: string | null
|
||||
intentionalRewindUuid?: string
|
||||
}): Promise<ClaudeTranscriptBranchProof> {
|
||||
return proveClaudeTranscriptBranchFromJsonl({
|
||||
contents: await readFile(input.transcriptPath, 'utf8'),
|
||||
providerSessionId: input.providerSessionId,
|
||||
previousLeafUuid: input.previousLeafUuid
|
||||
previousLeafUuid: input.previousLeafUuid,
|
||||
intentionalRewindUuid: input.intentionalRewindUuid
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { proveClaudeTranscriptBranchFromJsonl } from './claude-transcript-branch-proof'
|
||||
|
||||
const row = (uuid: string, parentUuid: string | null, extra = {}) =>
|
||||
JSON.stringify({ type: 'assistant', sessionId: 'provider', uuid, parentUuid, ...extra })
|
||||
const marker = (leafUuid: string) =>
|
||||
JSON.stringify({ type: 'last-prompt', sessionId: 'provider', leafUuid })
|
||||
const graph = [row('root', null), row('kept', 'root'), row('old', 'kept')]
|
||||
const prove = (rows: string[], leaf: string, intentionalRewindUuid?: string) =>
|
||||
proveClaudeTranscriptBranchFromJsonl({
|
||||
contents: `${[...rows, marker(leaf)].join('\n')}\n`,
|
||||
providerSessionId: 'provider',
|
||||
previousLeafUuid: 'old',
|
||||
intentionalRewindUuid
|
||||
})
|
||||
|
||||
describe('explicit Claude rewind ancestry', () => {
|
||||
it('admits only the exact requested main-chain ancestor', () => {
|
||||
expect(prove(graph, 'kept', 'kept')).toEqual({
|
||||
leafUuid: 'kept',
|
||||
relation: 'intentional-rewind'
|
||||
})
|
||||
expect(() => prove(graph, 'kept')).toThrow('sibling')
|
||||
expect(() => prove(graph, 'kept', 'root')).toThrow('target')
|
||||
expect(() => prove(graph, 'old', 'old')).toThrow('not an ancestor')
|
||||
})
|
||||
it('keeps sibling and sidechain rejection even with explicit intent', () => {
|
||||
expect(() => prove([...graph, row('sibling', 'root')], 'sibling', 'sibling')).toThrow(
|
||||
'not an ancestor'
|
||||
)
|
||||
expect(() =>
|
||||
prove(
|
||||
[row('root', null), row('kept', 'root', { isSidechain: true }), row('old', 'kept')],
|
||||
'kept',
|
||||
'kept'
|
||||
)
|
||||
).toThrow()
|
||||
})
|
||||
it('refuses missing, reordered, or cyclic ancestry', () => {
|
||||
expect(() => prove(graph.slice(1), 'kept', 'kept')).toThrow('missing ancestor')
|
||||
expect(() => prove([graph[1]!, graph[0]!, graph[2]!], 'kept', 'kept')).toThrow(
|
||||
'parent row follows'
|
||||
)
|
||||
expect(() => prove([row('root', 'old'), ...graph.slice(1)], 'kept', 'kept')).toThrow('cycle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { CodexAppServerRequestError } from './codex-app-server-connection'
|
||||
import type { CodexSession } from './codex-structured-session-state'
|
||||
import { recoverCodexRewind, rewindCodexSession } from './codex-structured-rewind'
|
||||
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds'
|
||||
import { openCodexThread } from './codex-structured-thread-open'
|
||||
|
||||
function fixture(reverted = true) {
|
||||
const request = vi.fn(async (method: string): Promise<unknown> => {
|
||||
if (method === 'thread/read') {
|
||||
return { thread: { id: 'thread', historyMode: 'paginated', status: { type: 'idle' } } }
|
||||
}
|
||||
if (method === 'thread/revert') {
|
||||
reverted = true
|
||||
return {
|
||||
thread: { id: 'thread', turns: [] },
|
||||
turnsBackwardsCursor: 'turn-cursor',
|
||||
itemsBackwardsCursor: 'item-cursor'
|
||||
}
|
||||
}
|
||||
if (method === 'thread/turns/list') {
|
||||
return { data: [...(reverted ? [] : [{ id: 'drop' }]), { id: 'kept' }], nextCursor: null }
|
||||
}
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
turnId: 'kept',
|
||||
item: {
|
||||
id: 'item-1',
|
||||
type: 'userMessage',
|
||||
content: [{ type: 'text', text: 'kept prompt' }]
|
||||
}
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
})
|
||||
const session = {
|
||||
connection: { request },
|
||||
threadId: 'thread',
|
||||
fence: 2,
|
||||
ended: false,
|
||||
historyMode: 'paginated',
|
||||
activeTurnIds: new Set()
|
||||
} as unknown as CodexSession
|
||||
return { request, session }
|
||||
}
|
||||
|
||||
describe('Codex rewind', () => {
|
||||
it('recovers verified history from fresh cursors without repeating revert', async () => {
|
||||
const { session, request } = fixture()
|
||||
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({
|
||||
ok: true,
|
||||
items: [{ body: { kind: 'message', blocks: [{ type: 'text', text: 'kept prompt' }] } }]
|
||||
})
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
'thread/read',
|
||||
'thread/turns/list',
|
||||
'thread/items/list'
|
||||
])
|
||||
for (const method of ['thread/turns/list', 'thread/items/list']) {
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
method,
|
||||
expect.objectContaining({ cursor: null, sortDirection: 'desc' }),
|
||||
expect.anything()
|
||||
)
|
||||
}
|
||||
})
|
||||
it('recognizes an unapplied rewind from the still-present target', async () => {
|
||||
const { session, request } = fixture()
|
||||
const original = request.getMockImplementation()!
|
||||
request.mockImplementation(async (method) =>
|
||||
method === 'thread/turns/list'
|
||||
? { data: [{ id: 'drop' }, { id: 'kept' }], nextCursor: null }
|
||||
: original(method)
|
||||
)
|
||||
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'provider-refused'
|
||||
})
|
||||
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
||||
})
|
||||
it.each(['cycle', 'pages', 'entries', 'bytes'] as const)(
|
||||
'bounds recovery by %s and never returns partial history',
|
||||
async (limit) => {
|
||||
const { session, request } = fixture()
|
||||
const original = request.getMockImplementation()!
|
||||
let pages = 0
|
||||
request.mockImplementation(async (method) => {
|
||||
if (method !== 'thread/turns/list') {
|
||||
return original(method)
|
||||
}
|
||||
pages++
|
||||
if (limit === 'entries') {
|
||||
return {
|
||||
data: Array.from({ length: 1025 }, (_, i) => ({ id: String(i) })),
|
||||
nextCursor: null
|
||||
}
|
||||
}
|
||||
if (limit === 'bytes') {
|
||||
return {
|
||||
data: [],
|
||||
padding: 'x'.repeat(AGENT_SESSION_HISTORY_MAX_PAGE_BYTES),
|
||||
nextCursor: null
|
||||
}
|
||||
}
|
||||
return { data: [], nextCursor: limit === 'cycle' ? 'repeated' : String(pages) }
|
||||
})
|
||||
await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
|
||||
'history-limit'
|
||||
)
|
||||
expect(pages).toBeLessThanOrEqual(100)
|
||||
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
||||
}
|
||||
)
|
||||
it('keeps an interrupted recovery retryable with read-only requests', async () => {
|
||||
const { session, request } = fixture()
|
||||
const original = request.getMockImplementation()!
|
||||
request.mockImplementation(async (method) => {
|
||||
if (method === 'thread/items/list') {
|
||||
throw new Error('offline')
|
||||
}
|
||||
return original(method)
|
||||
})
|
||||
await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
|
||||
'offline'
|
||||
)
|
||||
request.mockImplementation(original)
|
||||
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
||||
})
|
||||
it('refuses activity arriving during recovery hydration', async () => {
|
||||
const { session, request } = fixture()
|
||||
const original = request.getMockImplementation()!
|
||||
request.mockImplementation(async (method) => {
|
||||
if (method === 'thread/items/list') {
|
||||
session.activeTurnIds!.add('racing-turn')
|
||||
}
|
||||
return original(method)
|
||||
})
|
||||
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'busy'
|
||||
})
|
||||
})
|
||||
it('uses native revert and reads both retained indexes despite empty response turns', async () => {
|
||||
const { session, request } = fixture(false)
|
||||
const onPrepared = vi.fn<NonNullable<Parameters<typeof rewindCodexSession>[1]['onPrepared']>>(
|
||||
async (items) => {
|
||||
expect(items).toMatchObject([{ identity: { turnId: 'kept' } }])
|
||||
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
||||
}
|
||||
)
|
||||
expect(
|
||||
await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onPrepared })
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
items: [{ body: { kind: 'message' } }]
|
||||
})
|
||||
expect(onPrepared).toHaveBeenCalledTimes(1)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'thread/revert',
|
||||
{ threadId: 'thread', beforeTurnId: 'drop' },
|
||||
{ timeoutMs: undefined }
|
||||
)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'thread/turns/list',
|
||||
expect.objectContaining({ cursor: 'turn-cursor', sortDirection: 'desc' }),
|
||||
expect.anything()
|
||||
)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'thread/items/list',
|
||||
expect.objectContaining({ cursor: 'item-cursor', sortDirection: 'desc' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
it('refuses a known legacy thread before making a request', async () => {
|
||||
const { session, request } = fixture()
|
||||
session.historyMode = 'legacy'
|
||||
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'history-not-paginated'
|
||||
})
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
it('refuses history exceeding hydration capacity before mutating the provider', async () => {
|
||||
const { session, request } = fixture(false)
|
||||
const original = request.getMockImplementation()!
|
||||
const turns = Array.from({ length: 600 }, (_, i) => String(i))
|
||||
request.mockImplementation(async (method) => {
|
||||
if (method === 'thread/turns/list') {
|
||||
return { data: [{ id: 'drop' }, ...turns.map((id) => ({ id }))], nextCursor: null }
|
||||
}
|
||||
if (method === 'thread/items/list') {
|
||||
return {
|
||||
data: turns.map((turnId) => ({
|
||||
turnId,
|
||||
item: { id: turnId, type: 'userMessage', content: [{ type: 'text', text: 'x' }] }
|
||||
})),
|
||||
nextCursor: null
|
||||
}
|
||||
}
|
||||
return original(method)
|
||||
})
|
||||
const onReverted = vi.fn()
|
||||
expect(
|
||||
await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onReverted })
|
||||
).toEqual({ ok: false, reason: 'history-limit' })
|
||||
expect(onReverted).not.toHaveBeenCalled()
|
||||
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
||||
})
|
||||
it('refuses a missing target before mutation', async () => {
|
||||
const { session, request } = fixture()
|
||||
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'missing' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'invalid-target'
|
||||
})
|
||||
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
||||
})
|
||||
it('rechecks provider idleness after preflight hydration', async () => {
|
||||
const { session, request } = fixture(false)
|
||||
const original = request.getMockImplementation()!
|
||||
let reads = 0
|
||||
request.mockImplementation(async (method) => {
|
||||
if (method === 'thread/read' && ++reads === 2) {
|
||||
return { thread: { id: 'thread', status: { type: 'active' } } }
|
||||
}
|
||||
return original(method)
|
||||
})
|
||||
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'busy'
|
||||
})
|
||||
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
|
||||
})
|
||||
it('maps native legacy refusal without exposing provider text or falling back', async () => {
|
||||
const { session, request } = fixture(false)
|
||||
const original = request.getMockImplementation()!
|
||||
request.mockImplementation(async (method) => {
|
||||
if (method === 'thread/read') {
|
||||
return { thread: { id: 'thread', status: { type: 'idle' } } }
|
||||
}
|
||||
if (method !== 'thread/revert') {
|
||||
return original(method)
|
||||
}
|
||||
throw new CodexAppServerRequestError(
|
||||
'thread/revert',
|
||||
-32600,
|
||||
'thread/revert only supports paginated threads'
|
||||
)
|
||||
})
|
||||
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'history-not-paginated'
|
||||
})
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
'thread/read',
|
||||
'thread/turns/list',
|
||||
'thread/items/list',
|
||||
'thread/read',
|
||||
'thread/revert'
|
||||
])
|
||||
})
|
||||
it('refuses activity arriving during the preflight await', async () => {
|
||||
const { session, request } = fixture()
|
||||
request.mockImplementationOnce(async () => {
|
||||
session.activeTurnIds!.add('racing-turn')
|
||||
return { thread: { id: 'thread', status: { type: 'idle' } } }
|
||||
})
|
||||
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'busy'
|
||||
})
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('treats hydration failure after revert as unknown and never retries revert', async () => {
|
||||
const { session, request } = fixture(false)
|
||||
const original = request.getMockImplementation()!
|
||||
let reverted = false
|
||||
request.mockImplementation(async (method) => {
|
||||
if (method === 'thread/revert') {
|
||||
reverted = true
|
||||
}
|
||||
if (method === 'thread/items/list' && reverted) {
|
||||
throw new Error('offline')
|
||||
}
|
||||
return original(method)
|
||||
})
|
||||
await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
|
||||
'offline'
|
||||
)
|
||||
expect(request.mock.calls.filter(([method]) => method === 'thread/revert')).toHaveLength(1)
|
||||
})
|
||||
it('captures history mode at both start and resume without changing defaults', async () => {
|
||||
for (const resumeThreadId of [null, 'thread']) {
|
||||
const request = vi.fn(async (_method: string, _params?: unknown) => ({
|
||||
thread: { id: 'thread', historyMode: 'legacy' }
|
||||
}))
|
||||
expect(
|
||||
await openCodexThread({ request }, { cwd: '/workspace', resumeThreadId }, 10)
|
||||
).toMatchObject({ historyMode: 'legacy' })
|
||||
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('historyMode')
|
||||
}
|
||||
})
|
||||
it('rejects post-revert history missing an item within a retained turn', async () => {
|
||||
const { session, request } = fixture(false)
|
||||
const original = request.getMockImplementation()!
|
||||
let reverted = false
|
||||
request.mockImplementation(async (method) => {
|
||||
if (method === 'thread/revert') {
|
||||
reverted = true
|
||||
}
|
||||
if (method === 'thread/items/list' && !reverted) {
|
||||
return {
|
||||
data: [2, 1].map((i) => ({
|
||||
turnId: 'kept',
|
||||
item: {
|
||||
id: `item-${i}`,
|
||||
type: 'userMessage',
|
||||
content: [{ type: 'text', text: `prompt ${i}` }]
|
||||
}
|
||||
})),
|
||||
nextCursor: null
|
||||
}
|
||||
}
|
||||
return original(method)
|
||||
})
|
||||
await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
|
||||
'proof-mismatch'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import { CODEX_RESTORE_MAX_OPERATIONS } from './codex-structured-journal-translation-restore'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../shared/agent-session-wire'
|
||||
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds'
|
||||
import { isCodexAppServerRequestError } from './codex-app-server-connection'
|
||||
import type { CodexSession } from './codex-structured-session-state'
|
||||
|
||||
const MAX_PAGES = 100
|
||||
const MAX_ENTRIES = CODEX_RESTORE_MAX_OPERATIONS
|
||||
|
||||
class CodexRewindTargetRetainedError extends Error {}
|
||||
class CodexRewindTargetMissingError extends Error {}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('agent_session_rewind:invalid-provider-response')
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function cursor(value: unknown): string | null {
|
||||
if (value === null || (typeof value === 'string' && value.length > 0)) {
|
||||
return value
|
||||
}
|
||||
throw new Error('agent_session_rewind:invalid-provider-cursor')
|
||||
}
|
||||
|
||||
/** Read both indexes to completion before accepting the retained history. */
|
||||
export async function verifyCodexRevertedHistory(
|
||||
session: Pick<CodexSession, 'connection' | 'threadId'>,
|
||||
reply: Record<string, unknown>,
|
||||
beforeTurnId: string,
|
||||
timeoutMs?: number,
|
||||
targetPresence: 'absent' | 'present' = 'absent'
|
||||
): Promise<{ identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[]> {
|
||||
let bytes = 0
|
||||
let entries = 0
|
||||
const turns = new Map<string, { id: string; items: unknown[] }>()
|
||||
for (const [method, firstCursor] of [
|
||||
['thread/turns/list', cursor(reply.turnsBackwardsCursor)],
|
||||
['thread/items/list', cursor(reply.itemsBackwardsCursor)]
|
||||
] as const) {
|
||||
let next = firstCursor
|
||||
const seen = new Set<string>()
|
||||
for (let page = 0; ; page += 1) {
|
||||
if (page >= MAX_PAGES || (next !== null && seen.has(next))) {
|
||||
throw new Error('agent_session_rewind:history-limit')
|
||||
}
|
||||
if (next !== null) {
|
||||
seen.add(next)
|
||||
}
|
||||
const result = record(
|
||||
await session.connection.request(
|
||||
method,
|
||||
{
|
||||
threadId: session.threadId,
|
||||
cursor: next,
|
||||
sortDirection: 'desc',
|
||||
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
|
||||
},
|
||||
{ timeoutMs }
|
||||
)
|
||||
)
|
||||
if (!Array.isArray(result.data)) {
|
||||
throw new Error('agent_session_rewind:invalid-provider-page')
|
||||
}
|
||||
bytes += Buffer.byteLength(JSON.stringify(result), 'utf8')
|
||||
entries += result.data.length
|
||||
if (bytes > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES || entries > MAX_ENTRIES) {
|
||||
throw new Error('agent_session_rewind:history-limit')
|
||||
}
|
||||
for (const raw of result.data) {
|
||||
const item = record(raw)
|
||||
const turnId = method === 'thread/turns/list' ? item.id : item.turnId
|
||||
if (turnId === beforeTurnId && targetPresence === 'absent') {
|
||||
throw new CodexRewindTargetRetainedError('agent_session_rewind:target-retained')
|
||||
}
|
||||
if (typeof turnId !== 'string' || !turnId) {
|
||||
throw new Error('agent_session_rewind:invalid-retained-turn')
|
||||
}
|
||||
if (method === 'thread/turns/list') {
|
||||
if (turns.has(turnId)) {
|
||||
throw new Error('agent_session_rewind:duplicate-retained-turn')
|
||||
}
|
||||
turns.set(turnId, { id: turnId, items: [] })
|
||||
} else {
|
||||
const turn = turns.get(turnId)
|
||||
if (!turn) {
|
||||
throw new Error('agent_session_rewind:foreign-retained-item')
|
||||
}
|
||||
turn.items.push(record(item.item))
|
||||
}
|
||||
}
|
||||
next = cursor(result.nextCursor)
|
||||
if (next === null) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetPresence === 'present' && !turns.has(beforeTurnId)) {
|
||||
throw new CodexRewindTargetMissingError('agent_session_rewind:target-missing')
|
||||
}
|
||||
const items = new Map<
|
||||
string,
|
||||
{ identity: AgentJournalItemIdentity; body: AgentJournalItemBody }
|
||||
>()
|
||||
const translator = createCodexJournalTranslator({
|
||||
sink: {
|
||||
appendItem: (identity, body) => {
|
||||
items.set(agentJournalItemKey(identity), { identity, body })
|
||||
},
|
||||
appendTombstone: (identity) => {
|
||||
items.delete(agentJournalItemKey(identity))
|
||||
},
|
||||
publish: () => {}
|
||||
},
|
||||
primaryThreadId: () => session.threadId
|
||||
})
|
||||
try {
|
||||
const chronological = [...turns.values()].toReversed()
|
||||
const retained =
|
||||
targetPresence === 'present'
|
||||
? chronological.slice(
|
||||
0,
|
||||
chronological.findIndex((turn) => turn.id === beforeTurnId)
|
||||
)
|
||||
: chronological
|
||||
const admission = translator.restoreThread(session.threadId, {
|
||||
turns: retained.map((turn) => ({ ...turn, items: turn.items.toReversed() }))
|
||||
})
|
||||
if (!admission.accepted) {
|
||||
throw new Error('agent_session_rewind:history-unreadable')
|
||||
}
|
||||
return [...items.values()]
|
||||
} finally {
|
||||
translator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function preflightCodexRewind(
|
||||
session: CodexSession,
|
||||
fence: number,
|
||||
timeoutMs?: number
|
||||
): Promise<
|
||||
{ ok: true } | { ok: false; reason: 'invalid-target' | 'history-not-paginated' | 'busy' }
|
||||
> {
|
||||
if (session.fence !== fence || session.ended) {
|
||||
return { ok: false, reason: 'invalid-target' }
|
||||
}
|
||||
if (session.historyMode === 'legacy') {
|
||||
return { ok: false, reason: 'history-not-paginated' }
|
||||
}
|
||||
if (session.activeTurnIds?.size || session.dispatchPending) {
|
||||
return { ok: false, reason: 'busy' }
|
||||
}
|
||||
const metadata = record(
|
||||
await session.connection.request(
|
||||
'thread/read',
|
||||
{ threadId: session.threadId, includeTurns: false },
|
||||
{ timeoutMs }
|
||||
)
|
||||
)
|
||||
const thread = record(metadata.thread)
|
||||
if (thread.id !== session.threadId) {
|
||||
return { ok: false, reason: 'invalid-target' }
|
||||
}
|
||||
if (thread.historyMode === 'legacy') {
|
||||
session.historyMode = 'legacy'
|
||||
return { ok: false, reason: 'history-not-paginated' }
|
||||
}
|
||||
if (
|
||||
record(thread.status).type !== 'idle' ||
|
||||
session.activeTurnIds?.size ||
|
||||
session.dispatchPending
|
||||
) {
|
||||
return { ok: false, reason: 'busy' }
|
||||
}
|
||||
if (session.fence !== fence || session.ended) {
|
||||
return { ok: false, reason: 'invalid-target' }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function recoverCodexRewind(
|
||||
session: CodexSession,
|
||||
input: { fence: number; beforeTurnId: string },
|
||||
timeoutMs?: number
|
||||
): ReturnType<NonNullable<StructuredAgentSessionAdapter['recoverRewind']>> {
|
||||
const admission = await preflightCodexRewind(session, input.fence, timeoutMs)
|
||||
if (!admission.ok) {
|
||||
return admission
|
||||
}
|
||||
try {
|
||||
const items = await verifyCodexRevertedHistory(
|
||||
session,
|
||||
{ turnsBackwardsCursor: null, itemsBackwardsCursor: null },
|
||||
input.beforeTurnId,
|
||||
timeoutMs
|
||||
)
|
||||
if (session.fence !== input.fence || session.ended) {
|
||||
return { ok: false, reason: 'invalid-target' }
|
||||
}
|
||||
if (session.activeTurnIds?.size || session.dispatchPending) {
|
||||
return { ok: false, reason: 'busy' }
|
||||
}
|
||||
return { ok: true, items }
|
||||
} catch (error) {
|
||||
if (error instanceof CodexRewindTargetRetainedError) {
|
||||
return { ok: false, reason: 'provider-refused' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewindCodexSession(
|
||||
session: CodexSession,
|
||||
input: Omit<Parameters<NonNullable<StructuredAgentSessionAdapter['rewind']>>[0], 'sessionId'>,
|
||||
timeoutMs?: number
|
||||
): ReturnType<NonNullable<StructuredAgentSessionAdapter['rewind']>> {
|
||||
const admission = await preflightCodexRewind(session, input.fence, timeoutMs)
|
||||
if (!admission.ok) {
|
||||
return admission
|
||||
}
|
||||
let expectedItems: Set<string>
|
||||
try {
|
||||
const retained = await verifyCodexRevertedHistory(
|
||||
session,
|
||||
{ turnsBackwardsCursor: null, itemsBackwardsCursor: null },
|
||||
input.beforeTurnId,
|
||||
timeoutMs,
|
||||
'present'
|
||||
)
|
||||
expectedItems = new Set(retained.map(({ identity }) => agentJournalItemKey(identity)))
|
||||
await input.onPrepared?.(retained)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
error instanceof CodexRewindTargetMissingError
|
||||
? 'invalid-target'
|
||||
: error instanceof Error && error.message === 'agent_session_rewind:history-limit'
|
||||
? 'history-limit'
|
||||
: 'provider-refused'
|
||||
}
|
||||
}
|
||||
const current = await preflightCodexRewind(session, input.fence, timeoutMs)
|
||||
if (!current.ok) {
|
||||
return current
|
||||
}
|
||||
let result: unknown
|
||||
try {
|
||||
result = await session.connection.request(
|
||||
'thread/revert',
|
||||
{
|
||||
threadId: session.threadId,
|
||||
beforeTurnId: input.beforeTurnId
|
||||
},
|
||||
{ timeoutMs }
|
||||
)
|
||||
} catch (error) {
|
||||
if (isCodexAppServerRequestError(error)) {
|
||||
if (error.message === 'thread/revert only supports paginated threads') {
|
||||
session.historyMode = 'legacy'
|
||||
return { ok: false, reason: 'history-not-paginated' }
|
||||
}
|
||||
if (error.code === -32601) {
|
||||
return { ok: false, reason: 'unsupported' }
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const reply = record(result)
|
||||
if (record(reply.thread).id !== session.threadId) {
|
||||
throw new Error('agent_session_rewind:foreign-thread')
|
||||
}
|
||||
await input.onReverted?.()
|
||||
const items = await verifyCodexRevertedHistory(session, reply, input.beforeTurnId, timeoutMs)
|
||||
if (
|
||||
items.length !== expectedItems.size ||
|
||||
items.some(({ identity }) => !expectedItems.has(agentJournalItemKey(identity)))
|
||||
) {
|
||||
throw new Error('agent_session_rewind:proof-mismatch')
|
||||
}
|
||||
return { ok: true, items }
|
||||
}
|
||||
|
||||
export function observeCodexRewindActivity(
|
||||
session: CodexSession,
|
||||
method: string,
|
||||
params: unknown
|
||||
): void {
|
||||
if ((readCodexThreadId(params) ?? session.threadId) !== session.threadId) {
|
||||
return
|
||||
}
|
||||
const turnId = readCodexTurnId(params)
|
||||
if (turnId && method === 'turn/started') {
|
||||
session.activeTurnIds?.add(turnId)
|
||||
}
|
||||
if (turnId && method === 'turn/completed') {
|
||||
session.activeTurnIds?.delete(turnId)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,8 @@ export async function acquireCodexStructuredSession(input: {
|
||||
...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string),
|
||||
threadId: opened.threadId,
|
||||
historyPath: opened.historyPath,
|
||||
historyMode: opened.historyMode,
|
||||
activeTurnIds: new Set(),
|
||||
prompts: acquisition.prompts,
|
||||
options: restoredCodexSessionOptions(acquireInput.options),
|
||||
reportedOptions: reportedCodexThreadOptions(opened),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as codexRewind from './codex-structured-rewind'
|
||||
import type {
|
||||
AgentJournalMessageItem,
|
||||
AgentSessionJournalIdentity
|
||||
@@ -119,6 +120,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
method: string,
|
||||
params: unknown
|
||||
): CodexJournalTranslationAdmission {
|
||||
codexRewind.observeCodexRewindActivity(session, method, params)
|
||||
if (this.turnCancellation.handleNotification(sessionId, session, method, params)) {
|
||||
return { accepted: true }
|
||||
}
|
||||
@@ -177,8 +179,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
fence: number
|
||||
}): Promise<AgentSessionDispatchOutcome> {
|
||||
const session = this.session(input.sessionId)
|
||||
await this.turnCancellation.captureBaseline(session)
|
||||
return dispatchCodexTurn(session, input, this.deps.requestTimeoutMs)
|
||||
session.dispatchPending = true
|
||||
try {
|
||||
await this.turnCancellation.captureBaseline(session)
|
||||
return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs)
|
||||
} finally {
|
||||
session.dispatchPending = false
|
||||
}
|
||||
}
|
||||
|
||||
async cancelTurn(input: {
|
||||
@@ -191,6 +198,17 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
return turnId ? this.turnCancellation.cancel(session, turnId) : { cancelled: false }
|
||||
}
|
||||
|
||||
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = (sessionId) =>
|
||||
this.sessions.get(sessionId)?.historyMode === 'legacy'
|
||||
? { supported: false, reason: 'history-not-paginated' }
|
||||
: { supported: true }
|
||||
|
||||
rewind: NonNullable<StructuredAgentSessionAdapter['rewind']> = (input) =>
|
||||
codexRewind.rewindCodexSession(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
|
||||
|
||||
recoverRewind: NonNullable<StructuredAgentSessionAdapter['recoverRewind']> = (input) =>
|
||||
codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
|
||||
|
||||
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) => {
|
||||
const session = this.session(input.sessionId)
|
||||
return this.compactions.run(
|
||||
|
||||
@@ -65,6 +65,9 @@ export type CodexSession = {
|
||||
acquisitionGeneration: string
|
||||
threadId: string
|
||||
historyPath: string | null
|
||||
historyMode?: 'legacy' | 'paginated'
|
||||
activeTurnIds?: Set<string>
|
||||
dispatchPending?: boolean
|
||||
prompts: CodexAcquisitionWindow['prompts']
|
||||
options: Map<string, string>
|
||||
reportedOptions: { model?: string; effort?: string }
|
||||
|
||||
@@ -16,6 +16,7 @@ export type CodexOpenedThread = {
|
||||
thread?: Record<string, unknown>
|
||||
/** Rollout file Codex named, when it named one. */
|
||||
historyPath: string | null
|
||||
historyMode?: 'legacy' | 'paginated'
|
||||
model?: string
|
||||
effort?: string
|
||||
}
|
||||
@@ -92,6 +93,9 @@ export async function openCodexThread(
|
||||
threadId,
|
||||
thread,
|
||||
historyPath: readCodexThreadPath(opened),
|
||||
...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated'
|
||||
? { historyMode: thread.historyMode }
|
||||
: {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort } : {})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Error classes shared across the daemon protocol boundary (client, server,
|
||||
// host). Split from types.ts, which is capped for wire-shape declarations.
|
||||
import { SESSION_NOT_FOUND_MESSAGE_PREFIX } from '../../shared/pty-attach-absence-evidence'
|
||||
|
||||
const ATTACH_CANCELED_PREFIX = 'Attach canceled for session '
|
||||
|
||||
export class TerminalAttachCanceledError extends Error {
|
||||
@@ -56,7 +58,7 @@ export const DAEMON_UNAVAILABLE_RECONNECT_MESSAGE = 'Daemon temporarily unavaila
|
||||
|
||||
export class SessionNotFoundError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Session not found: ${sessionId}`)
|
||||
super(`${SESSION_NOT_FOUND_MESSAGE_PREFIX}${sessionId}`)
|
||||
this.name = 'SessionNotFoundError'
|
||||
}
|
||||
}
|
||||
@@ -87,8 +89,7 @@ export function isDaemonEndpointGoneError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export function decodeDaemonResponseError(message: string): Error {
|
||||
const prefix = 'Session not found: '
|
||||
return message.startsWith(prefix)
|
||||
? new SessionNotFoundError(message.slice(prefix.length))
|
||||
return message.startsWith(SESSION_NOT_FOUND_MESSAGE_PREFIX)
|
||||
? new SessionNotFoundError(message.slice(SESSION_NOT_FOUND_MESSAGE_PREFIX.length))
|
||||
: new DaemonProtocolError(message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* A fresh adapter (new app process) taking over a session an older process spawned. */
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { SessionNotFoundError } from './daemon-errors'
|
||||
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
|
||||
|
||||
// After an app restart a settled orchestration worker's pane reattaches under `attachOnly`. That
|
||||
// reattach is what makes the restarted main process an owner of the daemon session, so every
|
||||
// downstream consumer of the adapter cache — `hasPty`, resize, write, liveness, the serializer —
|
||||
// sees a session main actually owns. The pane used to attach renderer-only, leaving this cache
|
||||
// empty and `pty:hasPty` fabricating an absence the renderer closed the tab on (#16904 regression).
|
||||
describe('DaemonPtyAdapter attach-only ownership after a restart', () => {
|
||||
let harness: Awaited<ReturnType<typeof startDaemonAdapterHarness>>
|
||||
let restartedAdapter: DaemonPtyAdapter
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await startDaemonAdapterHarness(() => createMockSubprocess())
|
||||
restartedAdapter = new DaemonPtyAdapter({
|
||||
socketPath: harness.socketPath,
|
||||
tokenPath: harness.tokenPath
|
||||
})
|
||||
})
|
||||
afterEach(async () => {
|
||||
restartedAdapter.dispose()
|
||||
harness.adapter.dispose()
|
||||
await harness.server.shutdown()
|
||||
rmSync(harness.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('owns the session after an attach-only spawn, so the cache alone answers present', async () => {
|
||||
const { id } = await harness.adapter.spawn({ cols: 80, rows: 24 })
|
||||
expect(restartedAdapter.hasPty(id)).toBe(false)
|
||||
|
||||
const attached = await restartedAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: id,
|
||||
attachOnly: true
|
||||
})
|
||||
|
||||
expect(attached.id).toBe(id)
|
||||
expect(attached.isReattach).toBe(true)
|
||||
expect(restartedAdapter.hasPty(id)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to create a session the daemon does not have', async () => {
|
||||
await expect(
|
||||
restartedAdapter.spawn({ cols: 80, rows: 24, sessionId: 'never-spawned', attachOnly: true })
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError)
|
||||
expect(restartedAdapter.hasPty('never-spawned')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to replace a session that exited before the attach', async () => {
|
||||
const { id } = await harness.adapter.spawn({ cols: 80, rows: 24 })
|
||||
await harness.adapter.shutdown(id, { immediate: true })
|
||||
|
||||
await expect(
|
||||
restartedAdapter.spawn({ cols: 80, rows: 24, sessionId: id, attachOnly: true })
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError)
|
||||
expect(restartedAdapter.hasPty(id)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,482 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { SessionNotFoundError } from '../daemon/daemon-errors'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { registerPtyHandlers } from './pty'
|
||||
import * as stableAdoption from './pty/pane/adopt-stable'
|
||||
import { ptySizes } from './pty/delivery/visibility-state'
|
||||
import { isHiddenRendererPty } from './pty-hidden-delivery-gate'
|
||||
import { paneSpawnReservationsByOwnerKey } from './pty/pane/spawn-reservation'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../opencode/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../mimo/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/server', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../pi/titlebar-extension-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
|
||||
vi.mock('../wsl', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../telemetry/client', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../telemetry/classify-error', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../memory/pty-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-pane-account-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
describe('pty:spawn under a persisted main-owned resume fence', () => {
|
||||
const {
|
||||
handlers,
|
||||
mainWindow,
|
||||
installDaemonTestProvider,
|
||||
recoveredAgentClaim,
|
||||
recoveredAgentSurface
|
||||
} = setupPtyIpcSuite()
|
||||
|
||||
function buildFencedPaneContext(name: string) {
|
||||
const worktreeId = `repo-1::/tmp/${name}`
|
||||
const tabId = `tab-${name}`
|
||||
const leafId = '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b'
|
||||
const ptyId = `pty-${name}`
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
let session = {
|
||||
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: {
|
||||
root: { type: 'leaf' as const, leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId } as Record<string, string>
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' }
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: `inc-${name}` }
|
||||
}
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn(() => session),
|
||||
setWorkspaceSession: vi.fn((next: typeof session) => {
|
||||
session = next
|
||||
}),
|
||||
flushOrThrow: vi.fn(),
|
||||
persistPtyBinding: vi.fn(),
|
||||
getFolderWorkspace: vi.fn(() => undefined),
|
||||
getFolderWorkspaces: vi.fn(() => []),
|
||||
getProjectGroups: vi.fn(() => []),
|
||||
getRepos: vi.fn(() => [])
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn((_controller: unknown) => {}),
|
||||
resolveTerminalPane: vi.fn(() => {
|
||||
throw new Error('terminal_not_found')
|
||||
}),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => `term-${name}`),
|
||||
preAllocateHandleForPty: vi.fn(() => `term-${name}`),
|
||||
registerPreAllocatedHandleForPty: vi.fn(),
|
||||
beginPtyRegistration: vi.fn(),
|
||||
cancelPendingPtyRegistration: vi.fn(),
|
||||
assertPtyRegistrationAllowed: vi.fn(),
|
||||
preparePtyExecutionContext: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
seedHeadlessTerminal: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
const spawnArgs = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: `/tmp/${name}`,
|
||||
worktreeId,
|
||||
tabId,
|
||||
leafId,
|
||||
sessionId: ptyId,
|
||||
initiallyHidden: true,
|
||||
env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId }
|
||||
}
|
||||
return { ptyId, store, runtime, spawnArgs }
|
||||
}
|
||||
|
||||
it('attaches the restored session so main owns it, without a fresh spawn', async () => {
|
||||
const { ptyId, store, runtime, spawnArgs } = buildFencedPaneContext('fenced-live-worker')
|
||||
const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => {
|
||||
if (!options.attachOnly) {
|
||||
throw new Error('a fenced pane must never reach a fresh spawn')
|
||||
}
|
||||
return { id: options.sessionId!, incarnationId: 'inc-fenced-live-worker', isReattach: true }
|
||||
})
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
|
||||
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).resolves.toMatchObject({ id: ptyId })
|
||||
expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true)
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to mint a replacement session when the owner proves the session absent', async () => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('fenced-absent-worker')
|
||||
const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => {
|
||||
if (options.attachOnly) {
|
||||
throw new SessionNotFoundError(options.sessionId ?? '')
|
||||
}
|
||||
return { id: 'pty-replacement', incarnationId: 'inc-replacement' }
|
||||
})
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
|
||||
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).resolves.toEqual({
|
||||
id: spawnArgs.sessionId,
|
||||
exitedBeforeAttach: true
|
||||
})
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(runtime.beginPtyRegistration).not.toHaveBeenCalled()
|
||||
expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false)
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(providerSpawn.mock.calls.every(([options]) => options.attachOnly === true)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows an unfenced fresh tab to spawn', async () => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('fenced-no-session')
|
||||
const providerSpawn = installDaemonTestProvider()
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
...spawnArgs,
|
||||
sessionId: undefined,
|
||||
tabId: 'tab-unbound',
|
||||
leafId: '6c6c6c6c-6c6c-4c6c-8c6c-6c6c6c6c6c6c'
|
||||
})
|
||||
|
||||
expect(providerSpawn.mock.calls.at(-1)?.[0]).not.toMatchObject({ attachOnly: true })
|
||||
})
|
||||
it.each([new Error('daemon timeout'), new Error('Session not found: untyped')])(
|
||||
'preserves every binding on unverifiable local evidence: %s',
|
||||
async (error) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('unverifiable-worker')
|
||||
const providerSpawn = vi.fn(async () => {
|
||||
throw error
|
||||
})
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
await expect(handlers.get('pty:spawn')!(null, spawnArgs)).resolves.toEqual({
|
||||
id: spawnArgs.sessionId,
|
||||
reattachUnverifiable: true
|
||||
})
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'never spawns an ownerless fenced pane (session supplied: %s)',
|
||||
async (supplied) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('ownerless-worker')
|
||||
store.getWorkspaceSession().terminalLayoutsByTabId[spawnArgs.tabId].ptyIdsByLeafId = {}
|
||||
const providerSpawn = installDaemonTestProvider()
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
...spawnArgs,
|
||||
sessionId: supplied ? spawnArgs.sessionId : undefined
|
||||
})
|
||||
).resolves.toMatchObject({ reattachUnverifiable: true })
|
||||
expect(providerSpawn).not.toHaveBeenCalled()
|
||||
expect(runtime.beginPtyRegistration).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'runtime controller refuses ownerless fenced pane (session supplied: %s)',
|
||||
async (supplied) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('runtime-ownerless-worker')
|
||||
store.getWorkspaceSession().terminalLayoutsByTabId[spawnArgs.tabId].ptyIdsByLeafId = {}
|
||||
const providerSpawn = installDaemonTestProvider()
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
spawn: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
await expect(
|
||||
controller.spawn({ ...spawnArgs, sessionId: supplied ? spawnArgs.sessionId : undefined })
|
||||
).resolves.toMatchObject({ reattachUnverifiable: true })
|
||||
expect(providerSpawn).not.toHaveBeenCalled()
|
||||
expect(runtime.beginPtyRegistration).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
it('settles execute-stage refusal without registration, hidden marks or size changes', async () => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('execute-refusal')
|
||||
const earlyAdoption = vi.spyOn(stableAdoption, 'adoptStablePane').mockResolvedValueOnce(null)
|
||||
const providerSpawn = vi.fn(async () => {
|
||||
runtime.preparePtyExecutionContext.mockClear()
|
||||
throw new SessionNotFoundError(spawnArgs.sessionId)
|
||||
})
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
ptySizes.set(spawnArgs.sessionId, { cols: 120, rows: 40 })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
try {
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, { ...spawnArgs, initiallyHidden: true })
|
||||
).resolves.toEqual({ id: spawnArgs.sessionId, exitedBeforeAttach: true })
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(isHiddenRendererPty(spawnArgs.sessionId)).toBe(false)
|
||||
expect(ptySizes.get(spawnArgs.sessionId)).toEqual({ cols: 120, rows: 40 })
|
||||
expect(paneSpawnReservationsByOwnerKey.size).toBe(0)
|
||||
} finally {
|
||||
earlyAdoption.mockRestore()
|
||||
}
|
||||
})
|
||||
it.each([false, true])(
|
||||
'runtime controller carries a fenced owner refusal (claim: %s)',
|
||||
async (ensureClaim) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('runtime-absent-worker')
|
||||
const providerSpawn = vi.fn(async () => {
|
||||
runtime.preparePtyExecutionContext.mockClear()
|
||||
throw new SessionNotFoundError(spawnArgs.sessionId)
|
||||
})
|
||||
installDaemonTestProvider({ spawn: providerSpawn })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
spawn: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
await expect(
|
||||
controller.spawn({
|
||||
...spawnArgs,
|
||||
...(ensureClaim
|
||||
? { agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface } }
|
||||
: {})
|
||||
})
|
||||
).resolves.toEqual({
|
||||
id: spawnArgs.sessionId,
|
||||
exitedBeforeAttach: true
|
||||
})
|
||||
expect(providerSpawn).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(paneSpawnReservationsByOwnerKey.size).toBe(0)
|
||||
}
|
||||
)
|
||||
it.each([false, true])(
|
||||
'carries unverifiable through the runtime controller (pre-adopted: %s)',
|
||||
async (preAdopted) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('unverifiable-controller')
|
||||
const spawn = vi.fn(async () => {
|
||||
throw new Error('daemon unavailable')
|
||||
})
|
||||
installDaemonTestProvider({ spawn })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
spawn: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
const result = await controller.spawn({
|
||||
...spawnArgs,
|
||||
...(preAdopted
|
||||
? {
|
||||
adoptedStablePane: {
|
||||
result: { id: spawnArgs.sessionId, reattachUnverifiable: true },
|
||||
owner: {
|
||||
ptyId: spawnArgs.sessionId,
|
||||
tabId: spawnArgs.tabId,
|
||||
leafId: spawnArgs.leafId
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
})
|
||||
expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
if (preAdopted) {
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(runtime.beginPtyRegistration).not.toHaveBeenCalled()
|
||||
expect(runtime.preparePtyExecutionContext).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
it.each(['ipc', 'runtime'] as const)(
|
||||
'preserves a fence committed during %s attach',
|
||||
async (entry) => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('late-fenced-worker')
|
||||
const record =
|
||||
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[
|
||||
makePaneKey(spawnArgs.tabId, spawnArgs.leafId)
|
||||
]
|
||||
record.automaticResumeBlockedBy = ''
|
||||
const spawn = vi.fn(async () => {
|
||||
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
|
||||
throw new SessionNotFoundError(spawnArgs.sessionId)
|
||||
})
|
||||
installDaemonTestProvider({ spawn })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
spawn: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
const result =
|
||||
entry === 'ipc'
|
||||
? await handlers.get('pty:spawn')!(null, spawnArgs)
|
||||
: await controller.spawn(spawnArgs)
|
||||
expect(result).toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
it('refuses a claim spawn when the fence commits during owner reconciliation', async () => {
|
||||
const { store, runtime, spawnArgs } = buildFencedPaneContext('late-claim-fence')
|
||||
const record =
|
||||
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[
|
||||
makePaneKey(spawnArgs.tabId, spawnArgs.leafId)
|
||||
]
|
||||
record.automaticResumeBlockedBy = ''
|
||||
const spawn = vi.fn(async () => ({ id: 'replacement' }))
|
||||
const listProcesses = vi.fn(async () => {
|
||||
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
|
||||
return []
|
||||
})
|
||||
installDaemonTestProvider({ spawn, listProcesses })
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime as never,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
spawn: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
await expect(
|
||||
controller.spawn({
|
||||
...spawnArgs,
|
||||
agentSessionEnsure: { claim: recoveredAgentClaim, surface: recoveredAgentSurface }
|
||||
})
|
||||
).resolves.toEqual({ id: spawnArgs.sessionId, reattachUnverifiable: true })
|
||||
expect(listProcesses).toHaveBeenCalled()
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(runtime.registerPty).not.toHaveBeenCalled()
|
||||
expect(runtime.assertPtyRegistrationAllowed).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(spawnArgs.sessionId)
|
||||
expect(paneSpawnReservationsByOwnerKey.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -542,4 +542,29 @@ describe('registerPtyHandlers', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
it.each(['renderer', 'runtime'] as const)(
|
||||
'%s kill treats daemon session-not-found as completed shutdown',
|
||||
async (entry) => {
|
||||
const id = 'daemon-missing-session'
|
||||
const shutdown = vi.fn(async () => {
|
||||
throw new Error(`Session not found: ${id}`)
|
||||
})
|
||||
installDaemonTestProvider({ shutdown })
|
||||
setPtyOwnership(id, null)
|
||||
const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() }
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
if (entry === 'renderer') {
|
||||
await expect(handlers.get('pty:kill')!(null, { id })).resolves.toBeUndefined()
|
||||
} else {
|
||||
const controller = runtime.setPtyController.mock.calls[0]![0] as {
|
||||
kill: (id: string) => boolean
|
||||
}
|
||||
expect(controller.kill(id)).toBe(true)
|
||||
}
|
||||
await vi.waitFor(() => expect(runtime.onPtyExit).toHaveBeenCalled())
|
||||
expect(shutdown).toHaveBeenCalledTimes(1)
|
||||
const { ptyOwnership } = await import('./pty/provider/ownership-state')
|
||||
expect(ptyOwnership.has(id)).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
paneSpawnReservationsByOwnerKey,
|
||||
pendingRuntimePaneCreatesByOwnerKey
|
||||
} from '../pane/spawn-reservation'
|
||||
import { isStablePaneResumeBlocked } from '../pane/stable-pane-resume-fence'
|
||||
import { resolveStablePaneOwner } from '../pane/stable-owner'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
|
||||
@@ -96,6 +97,12 @@ export async function beginPtyIpcSpawn(
|
||||
args.connectionId
|
||||
)
|
||||
: null
|
||||
if (
|
||||
!ctx.earlyStablePaneOwner &&
|
||||
isStablePaneResumeBlocked(ctx.deps.store, earlyPaneKey, args.worktreeId, args.connectionId)
|
||||
) {
|
||||
return { id: args.sessionId ?? '', reattachUnverifiable: true }
|
||||
}
|
||||
ctx.earlyWorktreeId = args.worktreeId
|
||||
// Reserve early so renderer/runtime materialization cannot start duplicate provider spawns.
|
||||
ctx.paneSpawnReservationKey = earlyReservationKey
|
||||
|
||||
@@ -67,6 +67,9 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
|
||||
})
|
||||
ctx.result = stablePaneSpawn.result
|
||||
ctx.stablePaneOwner = stablePaneSpawn.owner
|
||||
if (ctx.result.exitedBeforeAttach || ctx.result.reattachUnverifiable) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
ctx.stablePaneOwner &&
|
||||
ctx.isMintedSessionId &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { rejectPaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import { rejectPaneSpawnReservation, resolvePaneSpawnReservation } from '../pane/spawn-reservation'
|
||||
import { ptySizes } from '../delivery/visibility-state'
|
||||
import { beginPtyIpcSpawn } from './spawn-begin'
|
||||
import { preparePtyIpcSpawnPreflight } from './spawn-preflight'
|
||||
@@ -39,6 +39,17 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
|
||||
}
|
||||
try {
|
||||
await preparePtyIpcSpawnPreflight(ctx)
|
||||
const attachOutcome = ctx.preAdoptedStablePane?.result
|
||||
if (attachOutcome?.exitedBeforeAttach || attachOutcome?.reattachUnverifiable) {
|
||||
if (ctx.preSpawnHiddenMarkId !== null) {
|
||||
ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false)
|
||||
}
|
||||
return resolvePaneSpawnReservation(
|
||||
ctx.paneSpawnReservationKey,
|
||||
ctx.paneSpawnReservation,
|
||||
attachOutcome
|
||||
)
|
||||
}
|
||||
await assemblePtyIpcSpawnEnv(ctx)
|
||||
const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => {
|
||||
restoreProvisionalPtySize(ctx)
|
||||
@@ -53,6 +64,20 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
|
||||
return earlyReserved
|
||||
}
|
||||
await executePtyIpcSpawn(ctx)
|
||||
if (ctx.result.exitedBeforeAttach || ctx.result.reattachUnverifiable) {
|
||||
releaseAbandonedAgentTeamsLeader(ctx)
|
||||
restoreProvisionalPtySize(ctx)
|
||||
if (ctx.preSpawnHiddenMarkId !== null) {
|
||||
ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false)
|
||||
}
|
||||
deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId ?? ctx.result.id)
|
||||
ctx.pendingRegistrationPtyId = null
|
||||
return resolvePaneSpawnReservation(
|
||||
ctx.paneSpawnReservationKey,
|
||||
ctx.paneSpawnReservation,
|
||||
ctx.result
|
||||
)
|
||||
}
|
||||
return await commitPtyIpcSpawn(ctx)
|
||||
} catch (err) {
|
||||
releaseAbandonedAgentTeamsLeader(ctx)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { Store } from '../../../persistence'
|
||||
import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement'
|
||||
import { resolvePersistedStablePaneOwner, type StablePaneOwner } from './stable-owner'
|
||||
|
||||
export function retirePersistedStablePaneOwner(
|
||||
store: Store | undefined,
|
||||
owner: StablePaneOwner,
|
||||
worktreeId: string,
|
||||
connectionId: string | null | undefined
|
||||
): boolean {
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
const paneKey = makePaneKey(owner.tabId, owner.leafId)
|
||||
const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined
|
||||
const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId)
|
||||
if (!current) {
|
||||
// Why: persistence already dropped this pane binding (an earlier stop retired it while the
|
||||
// runtime kept history), so there is nothing left to clear — that is a completed retirement,
|
||||
// not a competing owner. Reporting failure here strands the pane after its PTY is proven dead.
|
||||
return true
|
||||
}
|
||||
if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) {
|
||||
return false
|
||||
}
|
||||
const session = store.getWorkspaceSession(hostId)
|
||||
const retired = retireTerminalSurfaceFromPersistence(session, {
|
||||
worktreeId,
|
||||
parentTabId: owner.tabId,
|
||||
leafId: owner.leafId,
|
||||
ptyId: owner.ptyId,
|
||||
...(current.incarnationId ? { incarnationId: current.incarnationId } : {})
|
||||
})
|
||||
if (retired === session) {
|
||||
return false
|
||||
}
|
||||
store.setWorkspaceSession(retired, hostId)
|
||||
store.flushOrThrow()
|
||||
return true
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { isStablePaneResumeBlocked } from './stable-pane-resume-fence'
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause'
|
||||
import type { Store } from '../../../persistence'
|
||||
import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement'
|
||||
import { retirePersistedStablePaneOwner } from './stable-owner-retirement'
|
||||
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types'
|
||||
import { parseAppSshPtyId } from '../../../providers/ssh-pty-id'
|
||||
@@ -120,43 +121,6 @@ export function resolveStablePaneOwner(
|
||||
}
|
||||
}
|
||||
|
||||
export function retirePersistedStablePaneOwner(
|
||||
store: Store | undefined,
|
||||
owner: StablePaneOwner,
|
||||
worktreeId: string,
|
||||
connectionId: string | null | undefined
|
||||
): boolean {
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
const paneKey = makePaneKey(owner.tabId, owner.leafId)
|
||||
const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined
|
||||
const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId)
|
||||
if (!current) {
|
||||
// Why: persistence already dropped this pane binding (an earlier stop retired it while the
|
||||
// runtime kept history), so there is nothing left to clear — that is a completed retirement,
|
||||
// not a competing owner. Reporting failure here strands the pane after its PTY is proven dead.
|
||||
return true
|
||||
}
|
||||
if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) {
|
||||
return false
|
||||
}
|
||||
const session = store.getWorkspaceSession(hostId)
|
||||
const retired = retireTerminalSurfaceFromPersistence(session, {
|
||||
worktreeId,
|
||||
parentTabId: owner.tabId,
|
||||
leafId: owner.leafId,
|
||||
ptyId: owner.ptyId,
|
||||
...(current.incarnationId ? { incarnationId: current.incarnationId } : {})
|
||||
})
|
||||
if (retired === session) {
|
||||
return false
|
||||
}
|
||||
store.setWorkspaceSession(retired, hostId)
|
||||
store.flushOrThrow()
|
||||
return true
|
||||
}
|
||||
|
||||
export type StablePaneSpawnContext = {
|
||||
runtime: OrcaRuntimeService | undefined
|
||||
store?: Store
|
||||
@@ -214,6 +178,13 @@ export async function attachStablePaneOwner(
|
||||
args: StablePaneSpawnContext & { owner: StablePaneOwner }
|
||||
): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> {
|
||||
const { owner, provider, runtime, spawnOptions } = args
|
||||
const paneKey = makePaneKey(owner.tabId, owner.leafId)
|
||||
const blockedAtAttach = isStablePaneResumeBlocked(
|
||||
args.store,
|
||||
paneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
let result: PtySpawnResult
|
||||
try {
|
||||
result = await provider.spawn({
|
||||
@@ -233,6 +204,17 @@ export async function attachStablePaneOwner(
|
||||
onPtySpawnCommitted: undefined
|
||||
})
|
||||
} catch (error) {
|
||||
if (isStablePaneResumeBlocked(args.store, paneKey, args.worktreeId, args.connectionId)) {
|
||||
return {
|
||||
owner,
|
||||
result: {
|
||||
id: owner.ptyId,
|
||||
...(blockedAtAttach && isObservedPtyExitEvidence(error)
|
||||
? { exitedBeforeAttach: true as const }
|
||||
: { reattachUnverifiable: true as const })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error instanceof TerminalSessionOwnerUnverifiedError) {
|
||||
throw new Error('terminal_pane_owner_unverified')
|
||||
}
|
||||
@@ -298,6 +280,19 @@ export async function spawnForStablePane(
|
||||
return attached
|
||||
}
|
||||
}
|
||||
if (
|
||||
isStablePaneResumeBlocked(
|
||||
args.store,
|
||||
args.spawnOptions.paneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
) {
|
||||
return {
|
||||
result: { id: args.spawnOptions.sessionId ?? '', reattachUnverifiable: true },
|
||||
owner: null
|
||||
}
|
||||
}
|
||||
const result = await args.provider.spawn(args.spawnOptions)
|
||||
args.onFreshSpawn?.(result)
|
||||
return { result, owner: null }
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { Store } from '../../../persistence'
|
||||
import { SessionNotFoundError } from '../../../daemon/daemon-errors'
|
||||
import {
|
||||
SshPtyAbsentFromRelayError,
|
||||
SshPtyProvenExitedOnRelayError
|
||||
} from '../../../providers/ssh-pty-errors'
|
||||
import type { IPtyProvider } from '../../../providers/types'
|
||||
import { resolveStablePaneOwner, spawnForStablePane } from './stable-owner'
|
||||
import { ptyOwnership } from '../provider/ownership-state'
|
||||
|
||||
const tabId = 'tab-worker'
|
||||
const leafId = '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
const worktreeId = 'folder-worker'
|
||||
|
||||
function fixture(connectionId: string | null) {
|
||||
const ptyId = connectionId ? `ssh:${connectionId}@@worker` : 'worker'
|
||||
const session = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, type: 'terminal' }] },
|
||||
terminalLayoutsByTabId: { [tabId]: { ptyIdsByLeafId: { [leafId]: ptyId } } },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' }
|
||||
}
|
||||
}
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn(() => session),
|
||||
setWorkspaceSession: vi.fn(),
|
||||
flushOrThrow: vi.fn()
|
||||
}
|
||||
return {
|
||||
ptyId,
|
||||
store,
|
||||
owner: resolveStablePaneOwner(
|
||||
undefined,
|
||||
store as unknown as Store,
|
||||
paneKey,
|
||||
worktreeId,
|
||||
connectionId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('stable-pane fenced attach evidence', () => {
|
||||
it.each([
|
||||
[null, new SessionNotFoundError('worker'), 'exitedBeforeAttach'],
|
||||
[null, new Error('Session not found: worker'), 'reattachUnverifiable'],
|
||||
['host', new SshPtyAbsentFromRelayError('worker'), 'reattachUnverifiable'],
|
||||
['host', new SshPtyProvenExitedOnRelayError('worker'), 'exitedBeforeAttach'],
|
||||
['host', new Error('socket closed'), 'reattachUnverifiable']
|
||||
] as const)('preserves %s ownership for %s', async (connectionId, error, outcome) => {
|
||||
const { ptyId, store, owner } = fixture(connectionId)
|
||||
const spawn = vi.fn(async () => {
|
||||
throw error
|
||||
})
|
||||
ptyOwnership.set(ptyId, connectionId)
|
||||
try {
|
||||
const result = await spawnForStablePane({
|
||||
store: store as unknown as Store,
|
||||
runtime: undefined,
|
||||
provider: { spawn } as unknown as IPtyProvider,
|
||||
owner,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
spawnOptions: { cols: 80, rows: 24, paneKey, command: 'must never run' }
|
||||
})
|
||||
expect(result.result).toEqual({ id: ptyId, [outcome]: true })
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ attachOnly: true, command: undefined })
|
||||
)
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(ptyOwnership.get(ptyId)).toBe(connectionId)
|
||||
expect(store.getWorkspaceSession).toHaveBeenCalledWith(connectionId ? 'ssh:host' : undefined)
|
||||
} finally {
|
||||
ptyOwnership.delete(ptyId)
|
||||
}
|
||||
})
|
||||
it('refuses a newly fenced ownerless pane at the final spawn boundary', async () => {
|
||||
const { store } = fixture(null)
|
||||
const spawn = vi.fn(async () => ({ id: 'must-not-spawn' }))
|
||||
const result = await spawnForStablePane({
|
||||
store: store as unknown as Store,
|
||||
runtime: undefined,
|
||||
provider: { spawn } as unknown as IPtyProvider,
|
||||
owner: null,
|
||||
worktreeId,
|
||||
spawnOptions: { cols: 80, rows: 24, paneKey }
|
||||
})
|
||||
expect(result.result).toMatchObject({ reattachUnverifiable: true })
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rechecks a fence committed while attachment is awaiting the host', async () => {
|
||||
const { store, ptyId } = fixture(null)
|
||||
const record = store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[paneKey]
|
||||
delete (record as { automaticResumeBlockedBy?: string }).automaticResumeBlockedBy
|
||||
const resolveOwner = () =>
|
||||
resolveStablePaneOwner(undefined, store as unknown as Store, paneKey, worktreeId, null)
|
||||
const owner = resolveOwner()
|
||||
let calls = 0
|
||||
const spawn = vi.fn(async () => {
|
||||
if (++calls === 1) {
|
||||
record.automaticResumeBlockedBy = 'legacy-orchestration-worker'
|
||||
throw new SessionNotFoundError(ptyId)
|
||||
}
|
||||
return { id: 'replacement-after-fence' }
|
||||
})
|
||||
const result = await spawnForStablePane({
|
||||
runtime: undefined,
|
||||
store: store as unknown as Store,
|
||||
provider: { spawn } as unknown as IPtyProvider,
|
||||
owner,
|
||||
worktreeId,
|
||||
connectionId: null,
|
||||
resolveOwner,
|
||||
spawnOptions: { cols: 80, rows: 24, paneKey }
|
||||
})
|
||||
expect(result.result).toEqual({ id: ptyId, reattachUnverifiable: true })
|
||||
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { Store } from '../../../persistence'
|
||||
import { SessionNotFoundError } from '../../../daemon/daemon-errors'
|
||||
import type { IPtyProvider } from '../../../providers/types'
|
||||
import { resolveStablePaneOwner, spawnForStablePane } from './stable-owner'
|
||||
|
||||
const tabId = 'tab-worker'
|
||||
const leafId = '5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
const worktreeId = 'folder-worker'
|
||||
|
||||
function fixture(connectionId: string | null) {
|
||||
const ptyId = connectionId ? `ssh:${connectionId}@@worker` : 'worker'
|
||||
let session = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, type: 'terminal' }] },
|
||||
terminalLayoutsByTabId: {
|
||||
[tabId]: {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
ptyIdsByLeafId: { [leafId]: ptyId }
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' }
|
||||
}
|
||||
}
|
||||
const store = {
|
||||
getWorkspaceSession: vi.fn(() => session),
|
||||
setWorkspaceSession: vi.fn((next) => {
|
||||
session = next
|
||||
}),
|
||||
flushOrThrow: vi.fn()
|
||||
}
|
||||
return {
|
||||
ptyId,
|
||||
store,
|
||||
owner: resolveStablePaneOwner(
|
||||
undefined,
|
||||
store as unknown as Store,
|
||||
paneKey,
|
||||
worktreeId,
|
||||
connectionId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('a fence queued after retirement still stops fresh fallback', async () => {
|
||||
const { store, ptyId } = fixture(null)
|
||||
delete (
|
||||
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[paneKey] as {
|
||||
automaticResumeBlockedBy?: string
|
||||
}
|
||||
).automaticResumeBlockedBy
|
||||
const resolveOwner = () => {
|
||||
const owner = resolveStablePaneOwner(
|
||||
undefined,
|
||||
store as unknown as Store,
|
||||
paneKey,
|
||||
worktreeId,
|
||||
null
|
||||
)
|
||||
if (!owner) {
|
||||
queueMicrotask(() => {
|
||||
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey[
|
||||
paneKey
|
||||
].automaticResumeBlockedBy = 'legacy-orchestration-worker'
|
||||
})
|
||||
}
|
||||
return owner
|
||||
}
|
||||
const owner = resolveOwner()
|
||||
const spawn = vi.fn(async (options) => {
|
||||
if (options.attachOnly) {
|
||||
throw new SessionNotFoundError(ptyId)
|
||||
}
|
||||
return { id: 'replacement-despite-new-fence' }
|
||||
})
|
||||
const result = await spawnForStablePane({
|
||||
runtime: undefined,
|
||||
store: store as unknown as Store,
|
||||
provider: { spawn } as unknown as IPtyProvider,
|
||||
owner,
|
||||
worktreeId,
|
||||
connectionId: null,
|
||||
resolveOwner,
|
||||
spawnOptions: { cols: 80, rows: 24, paneKey }
|
||||
})
|
||||
expect(result.result).toMatchObject({ reattachUnverifiable: true })
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { isStablePaneResumeBlocked } from './stable-pane-resume-fence'
|
||||
|
||||
describe('review host-scoped fence', () => {
|
||||
it('reads SSH policy when the identical local pane is not fenced', () => {
|
||||
const paneKey = 'tab-worker:5b5b5b5b-5b5b-4b5b-8b5b-5b5b5b5b5b5b'
|
||||
const worktreeId = 'folder-worker'
|
||||
const local = { sleepingAgentSessionsByPaneKey: {} }
|
||||
const remote = {
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: { worktreeId, automaticResumeBlockedBy: 'legacy-orchestration-worker' }
|
||||
}
|
||||
}
|
||||
const store = { getWorkspaceSession: vi.fn((host) => (host === 'ssh:host' ? remote : local)) }
|
||||
expect(isStablePaneResumeBlocked(store as never, paneKey, worktreeId, 'host')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { isPaneAutomaticResumeBlocked } from '../../../../shared/terminal-pane-resume-fence'
|
||||
import type { Store } from '../../../persistence'
|
||||
|
||||
export function isStablePaneResumeBlocked(
|
||||
store: Store | undefined,
|
||||
paneKey: string | null | undefined,
|
||||
worktreeId: string | undefined,
|
||||
connectionId: string | null | undefined
|
||||
): boolean {
|
||||
if (!paneKey || !worktreeId || !store?.getWorkspaceSession) {
|
||||
return false
|
||||
}
|
||||
return isPaneAutomaticResumeBlocked(
|
||||
store.getWorkspaceSession(connectionId ? toSshExecutionHostId(connectionId) : undefined),
|
||||
paneKey,
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
export class StablePaneResumeBlockedError extends Error {}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay'
|
||||
import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority'
|
||||
import { agentSessionOwnerBindingsEqual } from '../../../../shared/claimed-agent-pty-owner'
|
||||
import { isSessionNotFoundRefusal } from '../../../../shared/pty-attach-absence-evidence'
|
||||
import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints'
|
||||
import { SessionNotFoundError } from '../../../daemon/daemon-errors'
|
||||
import type { Store } from '../../../persistence'
|
||||
@@ -64,7 +65,7 @@ export function isPtyAlreadyGoneError(err: unknown): boolean {
|
||||
// Why: the reattach path rewrites the relay's wording to SSH_SESSION_EXPIRED and only this
|
||||
// class preserves that the relay itself answered "absent" rather than the link dropping.
|
||||
isSshPtyAbsentFromRelayError(err) ||
|
||||
/Session not found/i.test(message)
|
||||
isSessionNotFoundRefusal(message)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ export function adoptMaterializedRuntimePtySpawn(
|
||||
if (startupPromise && !startupAlreadyAwaited) {
|
||||
return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true))
|
||||
}
|
||||
if (
|
||||
ctx.preAdoptedStablePane?.result.exitedBeforeAttach ||
|
||||
ctx.preAdoptedStablePane?.result.reattachUnverifiable
|
||||
) {
|
||||
return ctx.preAdoptedStablePane.result
|
||||
}
|
||||
if (!ctx.preAdoptedStablePane?.materialized) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
isStablePaneResumeBlocked,
|
||||
StablePaneResumeBlockedError
|
||||
} from '../pane/stable-pane-resume-fence'
|
||||
import type { PtySpawnResult } from '../../../providers/types'
|
||||
import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state'
|
||||
import { ptySizes } from '../delivery/visibility-state'
|
||||
@@ -31,15 +35,13 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
}
|
||||
const stablePaneOwnerCandidate = ctx.preAdoptedStablePane
|
||||
? ctx.preAdoptedStablePane.owner
|
||||
: args.agentSessionEnsure
|
||||
? null
|
||||
: resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
ctx.spawnIdentityPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
: resolveStablePaneOwner(
|
||||
ctx.deps.runtime,
|
||||
ctx.deps.store,
|
||||
ctx.spawnIdentityPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
const expectedPtyId =
|
||||
stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.sessionId
|
||||
if (expectedPtyId) {
|
||||
@@ -61,7 +63,16 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
throw new Error('client_disconnected')
|
||||
}
|
||||
}
|
||||
if (args.agentSessionEnsure && !ctx.preAdoptedStablePane) {
|
||||
if (
|
||||
args.agentSessionEnsure &&
|
||||
!ctx.preAdoptedStablePane &&
|
||||
!isStablePaneResumeBlocked(
|
||||
ctx.deps.store,
|
||||
ctx.spawnIdentityPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
) {
|
||||
// Why: daemon-backed claims can outlive this controller; import all
|
||||
// proven owners before deciding that an identity is absent.
|
||||
await reconcileAgentSessionOwnerListings()
|
||||
@@ -82,6 +93,16 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
surface: args.agentSessionEnsure.surface,
|
||||
spawn: async () => {
|
||||
assertClientStillConnected()
|
||||
if (
|
||||
isStablePaneResumeBlocked(
|
||||
ctx.deps.store,
|
||||
ctx.spawnIdentityPaneKey,
|
||||
args.worktreeId,
|
||||
args.connectionId
|
||||
)
|
||||
) {
|
||||
throw new StablePaneResumeBlockedError()
|
||||
}
|
||||
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
|
||||
ctx.rejectedRegistrationCandidate = providerResult
|
||||
// Why: a successful lower-owner return proves physical work committed even if admission sees an early exit.
|
||||
@@ -149,6 +170,9 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
})
|
||||
ctx.result = stablePaneSpawn.result
|
||||
ctx.stablePaneOwner = stablePaneSpawn.owner
|
||||
if (ctx.result.exitedBeforeAttach || ctx.result.reattachUnverifiable) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
ctx.stablePaneOwner &&
|
||||
ctx.isNewDaemonSession &&
|
||||
@@ -196,6 +220,10 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
: ctx.result.wslDistro
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof StablePaneResumeBlockedError) {
|
||||
ctx.result = { id: ctx.sessionId ?? '', reattachUnverifiable: true }
|
||||
return
|
||||
}
|
||||
if (
|
||||
(ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) &&
|
||||
ctx.effectiveSessionAppId
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
makePaneSpawnReservationKey,
|
||||
paneSpawnReservationsByOwnerKey,
|
||||
rejectPaneSpawnReservation,
|
||||
reservePaneSpawn
|
||||
reservePaneSpawn,
|
||||
resolvePaneSpawnReservation
|
||||
} from '../pane/spawn-reservation'
|
||||
import { isStablePaneResumeBlocked } from '../pane/stable-pane-resume-fence'
|
||||
import { resolveStablePaneOwner } from '../pane/stable-owner'
|
||||
import { ptySizes } from '../delivery/visibility-state'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
@@ -19,6 +21,8 @@ import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-st
|
||||
|
||||
function toRuntimeSpawnReply(result: {
|
||||
id: string
|
||||
exitedBeforeAttach?: true
|
||||
reattachUnverifiable?: true
|
||||
incarnationId?: string
|
||||
wslDistro?: string | null
|
||||
stablePaneOwner?: { handle: string; tabId: string; leafId: string }
|
||||
@@ -26,6 +30,8 @@ function toRuntimeSpawnReply(result: {
|
||||
}) {
|
||||
return {
|
||||
id: result.id,
|
||||
...(result.exitedBeforeAttach ? { exitedBeforeAttach: true as const } : {}),
|
||||
...(result.reattachUnverifiable ? { reattachUnverifiable: true as const } : {}),
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}),
|
||||
...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}),
|
||||
@@ -71,6 +77,12 @@ export async function spawnPtyFromRuntimeController(
|
||||
args.connectionId
|
||||
)
|
||||
: null
|
||||
if (
|
||||
!existingOwner &&
|
||||
isStablePaneResumeBlocked(deps.store, paneKey, args.worktreeId, args.connectionId)
|
||||
) {
|
||||
return { id: args.sessionId ?? '', reattachUnverifiable: true as const }
|
||||
}
|
||||
if (ownerKey && !existingOwner && !paneSpawnReservationsByOwnerKey.has(ownerKey)) {
|
||||
ctx.paneSpawnReservationKey = ownerKey
|
||||
ctx.paneSpawnReservation = reservePaneSpawn(ownerKey)
|
||||
@@ -95,6 +107,18 @@ export async function spawnPtyFromRuntimeController(
|
||||
return toRuntimeSpawnReply(earlyReserved)
|
||||
}
|
||||
await executeRuntimePtySpawn(ctx)
|
||||
if (ctx.result.exitedBeforeAttach || ctx.result.reattachUnverifiable) {
|
||||
restoreProvisionalPtySize(ctx)
|
||||
deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId ?? ctx.result.id)
|
||||
ctx.pendingRegistrationPtyId = null
|
||||
return toRuntimeSpawnReply(
|
||||
resolvePaneSpawnReservation(
|
||||
ctx.paneSpawnReservationKey,
|
||||
ctx.paneSpawnReservation,
|
||||
ctx.result
|
||||
)
|
||||
)
|
||||
}
|
||||
return toRuntimeSpawnReply(await commitRuntimePtySpawn(ctx))
|
||||
} catch (err) {
|
||||
if (ctx.pendingRegistrationPtyId) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import {
|
||||
AgentSessionPreSpawnError,
|
||||
@@ -15,6 +16,7 @@ export async function acquireOwner(
|
||||
input: AttachFlowInput,
|
||||
record: AgentSessionRecord
|
||||
): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> {
|
||||
const { store, rewind, now } = input
|
||||
const fence = record.lease.runtimeFence
|
||||
const spawnToken = record.lease.reservedSpawnToken
|
||||
if (!spawnToken) {
|
||||
@@ -36,6 +38,7 @@ export async function acquireOwner(
|
||||
}
|
||||
const acquired = await input.adapter.acquire({
|
||||
identity: journalIdentityFor(record, input.params),
|
||||
...claudeRewindAcquisitionProofs({ store, record, rewind, now }),
|
||||
fence,
|
||||
// Retries must recover the original reservation, not mint a second child.
|
||||
spawnToken,
|
||||
|
||||
@@ -46,6 +46,20 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
|
||||
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
|
||||
this.owner(input.sessionId).dispatch(input)
|
||||
|
||||
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = (sessionId) =>
|
||||
this.owners.get(sessionId)?.rewindSupport?.(sessionId) ?? {
|
||||
supported: false,
|
||||
reason: 'unsupported'
|
||||
}
|
||||
|
||||
rewind: NonNullable<StructuredAgentSessionAdapter['rewind']> = (input) =>
|
||||
this.owner(input.sessionId).rewind?.(input) ??
|
||||
Promise.resolve({ ok: false, reason: 'unsupported' })
|
||||
|
||||
recoverRewind: NonNullable<StructuredAgentSessionAdapter['recoverRewind']> = (input) =>
|
||||
this.owner(input.sessionId).recoverRewind?.(input) ??
|
||||
Promise.resolve({ ok: false, reason: 'unsupported' })
|
||||
|
||||
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) => {
|
||||
const compact = this.owner(input.sessionId).compact
|
||||
if (!compact) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
AgentSessionRewindReason,
|
||||
AgentSessionRewindSupport
|
||||
} from '../../../shared/agent-session-rewind'
|
||||
// What the wire needs from a provider adapter.
|
||||
//
|
||||
// Phase 2 implements this over the Codex app-server and the Claude Agent SDK;
|
||||
@@ -8,6 +12,7 @@
|
||||
|
||||
import type {
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalItemBody,
|
||||
AgentJournalMessageItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
@@ -34,6 +39,12 @@ export class AgentSessionAcquisitionRefusal extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentSessionRewindRefusal extends AgentSessionAcquisitionRefusal {
|
||||
constructor(readonly rewindReason: AgentSessionRewindReason) {
|
||||
super(`agent_session_rewind:${rewindReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's own root process was observed to exit, but its descendant tree
|
||||
* could not be verified. The lease keys on the root's pid and start time, so its
|
||||
@@ -97,6 +108,14 @@ export type StructuredAgentSessionLifecycleEvent = {
|
||||
|
||||
export type StructuredAgentSessionAcquireInput = {
|
||||
identity: AgentSessionJournalIdentity
|
||||
rewind?: {
|
||||
targetUuid: string
|
||||
previousLeafUuid: string
|
||||
dropsTurn?: string
|
||||
onProved?: (leafUuid: string) => Promise<void>
|
||||
}
|
||||
/** Recovery restores an unproved rewind's original cursor with ordinary branch proof. */
|
||||
rewindRecovery?: { leafUuid: string; onProved: () => Promise<void> }
|
||||
fence: number
|
||||
spawnToken: string
|
||||
options?: Readonly<Record<string, string>>
|
||||
@@ -131,6 +150,27 @@ export type StructuredAgentSessionAdapter = {
|
||||
body: AgentJournalMessageItem
|
||||
fence: number
|
||||
}): Promise<AgentSessionDispatchOutcome>
|
||||
rewindSupport?(sessionId: string): AgentSessionRewindSupport
|
||||
recoverRewind?(input: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
beforeTurnId: string
|
||||
}): Promise<
|
||||
| { ok: true; items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] }
|
||||
| { ok: false; reason: AgentSessionRewindReason }
|
||||
>
|
||||
rewind?(input: {
|
||||
sessionId: string
|
||||
fence: number
|
||||
beforeTurnId: string
|
||||
onPrepared?: (
|
||||
items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[]
|
||||
) => Promise<void>
|
||||
onReverted?: () => Promise<void>
|
||||
}): Promise<
|
||||
| { ok: true; items?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] }
|
||||
| { ok: false; reason: AgentSessionRewindReason }
|
||||
>
|
||||
compact?(input: {
|
||||
turnId: string
|
||||
sessionId: string
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AttachFlowInput } from './structured-agent-session-attach-flow'
|
||||
import {
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionAcquisitionRootExitObservedError,
|
||||
rethrowAfterAgentSessionAcquisitionCleanup
|
||||
} from './structured-agent-session-adapter'
|
||||
|
||||
export async function settlePostAcquisitionAttachFailure(
|
||||
input: AttachFlowInput,
|
||||
record: AgentSessionRecord,
|
||||
cause: unknown
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown = cause
|
||||
let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven'
|
||||
try {
|
||||
await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause)
|
||||
} catch (error) {
|
||||
cleanupError = error
|
||||
exitProof =
|
||||
error instanceof AgentSessionAcquisitionExitUnprovenError
|
||||
? 'unproven'
|
||||
: error instanceof AgentSessionAcquisitionRootExitObservedError
|
||||
? 'root-exit-observed'
|
||||
: 'exit-proven'
|
||||
}
|
||||
// A failed close must not prevent durable failure settlement.
|
||||
await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined)
|
||||
try {
|
||||
await input.store.settleFailedPostAcquisitionAttachment({
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
spawnToken: record.lease.reservedSpawnToken ?? '',
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.params.envelope.clientOperationId,
|
||||
outcome: {
|
||||
status: 'failed',
|
||||
code: 'agent_session_operation_invalid',
|
||||
message: cause instanceof Error ? cause.message : String(cause)
|
||||
},
|
||||
exitProof,
|
||||
now: input.now()
|
||||
})
|
||||
} catch (settlementError) {
|
||||
throw new AggregateError(
|
||||
[cleanupError, settlementError],
|
||||
'agent session post-acquisition attachment failure settlement failed'
|
||||
)
|
||||
}
|
||||
throw cleanupError
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
// The attach transition end to end: reserve the lease, make the reservation
|
||||
// real, open the journal.
|
||||
//
|
||||
// Split out of the host so the sequence reads in one place. The host still owns
|
||||
// the decisions that must not be client-supplied — the spawn token, the claim
|
||||
// key, the owner probe — and passes them in.
|
||||
import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure'
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
import {
|
||||
AgentSessionRewindRefusal,
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionAcquisitionRootExitObservedError,
|
||||
AgentSessionAcquisitionRefusal,
|
||||
isAgentSessionPreSpawnError,
|
||||
type StructuredAgentSessionAcquireInput,
|
||||
type StructuredAgentSessionAdapter
|
||||
} from './structured-agent-session-adapter'
|
||||
// The host supplies owner authority; this flow reserves, proves, and publishes the session.
|
||||
|
||||
import type {
|
||||
AgentSessionAttachResult,
|
||||
@@ -21,15 +27,7 @@ import {
|
||||
type AttachedJournal
|
||||
} from './structured-agent-session-attach'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support'
|
||||
import {
|
||||
AgentSessionAcquisitionExitUnprovenError,
|
||||
AgentSessionAcquisitionRootExitObservedError,
|
||||
AgentSessionAcquisitionRefusal,
|
||||
isAgentSessionPreSpawnError,
|
||||
rethrowAfterAgentSessionAcquisitionCleanup
|
||||
} from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
|
||||
import { readAgentSessionHydrationPage } from './agent-session-history-page'
|
||||
@@ -40,6 +38,7 @@ import {
|
||||
} from './structured-agent-session-adopted-import'
|
||||
|
||||
export type AttachFlowInput = {
|
||||
rewind?: StructuredAgentSessionAcquireInput['rewind']
|
||||
store: AgentSessionRecordStore
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
journalRoot: string
|
||||
@@ -47,22 +46,18 @@ export type AttachFlowInput = {
|
||||
callerKey: string
|
||||
params: AgentSessionAttachParams
|
||||
now: () => number
|
||||
/** Registers the opened journal and fans out to subscribers before the caller
|
||||
* sees the result, so no client can send against a session the host has not
|
||||
* finished publishing. */
|
||||
/** Publishes the journal before clients can send against the new owner. */
|
||||
onAttached: (
|
||||
attached: AttachedJournal,
|
||||
acquisitionGeneration: string | null
|
||||
) => Promise<void> | void
|
||||
/** Handed to the adapter so it can journal what the provider streams. The
|
||||
* host owns it and binds it to the journal inside `onAttached`. */
|
||||
/** Host-owned provider sink, bound to the journal inside `onAttached`. */
|
||||
eventSink?: StructuredAgentSessionEventSink
|
||||
/** Stops acquisition-window events targeting the superseded journal. */
|
||||
onAcquiring?: () => Promise<void> | void
|
||||
/** Settles writes already captured by the superseded journal before opening another. */
|
||||
beforeJournalOpen?: () => Promise<void> | void
|
||||
/** Removes any partial host publication after journal attachment fails, and
|
||||
* closes the journal handle of the map entry it drops. Awaited: see eviction. */
|
||||
/** Closes and removes partial publication after journal attachment fails. */
|
||||
onAttachFailed?: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -148,8 +143,7 @@ export async function performAttach(
|
||||
} catch (error) {
|
||||
const spawnToken = reservedRecord?.lease.reservedSpawnToken
|
||||
if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) {
|
||||
// A pre-spawn failure is its own processless proof; the settlement records the
|
||||
// evidence and the failed operation in one durable transaction.
|
||||
// Settle processless proof and failed operation atomically.
|
||||
const exitProof = isAgentSessionPreSpawnError(error)
|
||||
? 'processless'
|
||||
: error instanceof AgentSessionAcquisitionExitUnprovenError
|
||||
@@ -193,6 +187,9 @@ export async function performAttach(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (error instanceof AgentSessionRewindRefusal) {
|
||||
return rewindRefusal(error.rewindReason)
|
||||
}
|
||||
if (error instanceof AgentSessionAcquisitionRefusal) {
|
||||
return { ok: false, refusal: { code: error.code, message: error.message } }
|
||||
}
|
||||
@@ -268,48 +265,3 @@ async function settleUnsupportedReservation(
|
||||
throw new AggregateError([error], 'agent session unsupported reservation settlement failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function settlePostAcquisitionAttachFailure(
|
||||
input: AttachFlowInput,
|
||||
record: AgentSessionRecord,
|
||||
cause: unknown
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown = cause
|
||||
let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven'
|
||||
try {
|
||||
await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause)
|
||||
} catch (error) {
|
||||
cleanupError = error
|
||||
exitProof =
|
||||
error instanceof AgentSessionAcquisitionExitUnprovenError
|
||||
? 'unproven'
|
||||
: error instanceof AgentSessionAcquisitionRootExitObservedError
|
||||
? 'root-exit-observed'
|
||||
: 'exit-proven'
|
||||
}
|
||||
// Why: the close is awaited so the map entry is gone only once its handle is
|
||||
// released, but a failed close must not also cost the store settlement below.
|
||||
await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined)
|
||||
try {
|
||||
await input.store.settleFailedPostAcquisitionAttachment({
|
||||
sessionId: record.sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
spawnToken: record.lease.reservedSpawnToken ?? '',
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.params.envelope.clientOperationId,
|
||||
outcome: {
|
||||
status: 'failed',
|
||||
code: 'agent_session_operation_invalid',
|
||||
message: cause instanceof Error ? cause.message : String(cause)
|
||||
},
|
||||
exitProof,
|
||||
now: input.now()
|
||||
})
|
||||
} catch (settlementError) {
|
||||
throw new AggregateError(
|
||||
[cleanupError, settlementError],
|
||||
'agent session post-acquisition attachment failure settlement failed'
|
||||
)
|
||||
}
|
||||
throw cleanupError
|
||||
}
|
||||
|
||||
+15
-1
@@ -1,3 +1,5 @@
|
||||
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
|
||||
import { recoverStructuredRewind } from './structured-rewind-recovery'
|
||||
import { recoverInterruptedCompaction } from './structured-compaction-recovery'
|
||||
// The host's attach, lifted out of the host class.
|
||||
//
|
||||
@@ -29,7 +31,8 @@ export function attachStructuredAgentSession(
|
||||
context: StructuredAgentSessionAttachContext,
|
||||
callerKey: string,
|
||||
params: AgentSessionAttachParams,
|
||||
admitRecoveryTicket?: () => boolean
|
||||
admitRecoveryTicket?: () => boolean,
|
||||
rewind?: StructuredAgentSessionAcquireInput['rewind']
|
||||
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
|
||||
const sessionId = params.envelope.sessionId
|
||||
const attaching = context.serialize(sessionId, async () => {
|
||||
@@ -61,6 +64,7 @@ export function attachStructuredAgentSession(
|
||||
}
|
||||
const eventSink = context.runtimeState.eventSinkFor(sessionId)
|
||||
const attached = await performAttach({
|
||||
rewind,
|
||||
store: context.deps.store,
|
||||
adapter: context.deps.adapter,
|
||||
journalRoot: context.deps.journalRoot,
|
||||
@@ -124,6 +128,16 @@ export function attachStructuredAgentSession(
|
||||
hasProviderChild: true,
|
||||
acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null
|
||||
})
|
||||
if (!rewind) {
|
||||
await recoverStructuredRewind(
|
||||
context.deps.store,
|
||||
sessionId,
|
||||
attached.journal,
|
||||
fence,
|
||||
context.deps.adapter,
|
||||
context.now
|
||||
)
|
||||
}
|
||||
await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence)
|
||||
if (attached.recovery) {
|
||||
context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
// Everything a client can ask an ALREADY-ATTACHED session to do: send a turn, cancel one, answer a
|
||||
// prompt, change an option, read the options back.
|
||||
//
|
||||
@@ -75,6 +76,10 @@ export function sendStructuredAgentSessionTurn(
|
||||
return mutate(context, caller, params.envelope, {
|
||||
...plan,
|
||||
run: (ctx) => {
|
||||
const rewind = context.deps.store.getRecord(ctx.sessionId)?.rewind
|
||||
if (rewind?.phase === 'prepared' || rewind?.phase === 'provider-succeeded') {
|
||||
return Promise.resolve(rewindRefusal('outcome-unknown'))
|
||||
}
|
||||
const command = context.deps.store.getRecord(ctx.sessionId)?.conversationCommand
|
||||
if (
|
||||
command &&
|
||||
@@ -153,6 +158,14 @@ export function readStructuredAgentSessionOptions(
|
||||
const options = await context.deps.adapter.readOptions({ sessionId, fence: session.fence })
|
||||
return {
|
||||
...options,
|
||||
rewind:
|
||||
context.deps.store.getRecord(sessionId)?.rewind?.phase === 'prepared' ||
|
||||
context.deps.store.getRecord(sessionId)?.rewind?.phase === 'provider-succeeded'
|
||||
? { supported: false, reason: 'outcome-unknown' }
|
||||
: (context.deps.adapter.rewindSupport?.(sessionId) ?? {
|
||||
supported: false,
|
||||
reason: 'unsupported'
|
||||
}),
|
||||
conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear']
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind'
|
||||
import { rewindStructuredAgentSession } from './structured-agent-session-rewind'
|
||||
import { StructuredConversationCommandController } from './structured-conversation-command-controller'
|
||||
// Structured agent-session host: where the lease, journal, and provider adapter meet.
|
||||
// Mutations share one durable admission path and serialize per session.
|
||||
@@ -211,13 +213,11 @@ export class StructuredAgentSessionHost {
|
||||
|
||||
listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions)
|
||||
|
||||
getPersistedVisibleSessionTabIndex(): { present: boolean; sessionIds: string[] } {
|
||||
return this.deps.store.getVisibleSessionTabIndex()
|
||||
}
|
||||
getPersistedVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } =>
|
||||
this.deps.store.getVisibleSessionTabIndex()
|
||||
|
||||
setSessionTabVisibility(sessionId: string, visible: boolean): Promise<void> {
|
||||
return this.deps.store.setSessionTabVisibility(sessionId, visible)
|
||||
}
|
||||
setSessionTabVisibility = (sessionId: string, visible: boolean): Promise<void> =>
|
||||
this.deps.store.setSessionTabVisibility(sessionId, visible)
|
||||
|
||||
reconcileRestartLeases = async (): Promise<void> => {
|
||||
const refusal = await this.reconcileLeases('startup')
|
||||
@@ -233,8 +233,7 @@ export class StructuredAgentSessionHost {
|
||||
revealSession = (sessionId: string): Promise<StructuredAgentSessionReveal> =>
|
||||
this.restore.revealSession(sessionId)
|
||||
|
||||
private serialize = <T>(sessionId: string, task: () => Promise<T>): Promise<T> =>
|
||||
this.tasks.serialize(sessionId, task)
|
||||
private serialize = this.tasks.serialize.bind(this.tasks)
|
||||
|
||||
private restoreRenewedHandoff(sessionId: string): Promise<void> {
|
||||
return this.serialize(sessionId, async () => {
|
||||
@@ -307,6 +306,9 @@ export class StructuredAgentSessionHost {
|
||||
readOptions = (sessionId: string): Promise<SessionWire.AgentSessionOptionsResult> =>
|
||||
readStructuredAgentSessionOptions(this.mutationContext(), sessionId)
|
||||
|
||||
rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) =>
|
||||
rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params)
|
||||
|
||||
conversationCommand = (...args: Parameters<StructuredConversationCommandController['run']>) =>
|
||||
this.conversationCommands.run(...args)
|
||||
conversationReplacements = () => this.conversationCommands.replacements()
|
||||
|
||||
+5
-1
@@ -26,7 +26,11 @@ export async function runSettledAgentSessionMutation<TValue>(input: {
|
||||
status: 'succeeded',
|
||||
sessionId: input.envelope.sessionId
|
||||
})
|
||||
: { status: 'failed', code: outcome.refusal.code }
|
||||
: {
|
||||
status: 'failed',
|
||||
code: outcome.refusal.code,
|
||||
...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {})
|
||||
}
|
||||
)
|
||||
return outcome
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger'
|
||||
import {
|
||||
AGENT_SESSION_WIRE_REFUSAL_CODES,
|
||||
@@ -19,6 +20,9 @@ export function resolveAgentSessionReplayOutcome<TValue>(input: {
|
||||
}): AgentSessionReplayOutcomeDecision<TValue> {
|
||||
const { operationId, outcome } = input
|
||||
if (outcome.status === 'failed') {
|
||||
if (outcome.rewindReason) {
|
||||
return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal }
|
||||
}
|
||||
const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code)
|
||||
? (outcome.code as AgentSessionWireRefusalCode)
|
||||
: 'agent_session_operation_invalid'
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
agentJournalSubmissionKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { AgentSessionRewindRefusal } from './structured-agent-session-adapter'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import type {
|
||||
StructuredAgentSessionAdapter,
|
||||
StructuredAgentSessionAcquireInput,
|
||||
AgentSessionDispatchOutcome
|
||||
} from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import {
|
||||
HOST_TEST_NOW,
|
||||
HOST_TEST_SESSION,
|
||||
HOST_TEST_THREAD,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage,
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
|
||||
const caller = { callerKey: 'desktop' }
|
||||
let directory: string
|
||||
let store: AgentSessionRecordStore
|
||||
let host: StructuredAgentSessionHost
|
||||
let sink: StructuredAgentSessionEventSink
|
||||
let adapter: StructuredAgentSessionAdapter
|
||||
let acquires: StructuredAgentSessionAcquireInput[]
|
||||
const rewind = vi.fn<NonNullable<StructuredAgentSessionAdapter['rewind']>>()
|
||||
const recoverRewind = vi.fn<NonNullable<StructuredAgentSessionAdapter['recoverRewind']>>()
|
||||
let failClaude = false
|
||||
|
||||
beforeEach(async () => {
|
||||
resetHostTestOperationIds()
|
||||
rewind.mockReset().mockResolvedValue({ ok: true })
|
||||
recoverRewind.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
items: [
|
||||
{
|
||||
identity: { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'kept', ordinal: 0 },
|
||||
body: hostTestMessage('verified history')
|
||||
}
|
||||
]
|
||||
})
|
||||
failClaude = false
|
||||
acquires = []
|
||||
directory = await mkdtemp(join(tmpdir(), 'orca-rewind-'))
|
||||
store = await AgentSessionRecordStore.open({
|
||||
directory: join(directory, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
adapter = {
|
||||
supportsCreate: (_location, agent) => agent === 'codex' || agent === 'claude',
|
||||
supportsLocation: () => true,
|
||||
acquire: async (input) => {
|
||||
acquires.push(input)
|
||||
if (input.rewind && failClaude) {
|
||||
throw new AgentSessionRewindRefusal('provider-refused')
|
||||
}
|
||||
if (input.rewind) {
|
||||
await input.rewind.onProved?.(input.rewind.targetUuid)
|
||||
}
|
||||
await input.rewindRecovery?.onProved()
|
||||
sink = input.events!
|
||||
const handle = input.identity.providerHandle
|
||||
return {
|
||||
process: {
|
||||
hostId: 'local',
|
||||
pid: 4000 + acquires.length,
|
||||
processStartTimeMs: HOST_TEST_NOW,
|
||||
spawnToken: input.spawnToken
|
||||
},
|
||||
acquisitionGeneration: `generation-${acquires.length}`,
|
||||
link: {
|
||||
linkId: `link-${acquires.length}`,
|
||||
mintedAtFence: input.fence,
|
||||
observedAt: HOST_TEST_NOW,
|
||||
origin: acquires.length === 1 ? 'created' : 'resumed',
|
||||
handle:
|
||||
handle.kind === 'claude'
|
||||
? {
|
||||
provider: 'claude',
|
||||
sessionId: handle.sessionId,
|
||||
leafUuid: input.rewind?.targetUuid ?? 'tip'
|
||||
}
|
||||
: { provider: 'codex', threadId: HOST_TEST_THREAD }
|
||||
}
|
||||
}
|
||||
},
|
||||
dispatch: vi.fn(async (): Promise<AgentSessionDispatchOutcome> => ({
|
||||
state: 'unknown',
|
||||
reason: 'test'
|
||||
})),
|
||||
cancelTurn: async () => ({ cancelled: false }),
|
||||
answerPrompt: async () => {},
|
||||
setOption: async () => {},
|
||||
rewindSupport: () => ({ supported: true }),
|
||||
rewind,
|
||||
recoverRewind,
|
||||
releaseAcquisition: async () => true,
|
||||
closeSession: async () => true
|
||||
}
|
||||
host = new StructuredAgentSessionHost({
|
||||
store,
|
||||
adapter,
|
||||
journalRoot: directory,
|
||||
claimKeyId: 'key',
|
||||
now: () => HOST_TEST_NOW,
|
||||
probeOwner: async () => ({ outcome: 'exit-observed' })
|
||||
})
|
||||
})
|
||||
afterEach(async () => {
|
||||
await host.flushAllStreamedEvents()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function seed(provider: 'codex' | 'claude' = 'codex', acceptedSubmissions = false) {
|
||||
const params =
|
||||
provider === 'codex'
|
||||
? hostTestAttachParams(null)
|
||||
: hostTestAttachParams(null, {
|
||||
provider,
|
||||
agent: provider,
|
||||
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/claude' },
|
||||
providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'tip' }
|
||||
})
|
||||
expect(await host.attach(caller, params)).toMatchObject({ ok: true })
|
||||
const keys = ['kept', 'drop', 'tip'].map((uuid) =>
|
||||
provider === 'codex'
|
||||
? { provider, threadId: HOST_TEST_THREAD, turnId: uuid, ordinal: 0 }
|
||||
: { provider, sessionId: 'claude-session', uuid }
|
||||
)
|
||||
let selectedItemId = agentJournalItemKey(keys[1]!)
|
||||
for (const [i, identity] of keys.entries()) {
|
||||
const body = {
|
||||
...hostTestMessage(String(i)),
|
||||
role: i === 2 ? ('assistant' as const) : ('user' as const)
|
||||
}
|
||||
if (acceptedSubmissions && i !== 2) {
|
||||
const clientOperationId = hostTestOperationId()
|
||||
vi.mocked(adapter.dispatch).mockResolvedValueOnce({
|
||||
state: 'accepted',
|
||||
providerIdentity: identity
|
||||
})
|
||||
expect(
|
||||
await host.send(caller, {
|
||||
body,
|
||||
envelope: {
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
clientOperationId,
|
||||
expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
fields: { body }
|
||||
})
|
||||
}
|
||||
})
|
||||
).toMatchObject({ ok: true })
|
||||
if (i === 1) {
|
||||
selectedItemId = agentJournalSubmissionKey(clientOperationId)
|
||||
}
|
||||
} else {
|
||||
sink.appendItem(identity, body)
|
||||
}
|
||||
}
|
||||
await host.flushStreamedEvents(HOST_TEST_SESSION)
|
||||
return selectedItemId
|
||||
}
|
||||
function params(
|
||||
itemId: string,
|
||||
expectedEpoch = host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch
|
||||
) {
|
||||
return {
|
||||
itemId,
|
||||
expectedEpoch,
|
||||
envelope: {
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
clientOperationId: hostTestOperationId(),
|
||||
expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.rewind',
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
fields: { itemId, expectedEpoch }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('host rewind', () => {
|
||||
it.each(['codex', 'claude'] as const)(
|
||||
'resolves accepted %s user submissions to provider targets',
|
||||
async (provider) => {
|
||||
const target = await seed(provider, true)
|
||||
expect(target.startsWith('orca:')).toBe(true)
|
||||
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
|
||||
if (provider === 'codex') {
|
||||
expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' }))
|
||||
} else {
|
||||
expect(acquires[1]?.rewind).toMatchObject({ targetUuid: 'kept', dropsTurn: 'drop' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('retains the preceding accepted Claude prompt when rewinding its assistant response', async () => {
|
||||
await seed('claude', true)
|
||||
const target = agentJournalItemKey({
|
||||
provider: 'claude',
|
||||
sessionId: 'claude-session',
|
||||
uuid: 'tip'
|
||||
})
|
||||
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
|
||||
expect(acquires[1]?.rewind).toMatchObject({ targetUuid: 'drop' })
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
|
||||
})
|
||||
it('finishes a durable provider success on reattach without repeating the provider mutation', async () => {
|
||||
const target = await seed()
|
||||
const request = params(target)
|
||||
const replace = vi
|
||||
.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
|
||||
.mockRejectedValueOnce(new Error('disk failed'))
|
||||
await expect(host.rewind(caller, request)).rejects.toThrow('disk failed')
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded')
|
||||
replace.mockRestore()
|
||||
const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
|
||||
expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true })
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
|
||||
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries complete hydration after native acknowledgement without committing partial history', async () => {
|
||||
const target = await seed()
|
||||
const before = host.journalSnapshot(HOST_TEST_SESSION)
|
||||
rewind.mockImplementation(async (input) => {
|
||||
await input.onReverted?.()
|
||||
throw new Error('history unavailable')
|
||||
})
|
||||
await expect(host.rewind(caller, params(target))).rejects.toThrow('history unavailable')
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({
|
||||
phase: 'prepared',
|
||||
providerApplied: true
|
||||
})
|
||||
recoverRewind.mockRejectedValueOnce(new Error('history still unavailable'))
|
||||
await expect(
|
||||
host.attach(
|
||||
caller,
|
||||
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
|
||||
)
|
||||
).rejects.toThrow('history still unavailable')
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared')
|
||||
expect(
|
||||
await host.attach(
|
||||
caller,
|
||||
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
|
||||
)
|
||||
).toMatchObject({ ok: true })
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).items[0]?.body).toEqual(
|
||||
hostTestMessage('verified history')
|
||||
)
|
||||
expect(recoverRewind).toHaveBeenCalledTimes(2)
|
||||
expect(rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('fences stale owners and the second of two concurrent rewinds', async () => {
|
||||
const target = await seed()
|
||||
const stale = params(target)
|
||||
stale.envelope.expectedRuntimeFence++
|
||||
expect(await host.rewind(caller, stale)).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_checkpoint_stale' }
|
||||
})
|
||||
let finish!: () => void
|
||||
rewind.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => resolve({ ok: true })
|
||||
})
|
||||
)
|
||||
const first = host.rewind(caller, params(target))
|
||||
const second = host.rewind(caller, params(target))
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
finish()
|
||||
expect(await first).toMatchObject({ ok: true })
|
||||
expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } })
|
||||
expect(rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('replaces the epoch with the retained prefix and replays without another provider call', async () => {
|
||||
const target = await seed()
|
||||
const request = params(target)
|
||||
const result = await host.rewind(caller, request)
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch).not.toBe(request.expectedEpoch)
|
||||
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('reacquires Claude at the retained cursor with the same session and a new lease fence', async () => {
|
||||
const target = await seed('claude')
|
||||
const before = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
|
||||
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
|
||||
const emit = vi.fn()
|
||||
const unsubscribe = host.subscribe({ id: 'after-rewind', sessionId: HOST_TEST_SESSION, emit })
|
||||
emit.mockClear()
|
||||
sink.appendItem(
|
||||
{ provider: 'claude', sessionId: 'claude-session', uuid: 'next' },
|
||||
hostTestMessage('next')
|
||||
)
|
||||
sink.publish()
|
||||
await host.flushStreamedEvents(HOST_TEST_SESSION)
|
||||
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'batch' }))
|
||||
unsubscribe()
|
||||
expect(acquires[1]?.rewind).toMatchObject({
|
||||
targetUuid: 'kept',
|
||||
previousLeafUuid: 'tip',
|
||||
dropsTurn: 'drop'
|
||||
})
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence).toBeGreaterThan(before)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.lease.ownerProcess?.pid).toBe(4002)
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
|
||||
})
|
||||
it('recovers a Claude refusal with one plain resume and preserves the journal', async () => {
|
||||
const target = await seed('claude')
|
||||
failClaude = true
|
||||
const before = host.journalSnapshot(HOST_TEST_SESSION)
|
||||
expect(await host.rewind(caller, params(target))).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { rewindReason: 'provider-refused' }
|
||||
})
|
||||
expect(acquires).toHaveLength(3)
|
||||
expect(acquires[2]?.rewind).toBeUndefined()
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)!.lease.claimStatus).toBe('live')
|
||||
})
|
||||
it('refuses a rewind racing an active turn before provider execution', async () => {
|
||||
const target = await seed()
|
||||
sink.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'active' },
|
||||
{ kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } }
|
||||
)
|
||||
expect(await host.rewind(caller, params(target))).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { rewindReason: 'busy' }
|
||||
})
|
||||
expect(rewind).not.toHaveBeenCalled()
|
||||
})
|
||||
it('refuses stale epochs and targets from another provider', async () => {
|
||||
const target = await seed()
|
||||
expect(await host.rewind(caller, params(target, 'old-epoch'))).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { rewindReason: 'stale-epoch' }
|
||||
})
|
||||
expect(await host.rewind(caller, params('claude:foreign'))).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { rewindReason: 'invalid-target' }
|
||||
})
|
||||
expect(rewind).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind', async () => {
|
||||
const target = await seed()
|
||||
const request = params(target)
|
||||
const before = host.journalSnapshot(HOST_TEST_SESSION)
|
||||
rewind.mockRejectedValue(new Error('hydration failed'))
|
||||
await expect(host.rewind(caller, request)).rejects.toThrow('hydration failed')
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
|
||||
expect(await host.rewind(caller, request)).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { code: 'agent_session_operation_unknown' }
|
||||
})
|
||||
const body = hostTestMessage('new prompt')
|
||||
const envelope = {
|
||||
...params(target).envelope,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: HOST_TEST_SESSION,
|
||||
fields: { body }
|
||||
})
|
||||
}
|
||||
expect(await host.send(caller, { envelope, body })).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { rewindReason: 'outcome-unknown' }
|
||||
})
|
||||
expect(adapter.dispatch).not.toHaveBeenCalled()
|
||||
expect(
|
||||
await host.attach(
|
||||
caller,
|
||||
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
|
||||
)
|
||||
).toMatchObject({ ok: true })
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
|
||||
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
|
||||
expect(rewind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears an unapplied prepared rewind after observing the target still present', async () => {
|
||||
const target = await seed()
|
||||
const before = host.journalSnapshot(HOST_TEST_SESSION)
|
||||
rewind.mockRejectedValueOnce(new Error('read failed before revert'))
|
||||
await expect(host.rewind(caller, params(target))).rejects.toThrow('read failed')
|
||||
recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' })
|
||||
expect(
|
||||
await host.attach(
|
||||
caller,
|
||||
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
|
||||
)
|
||||
).toMatchObject({ ok: true })
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused')
|
||||
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('recovers against the complete provider preflight when the local journal omitted an older turn', async () => {
|
||||
const target = await seed()
|
||||
const items = ['older', 'kept'].map((turnId) => ({
|
||||
identity: { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId, ordinal: 0 },
|
||||
body: hostTestMessage(turnId)
|
||||
}))
|
||||
rewind.mockImplementationOnce(async (input) => {
|
||||
await input.onPrepared?.(items)
|
||||
await input.onReverted?.()
|
||||
throw new Error('lost after revert')
|
||||
})
|
||||
await expect(host.rewind(caller, params(target))).rejects.toThrow('lost after revert')
|
||||
recoverRewind.mockResolvedValueOnce({ ok: true, items })
|
||||
expect(
|
||||
await host.attach(
|
||||
caller,
|
||||
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
|
||||
)
|
||||
).toMatchObject({ ok: true })
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
|
||||
})
|
||||
|
||||
it.each(['turn', 'item'] as const)(
|
||||
'never commits a recovered prefix that omits an expected retained %s',
|
||||
async (missing) => {
|
||||
const target = await seed()
|
||||
const before = host.journalSnapshot(HOST_TEST_SESSION)
|
||||
const items = [0, 1].map((ordinal) => ({
|
||||
identity: {
|
||||
provider: 'codex' as const,
|
||||
threadId: HOST_TEST_THREAD,
|
||||
turnId: 'kept',
|
||||
ordinal
|
||||
},
|
||||
body: hostTestMessage(String(ordinal))
|
||||
}))
|
||||
rewind.mockImplementationOnce(async (input) => {
|
||||
await input.onPrepared?.(items)
|
||||
throw new Error('reply lost')
|
||||
})
|
||||
await expect(host.rewind(caller, params(target))).rejects.toThrow('reply lost')
|
||||
recoverRewind.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
items: missing === 'turn' ? [] : items.slice(0, 1)
|
||||
})
|
||||
const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
|
||||
await expect(
|
||||
host.attach(
|
||||
caller,
|
||||
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
|
||||
)
|
||||
).rejects.toThrow('proof-mismatch')
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
replace.mockRestore()
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.expectedEpoch).toBe(before.cursor.epoch)
|
||||
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared')
|
||||
}
|
||||
)
|
||||
|
||||
it('settles the existing epoch after a crash between journal commit and record completion', async () => {
|
||||
const target = await seed()
|
||||
const request = params(target)
|
||||
const transition = store.transitionHandoff.bind(store)
|
||||
const checkpoint = vi
|
||||
.spyOn(store, 'transitionHandoff')
|
||||
.mockImplementation((sessionId, update) =>
|
||||
transition(sessionId, (record) => {
|
||||
const next = update(record)
|
||||
if (next.rewind?.phase === 'completed') {
|
||||
throw new Error('completion write failed')
|
||||
}
|
||||
return next
|
||||
})
|
||||
)
|
||||
await expect(host.rewind(caller, request)).rejects.toThrow('completion write failed')
|
||||
const committed = host.journalSnapshot(HOST_TEST_SESSION)
|
||||
expect(committed.cursor.epoch).not.toBe(request.expectedEpoch)
|
||||
checkpoint.mockRestore()
|
||||
const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
|
||||
expect(
|
||||
await host.attach(
|
||||
caller,
|
||||
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
|
||||
)
|
||||
).toMatchObject({ ok: true })
|
||||
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed)
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
replace.mockRestore()
|
||||
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
agentJournalSubmissionKey,
|
||||
parseAgentJournalItemKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
|
||||
import type {
|
||||
AgentSessionRewindParams,
|
||||
AgentSessionRewindRecord,
|
||||
AgentSessionRewindResult
|
||||
} from '../../../shared/agent-session-rewind'
|
||||
import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire'
|
||||
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds'
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
|
||||
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
|
||||
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
|
||||
import { conversationCommandBlocked } from './structured-conversation-command-admission'
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery'
|
||||
import { replaceClaudeRewindOwner } from './structured-rewind-claude-owner'
|
||||
|
||||
export async function rewindStructuredAgentSession(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
attachContext: StructuredAgentSessionAttachContext,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: AgentSessionRewindParams
|
||||
): Promise<AgentSessionMutationResult<AgentSessionRewindResult>> {
|
||||
const { sessionId, clientOperationId } = params.envelope
|
||||
const store = context.deps.store
|
||||
return context.serialize(sessionId, async () => {
|
||||
const result = await admitAndRunAgentSessionMutation<AgentSessionRewindResult>({
|
||||
store,
|
||||
adapter: context.deps.adapter,
|
||||
callerKey: caller.callerKey,
|
||||
envelope: params.envelope,
|
||||
journal: context.sessions.get(sessionId)?.journal,
|
||||
publish: (journal) => context.publish(sessionId, journal),
|
||||
now: context.now,
|
||||
plan: {
|
||||
method: 'agentSession.rewind',
|
||||
fields: { itemId: params.itemId, expectedEpoch: params.expectedEpoch },
|
||||
recoverUnknownFromDurableState: true,
|
||||
settledOutcome: (rewind) => ({ status: 'succeeded', sessionId, rewind }),
|
||||
replay: (_ctx, outcome) => {
|
||||
if (outcome.status === 'succeeded' && outcome.rewind) {
|
||||
return outcome.rewind
|
||||
}
|
||||
const prior = store.getRecord(sessionId)?.rewind
|
||||
return prior?.operationId === clientOperationId &&
|
||||
prior.callerKey === caller.callerKey &&
|
||||
prior.phase === 'completed' &&
|
||||
prior.epoch
|
||||
? { itemId: prior.itemId, epoch: prior.epoch }
|
||||
: null
|
||||
},
|
||||
run: async (ctx) => {
|
||||
await attachContext.runtimeState.flushEventSink(sessionId)
|
||||
const record = store.getRecord(sessionId)!
|
||||
const support = ctx.adapter.rewindSupport?.(sessionId)
|
||||
if (!support?.supported) {
|
||||
return rewindRefusal(support?.reason ?? 'unsupported')
|
||||
}
|
||||
if (
|
||||
record.rewind?.phase === 'prepared' ||
|
||||
record.rewind?.phase === 'provider-succeeded'
|
||||
) {
|
||||
return rewindRefusal('outcome-unknown')
|
||||
}
|
||||
if (conversationCommandBlocked(ctx, record)) {
|
||||
return rewindRefusal('busy')
|
||||
}
|
||||
if (ctx.journal.isReadOnly) {
|
||||
return rewindRefusal('unsupported')
|
||||
}
|
||||
const snapshot = ctx.journal.snapshot()
|
||||
const providerKeys = new Map(
|
||||
snapshot.submissions.flatMap((submission) =>
|
||||
submission.dispatchState === 'accepted' && submission.providerItemId
|
||||
? [
|
||||
[
|
||||
agentJournalSubmissionKey(submission.clientMessageId),
|
||||
submission.providerItemId
|
||||
] as const
|
||||
]
|
||||
: []
|
||||
)
|
||||
)
|
||||
const providerKey = (itemId: string) => providerKeys.get(itemId) ?? itemId
|
||||
if (ctx.journal.cursor().epoch !== params.expectedEpoch) {
|
||||
return rewindRefusal('stale-epoch')
|
||||
}
|
||||
const selected = snapshot.items.findIndex((item) => item.itemId === params.itemId)
|
||||
const key = selected === -1 ? null : parseAgentJournalItemKey(providerKey(params.itemId))
|
||||
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle
|
||||
if (!key || !head || key.provider !== head.provider) {
|
||||
return rewindRefusal('invalid-target')
|
||||
}
|
||||
let boundary = selected
|
||||
let claude: Parameters<typeof replaceClaudeRewindOwner>[3] | undefined
|
||||
if (key.provider === 'codex' && head.provider === 'codex') {
|
||||
if (key.threadId !== head.threadId) {
|
||||
return rewindRefusal('invalid-target')
|
||||
}
|
||||
boundary = snapshot.items.findIndex((item) => {
|
||||
const identity = parseAgentJournalItemKey(providerKey(item.itemId))
|
||||
return (
|
||||
(identity?.provider === 'codex' &&
|
||||
identity.threadId === key.threadId &&
|
||||
identity.turnId === key.turnId) ||
|
||||
(item.body.kind === 'status' && item.body.turnLifecycle?.turnId === key.turnId)
|
||||
)
|
||||
})
|
||||
} else if (key.provider === 'claude' && head.provider === 'claude') {
|
||||
if (key.sessionId !== head.sessionId) {
|
||||
return rewindRefusal('invalid-target')
|
||||
}
|
||||
const previous = snapshot.items
|
||||
.slice(0, boundary)
|
||||
.map((item) => parseAgentJournalItemKey(providerKey(item.itemId)))
|
||||
.findLast(
|
||||
(identity) =>
|
||||
identity?.provider === 'claude' && identity.sessionId === key.sessionId
|
||||
)
|
||||
if (previous?.provider !== 'claude') {
|
||||
return rewindRefusal('invalid-target')
|
||||
}
|
||||
const prompts = snapshot.items
|
||||
.slice(boundary)
|
||||
.filter((item) => item.body.kind === 'message' && item.body.role === 'user')
|
||||
const prompt =
|
||||
prompts.length === 1
|
||||
? parseAgentJournalItemKey(providerKey(prompts[0]!.itemId))
|
||||
: null
|
||||
claude = {
|
||||
targetUuid: previous.uuid,
|
||||
previousLeafUuid: head.leafUuid ?? '',
|
||||
...(prompt?.provider === 'claude' ? { dropsTurn: prompt.uuid } : {})
|
||||
}
|
||||
} else {
|
||||
return rewindRefusal('invalid-target')
|
||||
}
|
||||
const retained = snapshot.items
|
||||
.slice(0, boundary)
|
||||
.map(({ itemId, body, observedAt }) => ({
|
||||
itemId: providerKey(itemId),
|
||||
body,
|
||||
observedAt
|
||||
}))
|
||||
if (
|
||||
retained.length > 10_000 ||
|
||||
Buffer.byteLength(JSON.stringify(retained), 'utf8') >
|
||||
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
|
||||
) {
|
||||
return rewindRefusal('history-limit')
|
||||
}
|
||||
let prepared: AgentSessionRewindRecord = {
|
||||
operationId: clientOperationId,
|
||||
callerKey: caller.callerKey,
|
||||
itemId: params.itemId,
|
||||
providerItemId: providerKey(params.itemId),
|
||||
expectedEpoch: params.expectedEpoch,
|
||||
phase: 'prepared',
|
||||
retained
|
||||
}
|
||||
await persistRewindRecord(store, sessionId, ctx.fence, prepared)
|
||||
ctx.publish()
|
||||
const provider = claude
|
||||
? await replaceClaudeRewindOwner(attachContext, caller.callerKey, params, claude)
|
||||
: await ctx.adapter.rewind!({
|
||||
sessionId,
|
||||
fence: ctx.fence,
|
||||
beforeTurnId: key.provider === 'codex' ? key.turnId : '',
|
||||
onPrepared: async (items) => {
|
||||
const retained = items.map(({ identity, body }) => ({
|
||||
itemId: agentJournalItemKey(identity),
|
||||
body,
|
||||
observedAt: ctx.now()
|
||||
}))
|
||||
if (
|
||||
retained.length > 10_000 ||
|
||||
Buffer.byteLength(JSON.stringify(retained), 'utf8') >
|
||||
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
|
||||
) {
|
||||
throw new Error('agent_session_rewind:history-limit')
|
||||
}
|
||||
prepared = { ...prepared, retained }
|
||||
await persistRewindRecord(store, sessionId, ctx.fence, prepared)
|
||||
},
|
||||
onReverted: async () => {
|
||||
await persistRewindRecord(store, sessionId, ctx.fence, {
|
||||
...prepared,
|
||||
providerApplied: true
|
||||
})
|
||||
}
|
||||
})
|
||||
const fence = store.getRecord(sessionId)!.lease.runtimeFence
|
||||
if (!provider.ok) {
|
||||
const reason =
|
||||
'reason' in provider
|
||||
? provider.reason
|
||||
: (provider.refusal.rewindReason ?? 'outcome-unknown')
|
||||
if (reason !== 'outcome-unknown') {
|
||||
await persistRewindRecord(store, sessionId, fence, {
|
||||
...prepared,
|
||||
phase: 'refused',
|
||||
reason,
|
||||
retained: []
|
||||
})
|
||||
const currentJournal = context.sessions.get(sessionId)?.journal
|
||||
if (currentJournal) {
|
||||
context.publish(sessionId, currentJournal)
|
||||
}
|
||||
}
|
||||
return rewindRefusal(reason)
|
||||
}
|
||||
const confirmed = provider.items
|
||||
? provider.items.map(({ identity, body }) => ({
|
||||
itemId: agentJournalItemKey(identity),
|
||||
body,
|
||||
observedAt: ctx.now()
|
||||
}))
|
||||
: prepared.retained
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(confirmed), 'utf8') >
|
||||
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
|
||||
) {
|
||||
throw new Error('agent_session_rewind:history-limit')
|
||||
}
|
||||
await persistRewindRecord(store, sessionId, fence, {
|
||||
...prepared,
|
||||
retained: confirmed,
|
||||
phase: 'provider-succeeded',
|
||||
hydrationVerified: true
|
||||
})
|
||||
const journal = context.sessions.get(sessionId)!.journal
|
||||
await attachContext.runtimeState.flushEventSink(sessionId)
|
||||
await recoverStructuredRewind(store, sessionId, journal, fence)
|
||||
context.publish(sessionId, journal)
|
||||
return { ok: true, value: { itemId: params.itemId, epoch: journal.cursor().epoch } }
|
||||
}
|
||||
}
|
||||
})
|
||||
return result.ok
|
||||
? {
|
||||
...result,
|
||||
fence: store.getRecord(sessionId)!.lease.runtimeFence,
|
||||
cursor: context.sessions.get(sessionId)!.journal.cursor()
|
||||
}
|
||||
: result
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma
|
||||
a.workspaceId === b.workspaceId &&
|
||||
a.agent === b.agent &&
|
||||
a.status === b.status &&
|
||||
a.rewindBlockedReason === b.rewindBlockedReason &&
|
||||
// Settled activity changes ranking; streaming active turns must stay quiet.
|
||||
(a.status !== 'idle' || a.updatedAt === b.updatedAt) &&
|
||||
a.latestPrompt === b.latestPrompt &&
|
||||
@@ -126,6 +127,9 @@ export class StructuredAgentSessionStatusFeed {
|
||||
workspaceId: session.params.location.workspaceId,
|
||||
agent: session.params.provider,
|
||||
...projectStructuredAgentSessionStatusSummary(items),
|
||||
...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded'
|
||||
? { rewindBlockedReason: 'outcome-unknown' as const }
|
||||
: {}),
|
||||
...(model ? { model } : {}),
|
||||
...(providerSession ? { providerSession } : {}),
|
||||
updatedAt: journal.lastActivityAt() || this.deps.now()
|
||||
|
||||
@@ -7,6 +7,9 @@ export function conversationCommandBlocked(
|
||||
record: AgentSessionRecord
|
||||
): string | null {
|
||||
const items = ctx.journal.snapshot().items
|
||||
if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') {
|
||||
return 'agent_session_rewind:outcome-unknown'
|
||||
}
|
||||
if (
|
||||
record.conversationCommand?.command === 'clear' &&
|
||||
record.conversationCommand.phase === 'committed' &&
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind'
|
||||
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
|
||||
import { attachFingerprintFields } from './structured-agent-session-attach'
|
||||
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
|
||||
import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration'
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
|
||||
/** Runs within the rewind's session queue; acquisition still uses the normal reservation CAS. */
|
||||
export async function replaceClaudeRewindOwner(
|
||||
context: StructuredAgentSessionAttachContext,
|
||||
callerKey: string,
|
||||
params: AgentSessionRewindParams,
|
||||
rewind: NonNullable<StructuredAgentSessionAcquireInput['rewind']>
|
||||
): Promise<{ ok: true; items?: never } | ReturnType<typeof rewindRefusal>> {
|
||||
const sessionId = params.envelope.sessionId
|
||||
const session = context.sessions.get(sessionId)!
|
||||
if (!(await context.deps.adapter.closeSession?.(sessionId))) {
|
||||
return rewindRefusal('outcome-unknown')
|
||||
}
|
||||
session.hasProviderChild = false
|
||||
const head = agentSessionProviderHandleChainHead(
|
||||
context.deps.store.getRecord(sessionId)!.providerHandleChain
|
||||
)?.handle
|
||||
if (head?.provider !== 'claude' || !head.leafUuid) {
|
||||
return rewindRefusal('invalid-target')
|
||||
}
|
||||
rewind = { ...rewind, previousLeafUuid: head.leafUuid }
|
||||
const attach = async (intent: typeof rewind | undefined, stage: string) => {
|
||||
const current = context.deps.store.getRecord(sessionId)!
|
||||
const operationId = `${params.envelope.clientOperationId.split('-')[0]}-${createHash('sha256')
|
||||
.update(JSON.stringify([callerKey, params.envelope.clientOperationId, stage]))
|
||||
.digest('hex')
|
||||
.slice(0, 32)}`
|
||||
const attachParams = {
|
||||
...session.params,
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: operationId,
|
||||
expectedRuntimeFence: current.lease.runtimeFence,
|
||||
payloadFingerprint: ''
|
||||
}
|
||||
}
|
||||
attachParams.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.attach',
|
||||
sessionId,
|
||||
fields: attachFingerprintFields(attachParams)
|
||||
})
|
||||
return attachStructuredAgentSession(
|
||||
{
|
||||
...context,
|
||||
serialize: (_id, run) => run()
|
||||
},
|
||||
callerKey,
|
||||
attachParams,
|
||||
undefined,
|
||||
intent
|
||||
)
|
||||
}
|
||||
const result = await attach(rewind, 'rewind')
|
||||
if (result.ok) {
|
||||
return { ok: true } as const
|
||||
}
|
||||
if (
|
||||
result.refusal.rewindReason === 'provider-refused' ||
|
||||
result.refusal.rewindReason === 'proof-mismatch'
|
||||
) {
|
||||
const recovered = await attach(undefined, 'resume')
|
||||
if (!recovered.ok) {
|
||||
return rewindRefusal('outcome-unknown')
|
||||
}
|
||||
return rewindRefusal(result.refusal.rewindReason)
|
||||
}
|
||||
return rewindRefusal(result.refusal.rewindReason ?? 'outcome-unknown')
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof'
|
||||
|
||||
function setup() {
|
||||
let current = agentSessionRecordFixture()
|
||||
current.providerHandleChain = current.providerHandleChain.map((link) => ({
|
||||
...link,
|
||||
handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'tip' }
|
||||
}))
|
||||
current.rewind = {
|
||||
operationId: 'rewind-operation',
|
||||
callerKey: 'desktop',
|
||||
itemId: 'selected',
|
||||
expectedEpoch: 'old-epoch',
|
||||
phase: 'prepared',
|
||||
retained: []
|
||||
}
|
||||
const store: Pick<AgentSessionRecordStore, 'transitionHandoff'> = {
|
||||
transitionHandoff: async (_sessionId, transition) => {
|
||||
current = transition(current)
|
||||
return current
|
||||
}
|
||||
}
|
||||
return {
|
||||
store,
|
||||
record: () => current,
|
||||
setFence: () => {
|
||||
current = { ...current, lease: { ...current.lease, runtimeFence: 8 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude rewind durable proof checkpoints', () => {
|
||||
it('atomically checkpoints the exact target and resumable head before owner publication', async () => {
|
||||
const state = setup()
|
||||
const proofs = claudeRewindAcquisitionProofs({
|
||||
store: state.store,
|
||||
record: state.record(),
|
||||
now: () => 3_000,
|
||||
rewind: { previousLeafUuid: 'tip', targetUuid: 'kept' }
|
||||
})
|
||||
await expect(proofs.rewind!.onProved!('wrong')).rejects.toThrow('proof-mismatch')
|
||||
expect(state.record().rewind?.phase).toBe('prepared')
|
||||
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
|
||||
await proofs.rewind!.onProved!('kept')
|
||||
expect(state.record().rewind).toMatchObject({
|
||||
phase: 'provider-succeeded',
|
||||
hydrationVerified: true
|
||||
})
|
||||
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'kept' })
|
||||
expect(
|
||||
claudeRewindAcquisitionProofs({
|
||||
store: state.store,
|
||||
record: state.record(),
|
||||
now: () => 3_001,
|
||||
rewind: undefined
|
||||
})
|
||||
).toEqual({})
|
||||
})
|
||||
it('restores prepared recovery through ordinary proof without carrying rewind authorization', async () => {
|
||||
const state = setup()
|
||||
const proofs = claudeRewindAcquisitionProofs({
|
||||
store: state.store,
|
||||
record: state.record(),
|
||||
now: () => 3_000,
|
||||
rewind: undefined
|
||||
})
|
||||
expect(proofs.rewind).toBeUndefined()
|
||||
expect(proofs.rewindRecovery?.leafUuid).toBe('tip')
|
||||
expect(state.record().rewind?.phase).toBe('prepared')
|
||||
await proofs.rewindRecovery!.onProved()
|
||||
expect(state.record().rewind).toMatchObject({ phase: 'refused', retained: [] })
|
||||
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
|
||||
})
|
||||
it('refuses a proof checkpoint from a superseded acquisition', async () => {
|
||||
const state = setup()
|
||||
const proofs = claudeRewindAcquisitionProofs({
|
||||
store: state.store,
|
||||
record: state.record(),
|
||||
now: () => 3_000,
|
||||
rewind: { previousLeafUuid: 'tip', targetUuid: 'kept' }
|
||||
})
|
||||
state.setFence()
|
||||
await expect(proofs.rewind!.onProved!('kept')).rejects.toThrow('checkpoint_stale')
|
||||
expect(state.record().rewind?.phase).toBe('prepared')
|
||||
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity'
|
||||
import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
|
||||
|
||||
/** Proof checkpoints survive failures later in acquisition, before an owner can be published. */
|
||||
export function claudeRewindAcquisitionProofs(input: {
|
||||
store: Pick<AgentSessionRecordStore, 'transitionHandoff'>
|
||||
record: AgentSessionRecord
|
||||
rewind: StructuredAgentSessionAcquireInput['rewind']
|
||||
now: () => number
|
||||
}): Pick<StructuredAgentSessionAcquireInput, 'rewind' | 'rewindRecovery'> {
|
||||
const { record, store } = input
|
||||
const pending = record.rewind
|
||||
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle
|
||||
if (
|
||||
record.provider !== 'claude' ||
|
||||
pending?.phase !== 'prepared' ||
|
||||
head?.provider !== 'claude'
|
||||
) {
|
||||
return input.rewind ? { rewind: input.rewind } : {}
|
||||
}
|
||||
const checkpoint = async (leafUuid?: string): Promise<void> => {
|
||||
await store.transitionHandoff(record.sessionId, (current) => {
|
||||
if (
|
||||
current.lease.runtimeFence !== record.lease.runtimeFence ||
|
||||
current.rewind?.operationId !== pending.operationId ||
|
||||
current.rewind.callerKey !== pending.callerKey ||
|
||||
current.rewind.phase !== 'prepared'
|
||||
) {
|
||||
throw new Error('agent_session_checkpoint_stale')
|
||||
}
|
||||
if (leafUuid === undefined) {
|
||||
return {
|
||||
...current,
|
||||
rewind: { ...pending, phase: 'refused', reason: 'outcome-unknown', retained: [] }
|
||||
}
|
||||
}
|
||||
if (leafUuid !== input.rewind?.targetUuid) {
|
||||
throw new Error('agent_session_rewind:proof-mismatch')
|
||||
}
|
||||
const observedAt = input.now()
|
||||
return {
|
||||
...recordAgentSessionProviderHandle({
|
||||
record: current,
|
||||
fence: record.lease.runtimeFence,
|
||||
link: claudeProviderHandleLink({
|
||||
sessionId: head.sessionId,
|
||||
leafUuid,
|
||||
resumed: true,
|
||||
fence: record.lease.runtimeFence,
|
||||
observedAt
|
||||
}),
|
||||
now: observedAt
|
||||
}),
|
||||
rewind: { ...pending, phase: 'provider-succeeded', hydrationVerified: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
if (input.rewind) {
|
||||
return { rewind: { ...input.rewind, onProved: checkpoint } }
|
||||
}
|
||||
if (!head.leafUuid) {
|
||||
throw new Error('agent_session_rewind:invalid-target')
|
||||
}
|
||||
return { rewindRecovery: { leafUuid: head.leafUuid, onProved: () => checkpoint() } }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind'
|
||||
import { restoreRewindJournalBody } from './structured-rewind-journal-body'
|
||||
|
||||
describe('rewind recovery of newer durable records', () => {
|
||||
it('keeps an unknown message role and block readable without discarding the row', () => {
|
||||
expect(
|
||||
restoreRewindJournalBody({
|
||||
kind: 'message',
|
||||
role: 'future-role',
|
||||
blocks: [{ type: 'future-block' }]
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'message',
|
||||
role: 'system',
|
||||
blocks: [{ type: 'text', text: '{"type":"future-block"}' }]
|
||||
})
|
||||
})
|
||||
it('preserves unknown state as evidence rather than inventing success or pending work', () => {
|
||||
const body = {
|
||||
kind: 'tool-call' as const,
|
||||
name: 'future-tool',
|
||||
input: { path: 'file' },
|
||||
state: 'paused-by-provider'
|
||||
}
|
||||
expect(restoreRewindJournalBody(body)).toEqual({ kind: 'status', text: JSON.stringify(body) })
|
||||
const status = {
|
||||
kind: 'status' as const,
|
||||
text: 'state',
|
||||
turnLifecycle: { turnId: 'turn', state: 'future-state' }
|
||||
}
|
||||
expect(restoreRewindJournalBody(status)).toEqual({
|
||||
kind: 'status',
|
||||
text: JSON.stringify(status)
|
||||
})
|
||||
})
|
||||
it('does not reject a saved recovery prefix over a newer refusal reason', () => {
|
||||
expect(
|
||||
AgentSessionRewindRecordSchema.safeParse({
|
||||
operationId: 'operation',
|
||||
callerKey: 'caller',
|
||||
itemId: 'selected',
|
||||
expectedEpoch: 'old',
|
||||
phase: 'provider-succeeded',
|
||||
reason: 'future-reason',
|
||||
retained: []
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas'
|
||||
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind'
|
||||
import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types'
|
||||
|
||||
type StoredBody = AgentSessionRewindRecord['retained'][number]['body']
|
||||
|
||||
/** Unknown future values remain visible evidence, never invented turn or prompt state. */
|
||||
export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody {
|
||||
let normalized: unknown = body
|
||||
const fallback = () => ({ kind: 'status', text: JSON.stringify(body) })
|
||||
if (body.kind === 'message') {
|
||||
normalized = {
|
||||
...body,
|
||||
role: NATIVE_CHAT_ROLES.find((role) => role === body.role) ?? 'system',
|
||||
blocks: body.blocks.map((block) => {
|
||||
if (
|
||||
(block.type === 'text' && 'text' in block) ||
|
||||
(block.type === 'tool-call' && 'name' in block && !('state' in block)) ||
|
||||
(block.type === 'tool-result' && 'output' in block) ||
|
||||
block.type === 'image-ref'
|
||||
) {
|
||||
return block
|
||||
}
|
||||
if (
|
||||
block.type === 'tool-call' &&
|
||||
'state' in block &&
|
||||
(block.state === 'running' || block.state === 'completed' || block.state === 'failed')
|
||||
) {
|
||||
return block
|
||||
}
|
||||
return { type: 'text', text: JSON.stringify(block) }
|
||||
})
|
||||
}
|
||||
} else if (
|
||||
body.kind === 'tool-call' &&
|
||||
body.state !== 'running' &&
|
||||
body.state !== 'completed' &&
|
||||
body.state !== 'failed'
|
||||
) {
|
||||
normalized = fallback()
|
||||
} else if (
|
||||
(body.kind === 'approval' || body.kind === 'question') &&
|
||||
body.resolution.state !== 'pending' &&
|
||||
body.resolution.state !== 'resolved' &&
|
||||
body.resolution.state !== 'cancelled'
|
||||
) {
|
||||
normalized = fallback()
|
||||
} else if (
|
||||
body.kind === 'status' &&
|
||||
body.turnLifecycle &&
|
||||
body.turnLifecycle.state !== 'running' &&
|
||||
body.turnLifecycle.state !== 'completed'
|
||||
) {
|
||||
normalized = fallback()
|
||||
}
|
||||
if (!isAdmissibleAgentJournalItemBody(normalized)) {
|
||||
throw new Error('agent_session_rewind:invalid-retained-body')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { restoreRewindJournalBody } from './structured-rewind-journal-body'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
parseAgentJournalItemKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds'
|
||||
|
||||
export function persistRewindRecord(
|
||||
store: AgentSessionRecordStore,
|
||||
sessionId: string,
|
||||
fence: number,
|
||||
rewind: AgentSessionRewindRecord
|
||||
): Promise<unknown> {
|
||||
return store.transitionHandoff(sessionId, (record) => {
|
||||
if (record.lease.runtimeFence !== fence) {
|
||||
throw new Error('agent_session_checkpoint_stale')
|
||||
}
|
||||
return { ...record, rewind }
|
||||
})
|
||||
}
|
||||
|
||||
/** Recovery observes provider state; it never repeats an ambiguous native mutation. */
|
||||
export async function recoverStructuredRewind(
|
||||
store: AgentSessionRecordStore,
|
||||
sessionId: string,
|
||||
journal: AgentSessionJournal,
|
||||
fence: number,
|
||||
adapter?: StructuredAgentSessionAdapter,
|
||||
now: () => number = Date.now
|
||||
): Promise<void> {
|
||||
let rewind = store.getRecord(sessionId)?.rewind
|
||||
if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') {
|
||||
return
|
||||
}
|
||||
const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId)
|
||||
if (target?.provider === 'codex' && !rewind.hydrationVerified) {
|
||||
const recovered = await adapter?.recoverRewind?.({
|
||||
sessionId,
|
||||
fence,
|
||||
beforeTurnId: target.turnId
|
||||
})
|
||||
if (!recovered?.ok) {
|
||||
if (
|
||||
recovered?.reason === 'provider-refused' &&
|
||||
rewind.phase === 'prepared' &&
|
||||
!rewind.providerApplied
|
||||
) {
|
||||
await persistRewindRecord(store, sessionId, fence, {
|
||||
...rewind,
|
||||
phase: 'refused',
|
||||
reason: recovered.reason,
|
||||
retained: []
|
||||
})
|
||||
return
|
||||
}
|
||||
throw new Error(`agent_session_rewind:${recovered?.reason ?? 'outcome-unknown'}`)
|
||||
}
|
||||
const expectedItems = new Set(rewind.retained.map((item) => item.itemId))
|
||||
const observedItems = new Set<string>()
|
||||
for (const { identity } of recovered.items) {
|
||||
const itemId = agentJournalItemKey(identity)
|
||||
if (
|
||||
identity.provider !== 'codex' ||
|
||||
identity.threadId !== target.threadId ||
|
||||
!expectedItems.has(itemId)
|
||||
) {
|
||||
throw new Error('agent_session_rewind:proof-mismatch')
|
||||
}
|
||||
observedItems.add(itemId)
|
||||
}
|
||||
if (observedItems.size !== expectedItems.size) {
|
||||
throw new Error('agent_session_rewind:proof-mismatch')
|
||||
}
|
||||
const retained = recovered.items.map(({ identity, body }) => ({
|
||||
itemId: agentJournalItemKey(identity),
|
||||
body,
|
||||
observedAt: now()
|
||||
}))
|
||||
if (
|
||||
retained.length > 10_000 ||
|
||||
Buffer.byteLength(JSON.stringify(retained), 'utf8') > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
|
||||
) {
|
||||
throw new Error('agent_session_rewind:history-limit')
|
||||
}
|
||||
rewind = { ...rewind, retained, phase: 'provider-succeeded', hydrationVerified: true }
|
||||
await persistRewindRecord(store, sessionId, fence, rewind)
|
||||
}
|
||||
if (rewind.phase !== 'provider-succeeded') {
|
||||
return
|
||||
}
|
||||
const replacement = rewind.retained.map((item) => {
|
||||
const identity = parseAgentJournalItemKey(item.itemId)
|
||||
if (!identity) {
|
||||
throw new Error('agent_session_rewind:invalid-retained-identity')
|
||||
}
|
||||
return { identity, body: restoreRewindJournalBody(item.body), observedAt: item.observedAt }
|
||||
})
|
||||
// A crash after the journal transaction must settle its existing epoch, not replace it twice.
|
||||
const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch
|
||||
if (
|
||||
alreadyReplaced &&
|
||||
!isDeepStrictEqual(
|
||||
journal.snapshot().items.map(({ itemId, body }) => ({ itemId, body })),
|
||||
replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body }))
|
||||
)
|
||||
) {
|
||||
throw new Error('agent_session_rewind:stale-epoch')
|
||||
}
|
||||
const cursor = alreadyReplaced
|
||||
? journal.cursor()
|
||||
: await journal.replaceEpochItems('handle_forked', fence, replacement)
|
||||
await persistRewindRecord(store, sessionId, fence, {
|
||||
...rewind,
|
||||
phase: 'completed',
|
||||
epoch: cursor.epoch,
|
||||
retained: []
|
||||
})
|
||||
await store.recordOperationOutcome({
|
||||
callerKey: rewind.callerKey,
|
||||
operationId: rewind.operationId,
|
||||
outcome: {
|
||||
status: 'succeeded',
|
||||
sessionId,
|
||||
rewind: { itemId: rewind.itemId, epoch: cursor.epoch }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
AGENT_SESSION_REWIND_REASONS,
|
||||
type AgentSessionRewindReason
|
||||
} from '../../../shared/agent-session-rewind'
|
||||
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
|
||||
|
||||
export function rewindRefusal(reason: AgentSessionRewindReason): {
|
||||
ok: false
|
||||
refusal: AgentSessionWireRefusal
|
||||
} {
|
||||
const knownReason =
|
||||
AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) ?? 'outcome-unknown'
|
||||
return {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code:
|
||||
knownReason === 'outcome-unknown'
|
||||
? 'agent_session_operation_unknown'
|
||||
: 'agent_session_operation_invalid',
|
||||
message: `agent_session_rewind:${knownReason}`,
|
||||
rewindReason: knownReason
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import type { PtySourceReceivingActivation } from '../../shared/pty-source-recei
|
||||
import type { TerminalOwner } from '../../shared/terminal-owner'
|
||||
|
||||
export type PtySpawnResult = {
|
||||
exitedBeforeAttach?: true
|
||||
reattachUnverifiable?: true
|
||||
agentSessionEnsure?: AgentSessionClaimedSpawnResult
|
||||
/** App-facing PTY id. Remote providers must return globally routable ids,
|
||||
* not relay-local handles, because renderer/runtime IPC routes by this key. */
|
||||
|
||||
@@ -4,6 +4,9 @@ import * as dependencies from './orca-runtime-create-terminal-dependencies'
|
||||
import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop'
|
||||
import { buildRuntimeAgentTeamsLaunchPlan } from './orca-runtime-agent-teams-launch-plan'
|
||||
import { createPtySpawnCommitReporter } from './orca-runtime-report-pty-spawn-commit'
|
||||
import { terminalAttachRefusal } from './terminal-attach-refusal'
|
||||
import { revealCreatedTerminal } from './terminal-create-reveal'
|
||||
import { resolveTerminalCreatePaneIdentity } from './terminal-create-pane-identity'
|
||||
|
||||
export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication {
|
||||
async createTerminal(
|
||||
@@ -33,15 +36,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path
|
||||
let preAllocatedHandle =
|
||||
launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle()
|
||||
const hintedTabId = launchOpts.tabId?.trim()
|
||||
const canAdoptPaneIdentity =
|
||||
hintedTabId !== undefined &&
|
||||
dependencies.isValidHostTerminalTabId(hintedTabId) &&
|
||||
launchOpts.leafId !== undefined &&
|
||||
dependencies.isTerminalLeafId(launchOpts.leafId)
|
||||
let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : dependencies.randomUUID()
|
||||
let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : dependencies.randomUUID()
|
||||
let paneKey = dependencies.makePaneKey(tabId, leafId)
|
||||
let { tabId, leafId, paneKey } = resolveTerminalCreatePaneIdentity(launchOpts)
|
||||
const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({
|
||||
worktreeId: workspace.id,
|
||||
connectionId: workspace.connectionId,
|
||||
@@ -70,6 +65,16 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
const refusalPane = { tabId, paneKey, worktreeId: workspace.id }
|
||||
const refusedAdoption =
|
||||
adoptedBeforeLaunch &&
|
||||
terminalAttachRefusal(adoptedBeforeLaunch.result, {
|
||||
handle: adoptedBeforeLaunch.owner.handle ?? preAllocatedHandle,
|
||||
...refusalPane
|
||||
})
|
||||
if (refusedAdoption) {
|
||||
return refusedAdoption
|
||||
}
|
||||
const launchToken = launchOpts.launchConfig
|
||||
? (launchOpts.launchToken ?? dependencies.randomUUID())
|
||||
: undefined
|
||||
@@ -175,6 +180,13 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
} finally {
|
||||
releaseStablePaneCreate?.()
|
||||
}
|
||||
const refusedSpawn = terminalAttachRefusal(result, {
|
||||
handle: result.stablePaneOwner?.handle ?? preAllocatedHandle,
|
||||
...refusalPane
|
||||
})
|
||||
if (refusedSpawn) {
|
||||
return refusedSpawn
|
||||
}
|
||||
if (!result.stablePaneOwner) {
|
||||
reportPtySpawnCommitted()
|
||||
}
|
||||
@@ -250,32 +262,21 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
|
||||
...(cwd !== workspace.path ? { startupCwd: cwd } : {})
|
||||
})
|
||||
}
|
||||
let surface: dependencies.RuntimeTerminalCreate['surface'] = 'background'
|
||||
let warning: string | undefined
|
||||
if (presentation !== 'background' && this.notifier?.revealTerminalSession) {
|
||||
try {
|
||||
await this.notifier.revealTerminalSession(workspace.id, {
|
||||
ptyId: result.id,
|
||||
title: launchOpts.title ?? null,
|
||||
...(cwd !== workspace.path ? { cwd } : {}),
|
||||
...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}),
|
||||
...(launchToken ? { launchToken } : {}),
|
||||
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
|
||||
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
|
||||
activate: presentation === 'focused',
|
||||
...(presentation ? { presentation } : {}),
|
||||
...dependencies.ownerSurfacing(opts.surfaceOwner !== false),
|
||||
tabId,
|
||||
leafId
|
||||
})
|
||||
surface = 'visible'
|
||||
} catch (err) {
|
||||
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
|
||||
warning = dependencies.createTerminalRevealWarning(handle, err)
|
||||
}
|
||||
} else if (presentation !== 'background') {
|
||||
warning = dependencies.createTerminalRevealWarning(handle)
|
||||
}
|
||||
const { surface, warning } = await revealCreatedTerminal({
|
||||
notifier: this.notifier,
|
||||
worktreeId: workspace.id,
|
||||
workspacePath: workspace.path,
|
||||
handle,
|
||||
ptyId: result.id,
|
||||
tabId,
|
||||
leafId,
|
||||
cwd,
|
||||
presentation,
|
||||
launchOpts,
|
||||
launchConfig: effectiveLaunchConfig,
|
||||
launchToken,
|
||||
surfaceOwner: opts.surfaceOwner
|
||||
})
|
||||
return {
|
||||
handle,
|
||||
tabId,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
webContents: { fromId: vi.fn(() => null) },
|
||||
ipcMain: {
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
},
|
||||
app: { getPath: vi.fn(() => '/tmp') }
|
||||
}))
|
||||
|
||||
function stubLaunchScope(runtime: OrcaRuntimeService, path = '/repo/app'): void {
|
||||
const internals = runtime as unknown as {
|
||||
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{
|
||||
id: string
|
||||
path: string
|
||||
connectionId: string | null
|
||||
repo: null
|
||||
folderWorkspace: null
|
||||
}>
|
||||
}
|
||||
vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
|
||||
id: 'wt-1',
|
||||
path,
|
||||
connectionId: null,
|
||||
repo: null,
|
||||
folderWorkspace: null
|
||||
})
|
||||
}
|
||||
|
||||
describe('paired terminal create attach refusal', () => {
|
||||
it.each(['exitedBeforeAttach', 'reattachUnverifiable'] as const)(
|
||||
'returns %s before registering a terminal',
|
||||
async (outcome) => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
stubLaunchScope(runtime)
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'retained-pty', [outcome]: true })
|
||||
const registerPty = vi.spyOn(runtime, 'registerPty')
|
||||
const committed = vi.fn()
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const result = await runtime.createTerminal('id:wt-1', {
|
||||
onPtySpawnCommitted: committed
|
||||
})
|
||||
expect(result).toMatchObject({ ptyId: 'retained-pty', [outcome]: true })
|
||||
expect(registerPty).not.toHaveBeenCalled()
|
||||
expect(committed).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['exitedBeforeAttach', 'reattachUnverifiable'] as const)(
|
||||
'returns adopted %s before requesting a spawn',
|
||||
async (outcome) => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
stubLaunchScope(runtime)
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'wrong-pty' })
|
||||
const registerPty = vi.spyOn(runtime, 'registerPty')
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
adoptStablePane: vi.fn().mockResolvedValue({
|
||||
result: { id: 'retained-pty', [outcome]: true },
|
||||
owner: {
|
||||
handle: 'retained-handle',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
ptyId: 'retained-pty'
|
||||
}
|
||||
}),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const result = await runtime.createTerminal('id:wt-1')
|
||||
expect(result).toMatchObject({
|
||||
handle: 'retained-handle',
|
||||
ptyId: 'retained-pty',
|
||||
[outcome]: true
|
||||
})
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(registerPty).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { negotiateTerminalCreateRefusal } from '../terminal-create-refusal-negotiation'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
getAgentResumeArgv,
|
||||
@@ -237,20 +238,32 @@ export const AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'terminal.ensureAgentSession',
|
||||
params: EnsureAgentSessionParams,
|
||||
handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) =>
|
||||
(runtime as AgentSessionRuntime).ensureAgentSession(
|
||||
withExecutionHostAgentPresentation(params, clientKind),
|
||||
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
|
||||
handler: async (
|
||||
params,
|
||||
{ runtime, pairedDeviceId, clientId, clientKind, signal, clientCapabilities }
|
||||
) =>
|
||||
negotiateTerminalCreateRefusal(
|
||||
await (runtime as AgentSessionRuntime).ensureAgentSession(
|
||||
withExecutionHostAgentPresentation(params, clientKind),
|
||||
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
|
||||
),
|
||||
clientCapabilities
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.createAgentSession',
|
||||
params: CreateAgentSessionParams,
|
||||
handler: (params, { runtime, pairedDeviceId, clientId, clientKind, signal }) => {
|
||||
handler: async (
|
||||
params,
|
||||
{ runtime, pairedDeviceId, clientId, clientKind, signal, clientCapabilities }
|
||||
) => {
|
||||
assertOperationTimestampWithinFutureSkew(params.clientOperationId)
|
||||
return (runtime as AgentSessionRuntime).createAgentSession(
|
||||
withExecutionHostAgentPresentation(params, clientKind),
|
||||
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
|
||||
return negotiateTerminalCreateRefusal(
|
||||
await (runtime as AgentSessionRuntime).createAgentSession(
|
||||
withExecutionHostAgentPresentation(params, clientKind),
|
||||
callerContext(pairedDeviceId ?? clientId, clientKind, signal)
|
||||
),
|
||||
clientCapabilities
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
+4
@@ -53,6 +53,10 @@ export const ADMISSION_METHODS = [
|
||||
},
|
||||
{ method: 'agentSession.ensure', params: attachParams() },
|
||||
{ method: 'agentSession.send', params: sendParams() },
|
||||
{
|
||||
method: 'agentSession.rewind',
|
||||
params: { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'epoch' }
|
||||
},
|
||||
{
|
||||
method: 'agentSession.respondToApproval',
|
||||
params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'allow' }
|
||||
|
||||
@@ -139,6 +139,7 @@ export function hostStub(): StructuredAgentSessionHost {
|
||||
unconfirmedClientMessageIds: []
|
||||
}
|
||||
})),
|
||||
rewind: vi.fn(async () => ({ ok: true, value: { itemId: 'chosen', epoch: 'next' } })),
|
||||
send: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
close: vi.fn(async () => undefined),
|
||||
|
||||
@@ -237,3 +237,11 @@ export const UnsubscribeParams = z
|
||||
|
||||
/** Read-only owner classification retained for restart safety; mutation handoff is separate. */
|
||||
export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict()
|
||||
|
||||
export const RewindParams = z
|
||||
.object({
|
||||
envelope: MutationEnvelope,
|
||||
itemId: Identifier('Invalid item id', 4096),
|
||||
expectedEpoch: Identifier('Invalid journal epoch')
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('capability gating', () => {
|
||||
}
|
||||
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
|
||||
// so an additive method is invisible to old clients and needs no protocol bump.
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(21)
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(22)
|
||||
})
|
||||
|
||||
it('hides the surface from a declared client that did not advertise it', async () => {
|
||||
@@ -668,3 +668,21 @@ describe('agentSession.subscribeStatus', () => {
|
||||
expect(hostCalls.subscribeStatus).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewind wire boundary', () => {
|
||||
it('routes the exact item and epoch through the structured capability gate', async () => {
|
||||
const params = { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'current' }
|
||||
const result = await call('agentSession.rewind', params, STRUCTURED_CLIENT)
|
||||
expect(result).toMatchObject({ result: { ok: true } })
|
||||
expect(hostCalls.rewind).toHaveBeenCalledWith(expect.anything(), params)
|
||||
})
|
||||
it('rejects absent epoch and caller-supplied provider keys', async () => {
|
||||
for (const params of [
|
||||
{ envelope: envelope(), itemId: 'chosen' },
|
||||
{ envelope: envelope(), itemId: 'chosen', expectedEpoch: 'current', beforeTurnId: 'forged' }
|
||||
]) {
|
||||
expect(await call('agentSession.rewind', params, STRUCTURED_CLIENT)).toHaveProperty('error')
|
||||
}
|
||||
expect(hostCalls.rewind).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
HandoffStatusParams,
|
||||
OptionsParams,
|
||||
RespondParams,
|
||||
RewindParams,
|
||||
SendParams,
|
||||
SetOptionParams,
|
||||
SubscribeParams,
|
||||
@@ -82,6 +83,15 @@ async function attachClientSuppliedLocation(
|
||||
}
|
||||
|
||||
export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'agentSession.rewind',
|
||||
params: RewindParams,
|
||||
handler: async (params, ctx) => {
|
||||
requireStructuredCapability(ctx)
|
||||
await ensureHostInstalled(ctx)
|
||||
return requireHost(ctx).rewind(callerFor(ctx), params)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.conversationCommand',
|
||||
params: ConversationCommandParams,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { negotiateTerminalCreateRefusal } from '../../terminal-create-refusal-negotiation'
|
||||
import { defineMethod, type RpcAnyMethod } from '../../core'
|
||||
import {
|
||||
navigationTargetsHost,
|
||||
@@ -34,7 +35,10 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'terminal.create',
|
||||
params: TerminalCreateParams,
|
||||
handler: async (params, { runtime, pairedDeviceId, clientId, clientKind }) => {
|
||||
handler: async (
|
||||
params,
|
||||
{ runtime, pairedDeviceId, clientId, clientKind, clientCapabilities }
|
||||
) => {
|
||||
// A focused terminal create predates paired-client navigation. Keep the
|
||||
// authority boundary here so a remote caller cannot activate the host
|
||||
// renderer. This legacy RPC remains a background create for paired viewers;
|
||||
@@ -44,38 +48,41 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [
|
||||
const activate = pairedViewer ? false : params.activate === true
|
||||
const presentation =
|
||||
pairedViewer && params.presentation === 'focused' ? 'background' : params.presentation
|
||||
return {
|
||||
terminal: await runtime.dedupeTerminalCreate(
|
||||
pairedDeviceId ?? clientId ?? 'local',
|
||||
params.worktree,
|
||||
params.clientMutationId,
|
||||
params.reconcileExisting === true,
|
||||
(canonicalWorktreeSelector, preAllocatedHandle) =>
|
||||
runtime.createTerminal(canonicalWorktreeSelector, {
|
||||
command: params.command,
|
||||
startupCommandDelivery: params.startupCommandDelivery,
|
||||
env: params.env,
|
||||
envToDelete: params.envToDelete,
|
||||
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
|
||||
...(params.resumeProviderSession
|
||||
? { resumeProviderSession: params.resumeProviderSession }
|
||||
: {}),
|
||||
...(params.launchToken ? { launchToken: params.launchToken } : {}),
|
||||
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
|
||||
...(params.terminalColorQueryReplies
|
||||
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
|
||||
: {}),
|
||||
title: params.title,
|
||||
focus,
|
||||
rendererBacked: params.rendererBacked === true,
|
||||
activate,
|
||||
presentation,
|
||||
tabId: params.tabId,
|
||||
leafId: params.leafId,
|
||||
...(preAllocatedHandle ? { preAllocatedHandle } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
return negotiateTerminalCreateRefusal(
|
||||
{
|
||||
terminal: await runtime.dedupeTerminalCreate(
|
||||
pairedDeviceId ?? clientId ?? 'local',
|
||||
params.worktree,
|
||||
params.clientMutationId,
|
||||
params.reconcileExisting === true,
|
||||
(canonicalWorktreeSelector, preAllocatedHandle) =>
|
||||
runtime.createTerminal(canonicalWorktreeSelector, {
|
||||
command: params.command,
|
||||
startupCommandDelivery: params.startupCommandDelivery,
|
||||
env: params.env,
|
||||
envToDelete: params.envToDelete,
|
||||
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
|
||||
...(params.resumeProviderSession
|
||||
? { resumeProviderSession: params.resumeProviderSession }
|
||||
: {}),
|
||||
...(params.launchToken ? { launchToken: params.launchToken } : {}),
|
||||
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
|
||||
...(params.terminalColorQueryReplies
|
||||
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
|
||||
: {}),
|
||||
title: params.title,
|
||||
focus,
|
||||
rendererBacked: params.rendererBacked === true,
|
||||
activate,
|
||||
presentation,
|
||||
tabId: params.tabId,
|
||||
leafId: params.leafId,
|
||||
...(preAllocatedHandle ? { preAllocatedHandle } : {})
|
||||
})
|
||||
)
|
||||
},
|
||||
clientCapabilities
|
||||
)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RpcMethod } from './core'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import { terminalAttachRefusal } from '../terminal-attach-refusal'
|
||||
import { AGENT_SESSION_METHODS } from './methods/agent-session'
|
||||
import { TERMINAL_LIFECYCLE_METHODS } from './methods/terminal/terminal-lifecycle-methods'
|
||||
import { mapRuntimeError } from './errors'
|
||||
|
||||
it.each(['terminal.create', 'terminal.createAgentSession', 'terminal.ensureAgentSession'])(
|
||||
'%s negotiates both refusal outcomes without changing ordinary success',
|
||||
async (method) => {
|
||||
const definition = [...TERMINAL_LIFECYCLE_METHODS, ...AGENT_SESSION_METHODS].find(
|
||||
(entry) => entry.name === method
|
||||
)! as RpcMethod
|
||||
for (const outcome of ['exitedBeforeAttach', 'reattachUnverifiable', 'success'] as const) {
|
||||
const owner = {
|
||||
handle: 'retained',
|
||||
tabId: 'tab-1',
|
||||
paneKey: 'tab-1:pane',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
const terminal =
|
||||
outcome === 'success'
|
||||
? { ...owner, ptyId: 'worker', title: null }
|
||||
: terminalAttachRefusal({ id: 'worker', [outcome]: true }, owner)!
|
||||
const result = { terminal, disposition: 'created' }
|
||||
const runtime = {
|
||||
createTerminal: vi.fn(async () => terminal),
|
||||
dedupeTerminalCreate: vi.fn(async () => terminal),
|
||||
createAgentSession: vi.fn(async () => result),
|
||||
ensureAgentSession: vi.fn(async () => result)
|
||||
}
|
||||
for (const clientCapabilities of [
|
||||
undefined,
|
||||
[],
|
||||
[TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY]
|
||||
]) {
|
||||
const call = definition.handler(
|
||||
{
|
||||
worktree: 'wt-1',
|
||||
agent: 'codex',
|
||||
kind: 'explicit',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
clientOperationId: '1752883200000-0123456789abcdef0123456789abcdef'
|
||||
},
|
||||
{ runtime, clientCapabilities } as never
|
||||
)
|
||||
if (outcome === 'success' || clientCapabilities?.length) {
|
||||
await expect(call).resolves.toMatchObject({ terminal })
|
||||
} else {
|
||||
await expect(call).rejects.toMatchObject({ code: 'remote_runtime_unavailable' })
|
||||
try {
|
||||
await call
|
||||
} catch (error) {
|
||||
expect(mapRuntimeError('request', { runtimeId: 'host' }, error)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'remote_runtime_unavailable' }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import type { RuntimeTerminalCreate } from '../../../shared/runtime-terminal-contracts'
|
||||
import type { RpcContext } from './core'
|
||||
|
||||
export function negotiateTerminalCreateRefusal<T extends { terminal: RuntimeTerminalCreate }>(
|
||||
result: T,
|
||||
clientCapabilities: RpcContext['clientCapabilities']
|
||||
): T {
|
||||
if (
|
||||
(result.terminal.exitedBeforeAttach || result.terminal.reattachUnverifiable) &&
|
||||
!clientCapabilities?.includes(TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
// Older clients interpret every successful create as permission to publish a fresh binding.
|
||||
throw Object.assign(new Error('Remote terminal attachment is temporarily unavailable.'), {
|
||||
code: 'remote_runtime_unavailable'
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -82,6 +82,8 @@ export type RuntimePtyController = {
|
||||
}
|
||||
}): Promise<{
|
||||
id: string
|
||||
exitedBeforeAttach?: true
|
||||
reattachUnverifiable?: true
|
||||
pid?: number | null
|
||||
incarnationId?: PtyIncarnationId
|
||||
wslDistro?: string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch-proof'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
|
||||
import { join } from 'node:path'
|
||||
@@ -70,10 +71,25 @@ export function createStructuredClaudeRuntimeAdapter(
|
||||
})
|
||||
)
|
||||
},
|
||||
readTranscriptLeaf: async ({ providerSessionId, previousLeafUuid, claudeConfigDir }) => {
|
||||
readTranscriptLeaf: async ({
|
||||
providerSessionId,
|
||||
previousLeafUuid,
|
||||
intentionalRewindUuid,
|
||||
claudeConfigDir
|
||||
}) => {
|
||||
const transcriptPath = await resolveSessionFilePath('claude', providerSessionId, {
|
||||
claudeProjectsDir: join(claudeConfigDir, 'projects')
|
||||
})
|
||||
if (transcriptPath && intentionalRewindUuid !== undefined) {
|
||||
return (
|
||||
await proveClaudeTranscriptBranch({
|
||||
transcriptPath,
|
||||
providerSessionId,
|
||||
previousLeafUuid,
|
||||
intentionalRewindUuid
|
||||
})
|
||||
).leafUuid
|
||||
}
|
||||
return transcriptPath
|
||||
? await readClaudeTranscriptLeafUuid(transcriptPath, providerSessionId, previousLeafUuid)
|
||||
: null
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { RuntimeTerminalCreate } from '../../shared/runtime-terminal-contracts'
|
||||
|
||||
export function terminalAttachRefusal(
|
||||
result: {
|
||||
id: string
|
||||
exitedBeforeAttach?: true
|
||||
reattachUnverifiable?: true
|
||||
},
|
||||
owner: { handle: string; tabId: string; paneKey: string; worktreeId: string }
|
||||
): RuntimeTerminalCreate | null {
|
||||
if (!result.exitedBeforeAttach && !result.reattachUnverifiable) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...owner,
|
||||
ptyId: result.id,
|
||||
title: null,
|
||||
...(result.exitedBeforeAttach ? { exitedBeforeAttach: true } : { reattachUnverifiable: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id'
|
||||
|
||||
export function resolveTerminalCreatePaneIdentity(options: { tabId?: string; leafId?: string }): {
|
||||
tabId: string
|
||||
leafId: string
|
||||
paneKey: string
|
||||
} {
|
||||
const hintedTabId = options.tabId?.trim()
|
||||
const canAdopt =
|
||||
hintedTabId !== undefined &&
|
||||
isValidHostTerminalTabId(hintedTabId) &&
|
||||
options.leafId !== undefined &&
|
||||
isTerminalLeafId(options.leafId)
|
||||
const tabId = canAdopt ? hintedTabId! : randomUUID()
|
||||
const leafId = canAdopt ? options.leafId! : randomUUID()
|
||||
return { tabId, leafId, paneKey: makePaneKey(tabId, leafId) }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { RuntimeTerminalCreate, RuntimeTerminalPresentation } from '../../shared/runtime-types'
|
||||
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
|
||||
import type { RuntimeNotifier } from './runtime-notifier-contract'
|
||||
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
|
||||
import { createTerminalRevealWarning, ownerSurfacing } from './orca-runtime-core'
|
||||
|
||||
export async function revealCreatedTerminal(args: {
|
||||
notifier?: RuntimeNotifier | null
|
||||
worktreeId: string
|
||||
workspacePath: string
|
||||
handle: string
|
||||
ptyId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
cwd: string
|
||||
presentation: RuntimeTerminalPresentation | undefined
|
||||
launchOpts: TerminalCreateOptions
|
||||
launchConfig?: SleepingAgentLaunchConfig | null
|
||||
launchToken?: string
|
||||
surfaceOwner?: boolean
|
||||
}): Promise<Pick<RuntimeTerminalCreate, 'surface' | 'warning'>> {
|
||||
if (args.presentation === 'background') {
|
||||
return { surface: 'background' }
|
||||
}
|
||||
if (!args.notifier?.revealTerminalSession) {
|
||||
return { surface: 'background', warning: createTerminalRevealWarning(args.handle) }
|
||||
}
|
||||
try {
|
||||
await args.notifier.revealTerminalSession(args.worktreeId, {
|
||||
ptyId: args.ptyId,
|
||||
title: args.launchOpts.title ?? null,
|
||||
...(args.cwd !== args.workspacePath ? { cwd: args.cwd } : {}),
|
||||
...(args.launchConfig ? { launchConfig: args.launchConfig } : {}),
|
||||
...(args.launchToken ? { launchToken: args.launchToken } : {}),
|
||||
...(args.launchOpts.launchAgent ? { launchAgent: args.launchOpts.launchAgent } : {}),
|
||||
...(args.launchOpts.viewMode ? { viewMode: args.launchOpts.viewMode } : {}),
|
||||
activate: args.presentation === 'focused',
|
||||
...(args.presentation ? { presentation: args.presentation } : {}),
|
||||
...ownerSurfacing(args.surfaceOwner !== false),
|
||||
tabId: args.tabId,
|
||||
leafId: args.leafId
|
||||
})
|
||||
return { surface: 'visible' }
|
||||
} catch (error) {
|
||||
console.warn(`[terminal-create] failed to create inactive tab for ${args.ptyId}:`, error)
|
||||
return { surface: 'background', warning: createTerminalRevealWarning(args.handle, error) }
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,8 @@ export type PtyApi = {
|
||||
isReattach?: boolean
|
||||
isAlternateScreen?: boolean
|
||||
replay?: string
|
||||
exitedBeforeAttach?: boolean
|
||||
reattachUnverifiable?: boolean
|
||||
sessionExpired?: boolean
|
||||
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
|
||||
startupCwdFallback?: { kind: 'worktree'; cwd: string }
|
||||
|
||||
+79
-3
@@ -177,13 +177,13 @@ describe('BrowserPaneOverlayLayer', () => {
|
||||
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps inactive browser panes mounted for a visible worktree', () => {
|
||||
it('defers inactive browser panes while retaining their viewport slots', () => {
|
||||
const markup = renderOverlay({ isWorktreeActive: true })
|
||||
|
||||
expect(markup).toContain('data-browser-pane-id="browser-a"')
|
||||
expect(markup).toContain('data-browser-pane-active="true"')
|
||||
expect(markup).toContain('data-browser-pane-id="browser-b"')
|
||||
expect(markup).toContain('data-browser-pane-active="false"')
|
||||
expect(markup).not.toContain('data-browser-pane-id="browser-b"')
|
||||
expect(markup).toContain('data-browser-overlay-tab-id="browser-b"')
|
||||
})
|
||||
|
||||
it('marks the active browser pane focused when its own group holds focus', () => {
|
||||
@@ -195,6 +195,82 @@ describe('BrowserPaneOverlayLayer', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('restores 200 tabs on demand and preserves viewport roots across parking and selection', () => {
|
||||
const browsers = Array.from({ length: 200 }, (_, index) =>
|
||||
createBrowserTab(`browser-${index}`, [`page-${index}`])
|
||||
)
|
||||
const tabs = browsers.map((browser, index) =>
|
||||
createUnifiedBrowserTab(`tab-${index}`, browser.id, index)
|
||||
)
|
||||
mocks.state!.browserTabsByWorktree['wt-1'] = browsers
|
||||
mocks.state!.unifiedTabsByWorktree['wt-1'] = tabs
|
||||
const group = mocks.state!.groupsByWorktree['wt-1'][0]
|
||||
mocks.state!.groupsByWorktree['wt-1'] = [
|
||||
{ ...group, activeTabId: tabs[0].id, tabOrder: tabs.map((tab) => tab.id) }
|
||||
]
|
||||
const view = render(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive />)
|
||||
const slot = view.container.querySelector('[data-browser-overlay-tab-id="browser-0"]')!
|
||||
const viewport = slot.firstElementChild
|
||||
const pane = slot.querySelector('[data-browser-pane-id]')
|
||||
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
|
||||
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(200)
|
||||
|
||||
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive={false} />)
|
||||
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0)
|
||||
expect(pane!.isConnected).toBe(false)
|
||||
expect((slot as HTMLElement).style.display).toBe('none')
|
||||
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive />)
|
||||
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
|
||||
expect(slot.querySelector('[data-browser-pane-id]')).not.toBe(pane)
|
||||
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive={false} />)
|
||||
mocks.state!.groupsByWorktree['wt-1'] = [{ ...group, activeTabId: tabs[199].id }]
|
||||
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive />)
|
||||
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-browser-pane-id="browser-199"]')).not.toBeNull()
|
||||
expect(slot.firstElementChild).toBe(viewport)
|
||||
expect(viewport!.isConnected).toBe(true)
|
||||
})
|
||||
|
||||
it('retains zero unclaimed hidden panes after visiting 50 worktrees with 20 tabs each', () => {
|
||||
const worktreeIds = Array.from({ length: 50 }, (_, index) => `wt-scale-${index}`)
|
||||
for (const worktreeId of worktreeIds) {
|
||||
const browsers = Array.from({ length: 20 }, (_, index) => ({
|
||||
...createBrowserTab(`${worktreeId}-browser-${index}`, [`${worktreeId}-page-${index}`]),
|
||||
worktreeId
|
||||
}))
|
||||
const tabs = browsers.map((browser, index) => ({
|
||||
...createUnifiedBrowserTab(`${worktreeId}-tab-${index}`, browser.id, index),
|
||||
worktreeId,
|
||||
groupId: `${worktreeId}-group-${index}`
|
||||
}))
|
||||
mocks.state!.browserTabsByWorktree[worktreeId] = browsers
|
||||
mocks.state!.unifiedTabsByWorktree[worktreeId] = tabs
|
||||
mocks.state!.groupsByWorktree[worktreeId] = tabs.map((tab) => ({
|
||||
id: tab.groupId,
|
||||
worktreeId,
|
||||
activeTabId: tab.id,
|
||||
tabOrder: [tab.id]
|
||||
}))
|
||||
}
|
||||
const surfaces = (activeId: string | null) =>
|
||||
worktreeIds.map((worktreeId) => (
|
||||
<RetainedBrowserPaneOverlayLayer
|
||||
key={worktreeId}
|
||||
worktreeId={worktreeId}
|
||||
isWorktreeActive={worktreeId === activeId}
|
||||
mountEligible={worktreeId === activeId}
|
||||
/>
|
||||
))
|
||||
const view = render(surfaces(null))
|
||||
for (const worktreeId of worktreeIds) {
|
||||
view.rerender(surfaces(worktreeId))
|
||||
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(20)
|
||||
view.rerender(surfaces(null))
|
||||
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0)
|
||||
}
|
||||
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(1000)
|
||||
})
|
||||
|
||||
it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => {
|
||||
mocks.state = createState()
|
||||
mocks.state.groupsByWorktree = {
|
||||
|
||||
+14
-21
@@ -1,10 +1,11 @@
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { registerBrowserOverlaySlotViewport } from '../host-guest/browser-page-viewport'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '../../../store'
|
||||
import type { BrowserTab as BrowserTabState } from '../../../../../shared/browser-workspace-types'
|
||||
import type { Tab, TabGroup } from '../../../../../shared/tab-types'
|
||||
import BrowserPane from './browser-workspace-pane'
|
||||
import { DeferredBrowserContent } from './DeferredBrowserContent'
|
||||
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
|
||||
import { tabGroupBodyAnchorName } from '../../tab-group/tab-group-body-anchor'
|
||||
import { useBrowserGuestPaintRetention } from '../host-guest/browser-guest-paint-retention'
|
||||
@@ -28,23 +29,23 @@ const EMPTY_GROUPS: readonly TabGroup[] = []
|
||||
|
||||
type BrowserOverlaySlotProps = {
|
||||
browserTab: BrowserTabState
|
||||
isWorktreeActive: boolean
|
||||
// Why: undefined = orphan tab (in browserTabs but not referenced by any group's unified-tab list); the fallback branch keeps these hidden.
|
||||
groupId: string | undefined
|
||||
isActive: boolean
|
||||
chromeShortcutScope: BrowserChromeShortcutScope
|
||||
// Why: overlay is a sibling of the group layout, so pane focus doesn't bubble to TabGroupPanel; re-sync it here or split-view clicks leave activeGroupIdByWorktree stale.
|
||||
onFocusOwningGroup: ((groupId: string) => void) | undefined
|
||||
isWorktreeActive: boolean
|
||||
}
|
||||
|
||||
// Why: memoize each slot so unrelated worktree mutations don't cascade a re-render into every BrowserPane subtree.
|
||||
const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
|
||||
browserTab,
|
||||
isWorktreeActive,
|
||||
groupId,
|
||||
isActive,
|
||||
chromeShortcutScope,
|
||||
onFocusOwningGroup,
|
||||
isWorktreeActive
|
||||
onFocusOwningGroup
|
||||
}: BrowserOverlaySlotProps): React.JSX.Element {
|
||||
// Why: persistent page viewports (webview guests) live under this root so they survive BrowserPane chrome unmounts without reparenting.
|
||||
const setSlotViewportRef = useCallback(
|
||||
@@ -60,8 +61,6 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
|
||||
: [browserTab.activePageId ?? browserTab.id]
|
||||
const needsGuestPaint = useBrowserGuestPaintRetention(browserPageIds)
|
||||
const isPaintable = isActive || needsGuestPaint
|
||||
// Why: hidden worktrees keep lightweight overlay slots, but park their webviews unless a remote controller or viewer needs the guest.
|
||||
const shouldMountPane = isWorktreeActive || needsGuestPaint
|
||||
// Why: CSS anchor positioning pins the overlay to its owning group's body — a tab move only swaps positionAnchor, no measurement/state.
|
||||
// Orphan branch (no anchorName) stays display:none until the tab is reassigned or destroyed.
|
||||
const style: React.CSSProperties = useMemo(
|
||||
@@ -104,14 +103,14 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
|
||||
onFocusCapture={handleFocus}
|
||||
>
|
||||
<div ref={setSlotViewportRef} className="absolute inset-0 flex min-h-0 flex-col" />
|
||||
{/* Why: hidden worktrees park the heavy pane subtree; visible ones keep stable slots so reparenting can't destroy the webview guest. */}
|
||||
{shouldMountPane ? (
|
||||
<DeferredBrowserContent mountEligible={isPaintable} retainMounted={isWorktreeActive}>
|
||||
<BrowserPane
|
||||
browserTab={browserTab}
|
||||
isWorktreeActive={isWorktreeActive}
|
||||
isActive={isActive}
|
||||
chromeShortcutScope={chromeShortcutScope}
|
||||
/>
|
||||
) : null}
|
||||
</DeferredBrowserContent>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -188,11 +187,11 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({
|
||||
<BrowserOverlaySlot
|
||||
key={browserTab.id}
|
||||
browserTab={browserTab}
|
||||
isWorktreeActive={isWorktreeActive}
|
||||
groupId={assignment?.groupId}
|
||||
isActive={isActive}
|
||||
chromeShortcutScope={chromeShortcutScope}
|
||||
onFocusOwningGroup={focusOwningGroup}
|
||||
isWorktreeActive={isWorktreeActive}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -265,17 +264,11 @@ export const RetainedBrowserPaneOverlayLayer = memo(function RetainedBrowserPane
|
||||
isWorktreeActive: boolean
|
||||
mountEligible: boolean
|
||||
}): React.JSX.Element | null {
|
||||
const [hasCommittedMount, setHasCommittedMount] = useState(false)
|
||||
// Why: commit the latch with the persistent slot DOM so discarded renders cannot retain a guest host.
|
||||
useLayoutEffect(() => {
|
||||
if (mountEligible && !hasCommittedMount) {
|
||||
setHasCommittedMount(true)
|
||||
}
|
||||
}, [hasCommittedMount, mountEligible])
|
||||
if (!mountEligible && !hasCommittedMount) {
|
||||
return null
|
||||
}
|
||||
return <BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isWorktreeActive} />
|
||||
return (
|
||||
<DeferredBrowserContent mountEligible={mountEligible}>
|
||||
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isWorktreeActive} />
|
||||
</DeferredBrowserContent>
|
||||
)
|
||||
})
|
||||
|
||||
export default BrowserPaneOverlayLayer
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useLayoutEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
export function DeferredBrowserContent({
|
||||
mountEligible,
|
||||
retainMounted = true,
|
||||
children
|
||||
}: {
|
||||
mountEligible: boolean
|
||||
retainMounted?: boolean
|
||||
children: ReactNode
|
||||
}): React.JSX.Element | null {
|
||||
const [hasCommittedMount, setHasCommittedMount] = useState(false)
|
||||
// Only committed, retainable mounts may survive the loss of eligibility.
|
||||
useLayoutEffect(() => {
|
||||
if (!retainMounted) {
|
||||
setHasCommittedMount(false)
|
||||
} else if (mountEligible && !hasCommittedMount) {
|
||||
setHasCommittedMount(true)
|
||||
}
|
||||
}, [hasCommittedMount, mountEligible, retainMounted])
|
||||
return mountEligible || (retainMounted && hasCommittedMount) ? <>{children}</> : null
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createStore, type StoreApi } from 'zustand'
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
|
||||
import type { Tab, TabGroup } from '../../../../../shared/tab-types'
|
||||
|
||||
type MockState = {
|
||||
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
|
||||
browserPagesByWorkspace: Record<string, BrowserPage[]>
|
||||
unifiedTabsByWorktree: Record<string, Tab[]>
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
activeGroupIdByWorktree: Record<string, string>
|
||||
remoteBrowserPageHandlesByPageId: Record<string, never>
|
||||
focusGroup: () => void
|
||||
updateBrowserPageState: () => void
|
||||
setBrowserPageUrl: () => void
|
||||
settings: { browserSshWorkspaceRoutingEnabled: boolean }
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: null as MockState | null,
|
||||
store: null as StoreApi<MockState> | null,
|
||||
executionHostId: 'local',
|
||||
prepare: vi.fn(),
|
||||
destroy: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const { useStore } = await import('zustand')
|
||||
return {
|
||||
useAppStore: (selector: (state: MockState) => unknown) => useStore(mocks.store!, selector)
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => null,
|
||||
getExecutionHostIdForWorktree: () => mocks.executionHostId
|
||||
}))
|
||||
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
|
||||
useContextualTour: () => {}
|
||||
}))
|
||||
vi.mock('../host-guest/webview-registry', () => ({ destroyPersistentWebview: mocks.destroy }))
|
||||
vi.mock('./BrowserMobileDriverOverlay', () => ({ BrowserMobileDriverOverlay: () => null }))
|
||||
vi.mock('./browser-page-pane', () => ({
|
||||
BrowserPagePane: ({ browserTab, isActive }: { browserTab: BrowserPage; isActive: boolean }) => (
|
||||
<input data-page-id={browserTab.id} data-active={isActive} />
|
||||
)
|
||||
}))
|
||||
vi.mock('../workspace-doc/workspace-doc-page-pane', () => ({
|
||||
WorkspaceDocPagePane: ({ page, isActive }: { page: BrowserPage; isActive: boolean }) => (
|
||||
<input data-page-id={page.id} data-active={isActive} />
|
||||
)
|
||||
}))
|
||||
|
||||
import BrowserPaneOverlayLayer from './BrowserPaneOverlayLayer'
|
||||
import {
|
||||
acquireBrowserAutomationVisibility,
|
||||
releaseBrowserAutomationVisibility
|
||||
} from '../host-guest/browser-automation-visibility'
|
||||
import { hydrateBrowserDrivers } from '@/lib/pane-manager/browser-mobile-driver-state'
|
||||
import { hydrateBrowserRemoteViewerPages } from '@/lib/pane-manager/browser-remote-viewer-state'
|
||||
|
||||
function createState(): MockState {
|
||||
const browsers: BrowserWorkspace[] = ['a', 'b'].map((id) => ({
|
||||
id,
|
||||
worktreeId: 'wt-1',
|
||||
label: id,
|
||||
sessionProfileId: null,
|
||||
activePageId: `${id}-1`,
|
||||
pageIds: [`${id}-1`, `${id}-2`],
|
||||
url: 'about:blank',
|
||||
title: id,
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}))
|
||||
return {
|
||||
browserTabsByWorktree: { 'wt-1': browsers },
|
||||
browserPagesByWorkspace: Object.fromEntries(
|
||||
browsers.map((browser) => [
|
||||
browser.id,
|
||||
(browser.pageIds ?? []).map((id) => ({ ...browser, id, workspaceId: browser.id }))
|
||||
])
|
||||
),
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': browsers.map((browser, index) => ({
|
||||
id: browser.id,
|
||||
entityId: browser.id,
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'browser',
|
||||
label: browser.id,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: 1
|
||||
}))
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'a', tabOrder: ['a', 'b'] }]
|
||||
},
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
focusGroup: () => {},
|
||||
updateBrowserPageState: () => {},
|
||||
setBrowserPageUrl: () => {},
|
||||
settings: { browserSshWorkspaceRoutingEnabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
function selectTab(id: string): void {
|
||||
mocks.state!.groupsByWorktree['wt-1'] = [
|
||||
{ ...mocks.state!.groupsByWorktree['wt-1'][0], activeTabId: id }
|
||||
]
|
||||
}
|
||||
|
||||
function selectPage(id: string): void {
|
||||
mocks.state!.browserTabsByWorktree['wt-1'] = mocks.state!.browserTabsByWorktree['wt-1'].map(
|
||||
(browser) => (browser.id === 'a' ? { ...browser, activePageId: id } : browser)
|
||||
)
|
||||
}
|
||||
|
||||
const surface = (active = true) => (
|
||||
<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive={active} />
|
||||
)
|
||||
function redraw(view: ReturnType<typeof render>, active = true): void {
|
||||
act(() => mocks.store!.setState({ ...mocks.state! }))
|
||||
view.rerender(surface(active))
|
||||
}
|
||||
const settle = () => act(async () => {})
|
||||
|
||||
describe('deferred browser lifecycle through the overlay and SSH gate', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state = createState()
|
||||
mocks.store = createStore(() => mocks.state!)
|
||||
mocks.executionHostId = 'local'
|
||||
mocks.destroy.mockReset()
|
||||
mocks.prepare.mockReset().mockResolvedValue({ partition: 'persist:orca-browser-v1-routed' })
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { browser: { prepareSshWorkspacePartition: mocks.prepare } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
hydrateBrowserDrivers([])
|
||||
hydrateBrowserRemoteViewerPages([])
|
||||
})
|
||||
|
||||
it.each(['automation', 'mobile', 'viewer'])(
|
||||
'releases hidden sibling chrome without remounting the %s-claimed page',
|
||||
(consumer) => {
|
||||
const view = render(surface())
|
||||
selectPage('a-2')
|
||||
redraw(view)
|
||||
const claimed = view.container.querySelector('[data-page-id="a-2"]')
|
||||
selectTab('b')
|
||||
redraw(view)
|
||||
let token: string | null = null
|
||||
act(() => {
|
||||
if (consumer === 'automation') {
|
||||
token = acquireBrowserAutomationVisibility('a-2')
|
||||
}
|
||||
if (consumer === 'mobile') {
|
||||
hydrateBrowserDrivers([
|
||||
{ browserPageId: 'a-2', driver: { kind: 'mobile', clientId: 'phone-1' } }
|
||||
])
|
||||
}
|
||||
if (consumer === 'viewer') {
|
||||
hydrateBrowserRemoteViewerPages(['a-2'])
|
||||
}
|
||||
})
|
||||
try {
|
||||
redraw(view, false)
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-page-id="a-2"]')).toBe(claimed)
|
||||
redraw(view)
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(2)
|
||||
expect(view.container.querySelector('[data-page-id="a-1"]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-page-id="a-2"]')).toBe(claimed)
|
||||
redraw(view, false)
|
||||
act(() => {
|
||||
if (token) {
|
||||
releaseBrowserAutomationVisibility(token)
|
||||
}
|
||||
hydrateBrowserDrivers([])
|
||||
hydrateBrowserRemoteViewerPages([])
|
||||
})
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0)
|
||||
redraw(view)
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-page-id="b-1"]')).not.toBeNull()
|
||||
} finally {
|
||||
if (token) {
|
||||
releaseBrowserAutomationVisibility(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['url', 'document'])(
|
||||
'retains %s content across page and tab switches within a visible worktree',
|
||||
(kind) => {
|
||||
if (kind === 'document') {
|
||||
mocks.state!.browserPagesByWorkspace.a[0].docLocation = {
|
||||
kind: 'workspace-doc',
|
||||
worktreeId: 'wt-1',
|
||||
filePath: '/workspace/report.html'
|
||||
}
|
||||
}
|
||||
const view = render(surface())
|
||||
const page = view.container.querySelector<HTMLInputElement>('[data-page-id="a-1"]')!
|
||||
page.value = 'unsaved state'
|
||||
page.scrollTop = 80
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
|
||||
selectPage('a-2')
|
||||
redraw(view)
|
||||
expect(page.isConnected).toBe(true)
|
||||
expect(page.dataset.active).toBe('false')
|
||||
selectTab('b')
|
||||
redraw(view)
|
||||
expect(page.isConnected).toBe(true)
|
||||
selectPage('a-1')
|
||||
selectTab('a')
|
||||
redraw(view)
|
||||
expect(view.container.querySelector('[data-page-id="a-1"]')).toBe(page)
|
||||
expect(page.value).toBe('unsaved state')
|
||||
expect(page.scrollTop).toBe(80)
|
||||
expect(page.dataset.active).toBe('true')
|
||||
expect(view.container.querySelector('[data-page-id="b-2"]')).toBeNull()
|
||||
|
||||
redraw(view, false)
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0)
|
||||
redraw(view)
|
||||
const restored = view.container.querySelector('[data-page-id="a-1"]')!
|
||||
expect(restored).not.toBe(page)
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
|
||||
|
||||
mocks.state!.browserPagesByWorkspace.a = mocks.state!.browserPagesByWorkspace.a.slice(1)
|
||||
mocks.state!.browserTabsByWorktree['wt-1'] = [...mocks.state!.browserTabsByWorktree['wt-1']]
|
||||
redraw(view)
|
||||
expect(page.isConnected).toBe(false)
|
||||
expect(restored.isConnected).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a prepared SSH gate and its opened pages alive when switching tabs', async () => {
|
||||
mocks.executionHostId = 'ssh:target-a'
|
||||
const view = render(surface())
|
||||
await settle()
|
||||
const page = view.container.querySelector('[data-page-id="a-1"]')
|
||||
expect(page).not.toBeNull()
|
||||
mocks.destroy.mockClear()
|
||||
selectTab('b')
|
||||
redraw(view)
|
||||
await settle()
|
||||
expect(mocks.destroy.mock.calls.flat()).not.toContain('a-1')
|
||||
mocks.destroy.mockClear()
|
||||
mocks.prepare.mockClear()
|
||||
selectTab('a')
|
||||
redraw(view)
|
||||
await settle()
|
||||
expect(mocks.destroy).not.toHaveBeenCalled()
|
||||
expect(mocks.prepare).not.toHaveBeenCalled()
|
||||
expect(view.container.querySelector('[data-page-id="a-1"]')).toBe(page)
|
||||
redraw(view, false)
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0)
|
||||
expect(mocks.destroy).not.toHaveBeenCalled()
|
||||
redraw(view)
|
||||
await settle()
|
||||
expect(mocks.prepare).toHaveBeenCalledOnce()
|
||||
expect(view.container.querySelector('[data-page-id="a-1"]')).not.toBe(page)
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('guards all pages, including inactive tabs, when SSH routing is enabled', async () => {
|
||||
mocks.executionHostId = 'ssh:target-a'
|
||||
mocks.state!.settings.browserSshWorkspaceRoutingEnabled = false
|
||||
const view = render(surface())
|
||||
selectPage('a-2')
|
||||
redraw(view)
|
||||
selectTab('b')
|
||||
redraw(view)
|
||||
selectTab('a')
|
||||
redraw(view)
|
||||
mocks.destroy.mockClear()
|
||||
mocks.prepare.mockImplementation(() => new Promise(() => {}))
|
||||
mocks.state!.settings = { browserSshWorkspaceRoutingEnabled: true }
|
||||
mocks.state!.browserTabsByWorktree['wt-1'] = mocks.state!.browserTabsByWorktree['wt-1'].map(
|
||||
(browser) => ({ ...browser })
|
||||
)
|
||||
redraw(view)
|
||||
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0)
|
||||
expect(new Set(mocks.destroy.mock.calls.flat())).toEqual(new Set(['a-1', 'a-2', 'b-1', 'b-2']))
|
||||
})
|
||||
})
|
||||
+41
-6
@@ -149,14 +149,49 @@ describe('browser workspace pane retention props', () => {
|
||||
hydrateBrowserRemoteViewerPages([])
|
||||
})
|
||||
|
||||
it('defers unopened pages out of 200 and retains opened pages until unmount', () => {
|
||||
const pages = Array.from({ length: 200 }, (_, index) => createPage(`page-${index}`))
|
||||
mocks.state!.browserPagesByWorkspace[WORKSPACE_ID] = pages
|
||||
const workspace = { ...createWorkspace(), activePageId: pages[0].id }
|
||||
const view = render(<BrowserPane browserTab={workspace} isActive />)
|
||||
const renderedIds = (): (string | null)[] =>
|
||||
[...view.container.querySelectorAll('[data-browser-page-id]')].map((node) =>
|
||||
node.getAttribute('data-browser-page-id')
|
||||
)
|
||||
expect(renderedIds()).toEqual(['page-0'])
|
||||
|
||||
view.rerender(<BrowserPane browserTab={{ ...workspace, activePageId: 'page-199' }} isActive />)
|
||||
expect(renderedIds()).toEqual(['page-0', 'page-199'])
|
||||
view.rerender(<BrowserPane browserTab={workspace} isActive={false} />)
|
||||
expect(renderedIds()).toEqual(['page-0', 'page-199'])
|
||||
view.rerender(<BrowserPane key="restored" browserTab={workspace} isActive />)
|
||||
expect(renderedIds()).toEqual(['page-0'])
|
||||
})
|
||||
|
||||
it.each(['automation', 'mobile', 'viewer'])('loads an inactive page for %s only', (consumer) => {
|
||||
const token = consumer === 'automation' ? acquireBrowserAutomationVisibility('page-b') : null
|
||||
if (consumer === 'mobile') {
|
||||
hydrateBrowserDrivers([
|
||||
{ browserPageId: 'page-b', driver: { kind: 'mobile', clientId: 'phone-1' } }
|
||||
])
|
||||
}
|
||||
if (consumer === 'viewer') {
|
||||
hydrateBrowserRemoteViewerPages(['page-b'])
|
||||
}
|
||||
try {
|
||||
const view = render(<BrowserPane browserTab={createWorkspace()} isActive={false} />)
|
||||
expect(view.container.querySelector('[data-browser-page-id="page-a"]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-browser-page-id="page-b"]')).not.toBeNull()
|
||||
} finally {
|
||||
if (token) {
|
||||
releaseBrowserAutomationVisibility(token)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('threads all three retention terms to the page that owns them', () => {
|
||||
renderWorkspacePane()
|
||||
expect(propsFor('page-b')).toEqual({
|
||||
id: 'page-b',
|
||||
isAutomationVisible: false,
|
||||
isMobileDriven: false,
|
||||
isRemotelyViewed: false
|
||||
})
|
||||
expect(mocks.pageProps.some((props) => props.id === 'page-b')).toBe(false)
|
||||
|
||||
cleanup()
|
||||
const token = acquireBrowserAutomationVisibility('page-b')
|
||||
|
||||
+48
-35
@@ -19,15 +19,19 @@ import { RemoteBrowserPagePane } from '../stream-remote/remote-browser-page-pane
|
||||
import { ClientHostedBrowserPagePane } from '../ClientHostedBrowserPagePane'
|
||||
import { BrowserPagePane } from './browser-page-pane'
|
||||
import { WorkspaceDocPagePane } from '../workspace-doc/workspace-doc-page-pane'
|
||||
import { DeferredBrowserContent } from './DeferredBrowserContent'
|
||||
import { isBrowserPagePanePaintable } from '../host-guest/browser-page-paintability'
|
||||
import { SshRoutedBrowserPageGate } from './ssh-routed-browser-page-gate'
|
||||
|
||||
export default function BrowserPane({
|
||||
browserTab,
|
||||
isActive,
|
||||
isWorktreeActive = true,
|
||||
chromeShortcutScope
|
||||
}: {
|
||||
browserTab: BrowserWorkspaceState
|
||||
isActive: boolean
|
||||
isWorktreeActive?: boolean
|
||||
chromeShortcutScope?: BrowserChromeShortcutScope
|
||||
}): React.JSX.Element {
|
||||
const resolvedChromeShortcutScope = chromeShortcutScope ?? (isActive ? 'focused' : 'inactive')
|
||||
@@ -55,17 +59,17 @@ export default function BrowserPane({
|
||||
const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds)
|
||||
const mobileDrivenPageIds = useBrowserMobileDrivenPageIds(browserPageIds)
|
||||
const remotelyViewedPageIds = useBrowserRemotelyViewedPageIds(browserPageIds)
|
||||
// Why: inactive webviews must stay mounted in their original DOM parent; unmounting/reparenting loses form text and SPA state.
|
||||
const renderedBrowserPages = useMemo(
|
||||
const localBrowserPages = useMemo(
|
||||
() =>
|
||||
browserPages.filter(
|
||||
(page) => !getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId)
|
||||
),
|
||||
[browserPages, activeRuntimeEnvironmentId]
|
||||
)
|
||||
const renderedBrowserPageIds = useMemo(
|
||||
() => renderedBrowserPages.map((page) => page.id),
|
||||
[renderedBrowserPages]
|
||||
// Routing guards every local guest, including pages hidden after their first activation.
|
||||
const localBrowserPageIds = useMemo(
|
||||
() => localBrowserPages.map((page) => page.id),
|
||||
[localBrowserPages]
|
||||
)
|
||||
const pageDriver = useBrowserDriverForPage(activeBrowserPageId)
|
||||
// Why: a runtime-backed page is streamed, never locally driven, so its driver must read idle.
|
||||
@@ -149,42 +153,51 @@ export default function BrowserPane({
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col">
|
||||
{renderedBrowserPages.length > 0 ? (
|
||||
{localBrowserPages.length > 0 ? (
|
||||
<SshRoutedBrowserPageGate
|
||||
worktreeId={browserTab.worktreeId}
|
||||
sessionProfileId={browserTab.sessionProfileId ?? null}
|
||||
pageIds={renderedBrowserPageIds}
|
||||
pageIds={localBrowserPageIds}
|
||||
>
|
||||
{(routedPartition) => (
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
{renderedBrowserPages.map((page) =>
|
||||
page.docLocation ? (
|
||||
<WorkspaceDocPagePane
|
||||
key={page.id}
|
||||
page={page}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
/>
|
||||
) : (
|
||||
<BrowserPagePane
|
||||
key={page.id}
|
||||
browserTab={page}
|
||||
workspaceId={browserTab.id}
|
||||
worktreeId={browserTab.worktreeId}
|
||||
sessionProfileId={browserTab.sessionProfileId ?? null}
|
||||
sessionPartition={routedPartition ?? browserTab.sessionPartition ?? null}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
chromeShortcutScope={
|
||||
page.id === activeBrowserPage?.id ? resolvedChromeShortcutScope : 'inactive'
|
||||
}
|
||||
isAutomationVisible={automationVisiblePageIds.has(page.id)}
|
||||
isMobileDriven={mobileDrivenPageIds.has(page.id)}
|
||||
isRemotelyViewed={remotelyViewedPageIds.has(page.id)}
|
||||
inputLocked={activeBrowserDriver.kind === 'mobile'}
|
||||
onUpdatePageState={updateBrowserPageState}
|
||||
onSetUrl={setBrowserPageUrl}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{localBrowserPages.map((page) => (
|
||||
<DeferredBrowserContent
|
||||
key={page.id}
|
||||
retainMounted={isWorktreeActive}
|
||||
mountEligible={isBrowserPagePanePaintable({
|
||||
isActive: isActive && page.id === activeBrowserPageId,
|
||||
isAutomationVisible: automationVisiblePageIds.has(page.id),
|
||||
isMobileDriven: mobileDrivenPageIds.has(page.id),
|
||||
hasRemoteViewer: remotelyViewedPageIds.has(page.id)
|
||||
})}
|
||||
>
|
||||
{page.docLocation ? (
|
||||
<WorkspaceDocPagePane
|
||||
page={page}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
/>
|
||||
) : (
|
||||
<BrowserPagePane
|
||||
browserTab={page}
|
||||
workspaceId={browserTab.id}
|
||||
worktreeId={browserTab.worktreeId}
|
||||
sessionProfileId={browserTab.sessionProfileId ?? null}
|
||||
sessionPartition={routedPartition ?? browserTab.sessionPartition ?? null}
|
||||
isActive={isActive && page.id === activeBrowserPage?.id}
|
||||
chromeShortcutScope={
|
||||
page.id === activeBrowserPage?.id ? resolvedChromeShortcutScope : 'inactive'
|
||||
}
|
||||
isAutomationVisible={automationVisiblePageIds.has(page.id)}
|
||||
isMobileDriven={mobileDrivenPageIds.has(page.id)}
|
||||
isRemotelyViewed={remotelyViewedPageIds.has(page.id)}
|
||||
inputLocked={activeBrowserDriver.kind === 'mobile'}
|
||||
onUpdatePageState={updateBrowserPageState}
|
||||
onSetUrl={setBrowserPageUrl}
|
||||
/>
|
||||
)}
|
||||
</DeferredBrowserContent>
|
||||
))}
|
||||
<BrowserMobileDriverOverlay
|
||||
driver={activeBrowserDriver}
|
||||
onTakeBack={reclaimActiveBrowserForDesktop}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createBrowserPageWebviewGuestSession } from './browser-page-webview-guest-session'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
replace: vi.fn(async () => {}),
|
||||
registeredIds: new Map<string, number>(),
|
||||
isRegistered: vi.fn(async () => true)
|
||||
}))
|
||||
vi.mock('./webview-registry', () => ({
|
||||
registeredWebContentsIds: mocks.registeredIds,
|
||||
replacePersistentWebview: mocks.replace
|
||||
}))
|
||||
vi.mock('../describe-page/browser-page-load-error', () => ({ browserPageExists: () => true }))
|
||||
|
||||
function createPage(id: string) {
|
||||
const webview = document.createElement('webview') as Electron.WebviewTag
|
||||
webview.getWebContentsId = vi.fn(() => {
|
||||
if (!webview.isConnected) {
|
||||
throw new Error('guest destroyed')
|
||||
}
|
||||
return 1
|
||||
})
|
||||
document.body.appendChild(webview)
|
||||
const paintable = { current: false }
|
||||
const setGeneration = vi.fn()
|
||||
const ref = <T>(current: T) => ({ current })
|
||||
const session = createBrowserPageWebviewGuestSession({
|
||||
webview,
|
||||
browserTabId: id,
|
||||
workspaceId: 'browser-1',
|
||||
worktreeId: 'wt-1',
|
||||
sessionProfileId: null,
|
||||
webviewRef: ref(webview),
|
||||
isPaintableRef: paintable,
|
||||
guestRecoveryPendingRef: ref(false),
|
||||
browserTabUrlRef: ref('https://example.test'),
|
||||
addressBarValueRef: ref('https://example.test'),
|
||||
activeLoadFailureRef: ref(null),
|
||||
recoveryNavigationValidationRef: ref(null),
|
||||
keepAddressBarFocusRef: ref(false),
|
||||
paneZoomLevelRef: ref(0),
|
||||
viewportPresetIdRef: ref(null),
|
||||
onUpdatePageStateRef: ref(vi.fn()),
|
||||
setGuestRecoveryGeneration: setGeneration,
|
||||
setBrowserZoomPercent: vi.fn(),
|
||||
focusAddressBarNow: () => false,
|
||||
syncNavigationState: vi.fn(),
|
||||
syncBrowserAnnotationViewportBridge: vi.fn()
|
||||
})
|
||||
return { webview, paintable, setGeneration, recovery: session.guestRecovery }
|
||||
}
|
||||
|
||||
describe('retained browser panes after guest eviction', () => {
|
||||
const pages: ReturnType<typeof createPage>[] = []
|
||||
beforeEach(() => {
|
||||
mocks.replace.mockClear()
|
||||
mocks.isRegistered.mockClear()
|
||||
mocks.registeredIds.clear()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { browser: { isGuestRegistered: mocks.isRegistered } }
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const page of pages.splice(0)) {
|
||||
page.recovery.dispose()
|
||||
page.webview.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilds only the selected page after evicting 200 hidden guests', async () => {
|
||||
for (let index = 0; index < 200; index++) {
|
||||
const page = createPage(`page-${index}`)
|
||||
pages.push(page)
|
||||
page.webview.remove()
|
||||
page.recovery.validateAfterResume()
|
||||
}
|
||||
expect(mocks.replace).not.toHaveBeenCalled()
|
||||
pages[199].paintable.current = true
|
||||
pages[199].recovery.validateAfterResume()
|
||||
await vi.waitFor(() => expect(pages[199].setGeneration).toHaveBeenCalledOnce())
|
||||
expect(mocks.replace).toHaveBeenCalledExactlyOnceWith('page-199')
|
||||
expect(pages.slice(0, 199).every((page) => page.setGeneration.mock.calls.length === 0)).toBe(
|
||||
true
|
||||
)
|
||||
expect(mocks.isRegistered).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses a connected registered guest on reactivation', async () => {
|
||||
const page = createPage('page-1')
|
||||
pages.push(page)
|
||||
mocks.registeredIds.set('page-1', 1)
|
||||
page.paintable.current = true
|
||||
page.recovery.validateAfterResume()
|
||||
await vi.waitFor(() => expect(mocks.isRegistered).toHaveBeenCalledOnce())
|
||||
expect(mocks.replace).not.toHaveBeenCalled()
|
||||
expect(page.setGeneration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mistake a connected guest awaiting dom-ready for an evicted guest', async () => {
|
||||
const page = createPage('page-1')
|
||||
pages.push(page)
|
||||
vi.mocked(page.webview.getWebContentsId).mockImplementation(() => {
|
||||
throw new Error('not ready')
|
||||
})
|
||||
page.paintable.current = true
|
||||
page.recovery.validateAfterResume()
|
||||
await vi.waitFor(() => expect(page.webview.getWebContentsId).toHaveBeenCalledOnce())
|
||||
expect(mocks.replace).not.toHaveBeenCalled()
|
||||
expect(page.setGeneration).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+4
@@ -134,6 +134,10 @@ export function createBrowserPageWebviewGuestSession({
|
||||
guestRecoveryPendingRef.current = pending
|
||||
},
|
||||
validateRegistration: async () => {
|
||||
// Budget eviction can remove a hidden guest while its pane stays mounted.
|
||||
if (!webview.isConnected) {
|
||||
return false
|
||||
}
|
||||
let webContentsId: number
|
||||
try {
|
||||
webContentsId = webview.getWebContentsId()
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEffect, type MutableRefObject } from 'react'
|
||||
import { useWebviewDragPassthroughActive } from './use-webview-drag-passthrough-active'
|
||||
|
||||
/**
|
||||
* Enrols a single component-owned guest in the renderer's drag passthrough.
|
||||
*
|
||||
* Why it matters: a `<webview>` swallows the pointer stream the document never sees, so a
|
||||
* dnd-kit drag stops receiving `pointermove` the instant the cursor crosses one — the dragged
|
||||
* tab stops following the cursor and the drop it was aiming for cannot be made. The browser
|
||||
* pane's guests are held click-through through their registry; a guest that belongs to one
|
||||
* component instead (the document preview) has no registry to be walked by, so it enrols here.
|
||||
*/
|
||||
export function useGuestDragPassthrough(
|
||||
webviewRef: MutableRefObject<Electron.WebviewTag | null>,
|
||||
/** Changes when the ref is pointed at a new guest, so one attached mid-drag is settled too. */
|
||||
guestKey: string | null
|
||||
): void {
|
||||
const passthroughActive = useWebviewDragPassthroughActive()
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
if (!webview) {
|
||||
return
|
||||
}
|
||||
webview.style.pointerEvents = passthroughActive ? 'none' : ''
|
||||
return () => {
|
||||
// Why reset rather than restore: the guest outlives this state, and leaving it transparent
|
||||
// would cost the reader every click on the document.
|
||||
webview.style.pointerEvents = ''
|
||||
}
|
||||
}, [guestKey, passthroughActive, webviewRef])
|
||||
}
|
||||
+3
-1
@@ -9,6 +9,7 @@
|
||||
// read error or a revoked grant); 'unsupported-asset' comes from a subresource whose format the
|
||||
// host declined to send — a font, say — and never from the document itself.
|
||||
import { act } from 'react'
|
||||
import type * as WebviewRegistryModule from '../host-guest/webview-registry'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
@@ -47,7 +48,8 @@ vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
|
||||
vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WebviewRegistryModule>()),
|
||||
moveFocusToRendererBeforeWebviewDetach: () => undefined
|
||||
}))
|
||||
|
||||
|
||||
+56
-2
@@ -5,6 +5,8 @@
|
||||
// showing the internal preview scheme, Back/Forward really drive the guest's history, and the chip
|
||||
// hands over the path the owner spells rather than the one the grant was minted with.
|
||||
import { act } from 'react'
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
|
||||
import type * as WebviewRegistryModule from '../host-guest/webview-registry'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
@@ -39,7 +41,8 @@ vi.mock('@/lib/doc-preview-grants', () => ({
|
||||
releaseDocPreviewGrant: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
|
||||
vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WebviewRegistryModule>()),
|
||||
moveFocusToRendererBeforeWebviewDetach: () => undefined
|
||||
}))
|
||||
|
||||
@@ -126,13 +129,14 @@ type StubWebview = Element & {
|
||||
async function renderPreview(
|
||||
container: HTMLDivElement,
|
||||
root: Root,
|
||||
options: { holdsGuestFocus?: boolean } = {}
|
||||
options: { holdsGuestFocus?: boolean; isActive?: boolean } = {}
|
||||
): Promise<StubWebview> {
|
||||
const { HtmlDocPreview } = await import('./HtmlDocPreview')
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<HtmlDocPreview
|
||||
isActive={options.isActive ?? true}
|
||||
previewId="preview-1"
|
||||
filePath={ABSOLUTE_PATH}
|
||||
relativePath={ENTRY_RELATIVE_PATH}
|
||||
@@ -199,6 +203,7 @@ describe('HtmlDocPreview browser chrome', () => {
|
||||
}
|
||||
},
|
||||
browser: {
|
||||
unregisterGuest: () => Promise.resolve(),
|
||||
setGrabMode: (args: { browserPageId: string; enabled: boolean }) => {
|
||||
grabCalls.push(args)
|
||||
return Promise.resolve({ ok: true })
|
||||
@@ -222,6 +227,55 @@ describe('HtmlDocPreview browser chrome', () => {
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('counts document guests in the workspace budget and restores only on activation', async () => {
|
||||
const { hasLiveBrowserGuest, webviewRegistry } = await import('../host-guest/webview-registry')
|
||||
const { worktreeHoldsLiveBrowserGuests, selectBrowserGuestEvictionWorktreeIds } =
|
||||
await import('../host-guest/browser-guest-worktree-retention')
|
||||
const { destroyWorktreeBrowserGuests } = await import('@/store/slices/browser-webview-cleanup')
|
||||
const guest = await renderPreview(container, root)
|
||||
expect(hasLiveBrowserGuest('preview-1')).toBe(true)
|
||||
expect(await renderPreview(container, root, { isActive: false })).toBe(guest)
|
||||
const page: BrowserPage = {
|
||||
id: 'preview-1',
|
||||
workspaceId: 'browser-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'about:blank',
|
||||
title: 'Report',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1,
|
||||
docLocation: { kind: 'workspace-doc', worktreeId: 'wt-1', filePath: ABSOLUTE_PATH }
|
||||
}
|
||||
const browsers: BrowserWorkspace[] = [{ ...page, id: 'browser-1', pageIds: [page.id] }]
|
||||
const pages: Record<string, BrowserPage[]> = { 'browser-1': [page] }
|
||||
const evicted = selectBrowserGuestEvictionWorktreeIds({
|
||||
orderedWorktreeIds: ['wt-1'],
|
||||
activeWorktreeId: 'wt-2',
|
||||
limit: 0,
|
||||
isRetained: () => true,
|
||||
isEvictable: () => true,
|
||||
holdsLiveGuests: () => worktreeHoldsLiveBrowserGuests(browsers, pages, hasLiveBrowserGuest)
|
||||
})
|
||||
expect(evicted).toEqual(['wt-1'])
|
||||
await act(async () => {
|
||||
destroyWorktreeBrowserGuests({ 'wt-1': browsers }, pages, 'wt-1')
|
||||
})
|
||||
expect(guest.isConnected).toBe(false)
|
||||
expect(hasLiveBrowserGuest('preview-1')).toBe(false)
|
||||
expect(container.querySelector('webview')).toBeNull()
|
||||
const restored = await renderPreview(container, root)
|
||||
expect(restored).not.toBe(guest)
|
||||
expect(webviewRegistry.get('preview-1')).toBe(restored)
|
||||
expect(await renderPreview(container, root, { isActive: false })).toBe(restored)
|
||||
expect(await renderPreview(container, root)).toBe(restored)
|
||||
await act(async () => root.unmount())
|
||||
mounted = false
|
||||
expect(hasLiveBrowserGuest('preview-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies the document by its workspace path and owning machine', async () => {
|
||||
await renderPreview(container, root)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
returnAcrossBrowserPageConversion
|
||||
} from '@/lib/browser-page-conversion-history'
|
||||
import { BrowserGuestAnnotateOverlays } from '@/components/browser-pane/annotate/browser-guest-annotate-overlays'
|
||||
import { useGuestDragPassthrough } from '@/components/browser-pane/host-guest/use-guest-drag-passthrough'
|
||||
import { attachDocPreviewWebview } from './doc-preview-webview-attach'
|
||||
import {
|
||||
buildDocPreviewGrantRequest,
|
||||
@@ -47,6 +46,7 @@ export function HtmlDocPreview({
|
||||
relativePath,
|
||||
worktreeId,
|
||||
holdsGuestFocus = false,
|
||||
isActive = true,
|
||||
runtimeEnvironmentId = null,
|
||||
externalSshTargetId = null,
|
||||
convertedFrom = null,
|
||||
@@ -58,6 +58,7 @@ export function HtmlDocPreview({
|
||||
worktreeId: string
|
||||
/** Whether this preview is the surface the reader is in, and so may hold the keyboard. */
|
||||
holdsGuestFocus?: boolean
|
||||
isActive?: boolean
|
||||
runtimeEnvironmentId?: string | null
|
||||
externalSshTargetId?: string | null
|
||||
/** Set when the address bar converted this page; Back returns across it once guest history runs out. */
|
||||
@@ -125,7 +126,6 @@ export function HtmlDocPreview({
|
||||
[filePath, hostLabel, worktreeRoot]
|
||||
)
|
||||
const isUnavailable = state === 'unavailable' || failureReason !== null
|
||||
useGuestDragPassthrough(webviewRef, grantId)
|
||||
const { grab, markup, annotationSend, grabAnnotations, browserOverlayViewport, elementTools } =
|
||||
useDocPreviewGuestTools({
|
||||
previewId,
|
||||
@@ -217,6 +217,7 @@ export function HtmlDocPreview({
|
||||
return
|
||||
}
|
||||
const attached = attachDocPreviewWebview({
|
||||
previewId,
|
||||
container: containerRef.current,
|
||||
url: handle.url,
|
||||
ariaLabel: translate(
|
||||
@@ -270,6 +271,13 @@ export function HtmlDocPreview({
|
||||
worktreeId
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
// Eviction removes the guest, not the retained pane; only the selected preview restores it.
|
||||
if (isActive && webviewRef.current && !webviewRef.current.isConnected) {
|
||||
setRemintCount((count) => count + 1)
|
||||
}
|
||||
}, [isActive, previewId])
|
||||
|
||||
// The dropdown's doc-history source: opening a document is a visit, once per document per mount
|
||||
// (a hard reload re-mints the grant but is not a new visit).
|
||||
useEffect(() => {
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { acquireWebviewsDragPassthrough } from '../host-guest/webview-drag-passthrough'
|
||||
import { webviewRegistry } from '../host-guest/webview-registry'
|
||||
import { attachDocPreviewWebview } from './doc-preview-webview-attach'
|
||||
|
||||
it('restores pointer input when a drag ends after attaching a document preview', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const append = vi.spyOn(container, 'appendChild')
|
||||
append.mockImplementation((node) => {
|
||||
expect((node as HTMLElement).style.pointerEvents).toBe('none')
|
||||
return Node.prototype.appendChild.call(container, node)
|
||||
})
|
||||
const release = acquireWebviewsDragPassthrough()
|
||||
const attached = attachDocPreviewWebview({
|
||||
previewId: 'preview-drag',
|
||||
container,
|
||||
url: 'orca-preview://grant/index.html',
|
||||
ariaLabel: 'HTML preview',
|
||||
onLoadStarted: vi.fn(),
|
||||
onLoadStopped: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onNavigated: vi.fn(),
|
||||
onTitleUpdated: vi.fn()
|
||||
})
|
||||
|
||||
try {
|
||||
expect(webviewRegistry.get('preview-drag')).toBe(attached.webview)
|
||||
expect(attached.webview.style.pointerEvents).toBe('none')
|
||||
release()
|
||||
expect(attached.webview.style.pointerEvents).toBe('')
|
||||
} finally {
|
||||
release()
|
||||
attached.detach()
|
||||
container.remove()
|
||||
append.mockRestore()
|
||||
}
|
||||
expect(webviewRegistry.has('preview-drag')).toBe(false)
|
||||
})
|
||||
+13
-9
@@ -1,9 +1,14 @@
|
||||
import { DOC_PREVIEW_PARTITION } from '../../../../../shared/doc-preview-scheme'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences'
|
||||
import { isWebviewDragPassthroughActive } from '@/components/browser-pane/host-guest/webview-drag-passthrough'
|
||||
import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/host-guest/webview-registry'
|
||||
import {
|
||||
moveFocusToRendererBeforeWebviewDetach,
|
||||
registerPersistentWebview,
|
||||
unregisterPersistentWebview,
|
||||
webviewRegistry
|
||||
} from '@/components/browser-pane/host-guest/webview-registry'
|
||||
|
||||
export function attachDocPreviewWebview({
|
||||
previewId,
|
||||
container,
|
||||
url,
|
||||
ariaLabel,
|
||||
@@ -13,6 +18,7 @@ export function attachDocPreviewWebview({
|
||||
onNavigated,
|
||||
onTitleUpdated
|
||||
}: {
|
||||
previewId: string
|
||||
container: HTMLDivElement
|
||||
url: string
|
||||
ariaLabel: string
|
||||
@@ -45,13 +51,8 @@ export function attachDocPreviewWebview({
|
||||
// Why the document names its own tab: a preview is a browser tab, and this is how every other
|
||||
// one is named. What the document cannot do is name it the grant it is served over.
|
||||
webview.addEventListener('page-title-updated', onTitleUpdated)
|
||||
// Why here and not in the enrolling hook: appending is what makes this guest hittable, and the
|
||||
// registry's contract is that the path doing so settles it. Dragging the preview's own tab
|
||||
// remounts this component mid-drag, and a hook effect lands a turn too late — for the rest of
|
||||
// that turn the fresh guest eats the pointer stream and the drag freezes.
|
||||
if (isWebviewDragPassthroughActive()) {
|
||||
webview.style.pointerEvents = 'none'
|
||||
}
|
||||
// Register before append so a guest attached mid-drag cannot swallow the pointer stream.
|
||||
registerPersistentWebview(previewId, webview)
|
||||
container.appendChild(webview)
|
||||
webview.setAttribute('src', url)
|
||||
|
||||
@@ -66,6 +67,9 @@ export function attachDocPreviewWebview({
|
||||
webview.removeEventListener('page-title-updated', onTitleUpdated)
|
||||
moveFocusToRendererBeforeWebviewDetach(webview)
|
||||
webview.remove()
|
||||
if (webviewRegistry.get(previewId) === webview) {
|
||||
unregisterPersistentWebview(previewId)
|
||||
}
|
||||
},
|
||||
// Why: the protocol handler answers with no-store, so a reload re-reads the workspace disk.
|
||||
reload: () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export function WorkspaceDocPagePane({
|
||||
// grab in flight, exactly as a URL page's pane does.
|
||||
<div className="absolute inset-0 flex min-h-0 flex-col" hidden={!isActive}>
|
||||
<HtmlDocPreview
|
||||
isActive={isActive}
|
||||
holdsGuestFocus={isActive && isReaderSurface}
|
||||
previewId={page.id}
|
||||
filePath={filePath}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness'
|
||||
|
||||
describe('IPC attach evidence', () => {
|
||||
const originalWindow = globalThis.window
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
installIpcPtyWindow(originalWindow, {})
|
||||
})
|
||||
afterEach(() => restorePtySpecWindow(originalWindow))
|
||||
|
||||
it.each(['exitedBeforeAttach', 'reattachUnverifiable'] as const)(
|
||||
'preserves main %s without publishing a successful spawn or attach',
|
||||
async (evidence) => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const id = 'ssh:ssh-1@@old-epoch-pty'
|
||||
const outcome = { id, [evidence]: true }
|
||||
vi.mocked(window.api.pty.spawn).mockResolvedValueOnce(outcome)
|
||||
const onPtySpawn = vi.fn()
|
||||
const onConnect = vi.fn()
|
||||
const transport = createIpcPtyTransport({ connectionId: 'ssh-1', onPtySpawn })
|
||||
expect(await transport.connect({ url: '', sessionId: id, callbacks: { onConnect } })).toEqual(
|
||||
outcome
|
||||
)
|
||||
expect(onPtySpawn).not.toHaveBeenCalled()
|
||||
expect(onConnect).not.toHaveBeenCalled()
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
transport.destroy?.()
|
||||
expect(window.api.pty.kill).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'SSH_SESSION_EXPIRED: old-epoch-pty',
|
||||
'Session not found: old-epoch-pty',
|
||||
'SSH relay connection unavailable'
|
||||
])('does not reconstruct observed exit from %s', async (message) => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
vi.mocked(window.api.pty.spawn).mockRejectedValueOnce(new Error(message))
|
||||
const transport = createIpcPtyTransport({ connectionId: 'ssh-1' })
|
||||
const result = await transport.connect({
|
||||
url: '',
|
||||
sessionId: 'ssh:ssh-1@@old-epoch-pty',
|
||||
callbacks: {}
|
||||
})
|
||||
expect(result && typeof result === 'object' && result.exitedBeforeAttach).toBeFalsy()
|
||||
transport.destroy?.()
|
||||
})
|
||||
})
|
||||
@@ -88,6 +88,9 @@ export async function connectIpcPty(
|
||||
// recorded before we asked for a PTY, so it belongs to that earlier owner, not to us.
|
||||
const priorIncarnationFence = currentPreHandlerPtySequence()
|
||||
const spawnResult = await spawnIpcPty(transportOptions, options, admittedSessionId)
|
||||
if (spawnResult.exitedBeforeAttach || spawnResult.reattachUnverifiable) {
|
||||
return context.isDestroyed() ? undefined : spawnResult
|
||||
}
|
||||
const retireFreshSpawn = async (): Promise<void> => {
|
||||
// A newer generation may already own a recycled id; an id-only kill would retire its PTY.
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { transformSync } from 'esbuild'
|
||||
import type { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
|
||||
|
||||
export async function loadPrBasePtyTransport(): Promise<{
|
||||
createRemoteRuntimePtyTransport: typeof createRemoteRuntimePtyTransport
|
||||
}> {
|
||||
// Frozen PR-base reader, evaluated with the same mocked dependencies as the current transport.
|
||||
const source = readFileSync(
|
||||
resolve('tests/fixtures/terminal/pr-base-1d1b73c40850-pty-transport.txt'),
|
||||
'utf8'
|
||||
)
|
||||
const { code: outputText } = transformSync(source, { loader: 'ts', format: 'cjs' })
|
||||
const dependencies = new Map<string, unknown>()
|
||||
for (const match of outputText.matchAll(/require\("([^"]+)"\)/g)) {
|
||||
const specifier = match[1]
|
||||
if (!dependencies.has(specifier)) {
|
||||
const target = specifier.startsWith('@/')
|
||||
? resolve('src/renderer/src', specifier.slice(2))
|
||||
: resolve(__dirname, specifier)
|
||||
dependencies.set(specifier, await import(/* @vite-ignore */ target))
|
||||
}
|
||||
}
|
||||
const exports = {} as { createRemoteRuntimePtyTransport: typeof createRemoteRuntimePtyTransport }
|
||||
const module = { exports }
|
||||
new Function('require', 'exports', 'module', outputText)(
|
||||
(name: string) => dependencies.get(name),
|
||||
exports,
|
||||
module
|
||||
)
|
||||
return module.exports
|
||||
}
|
||||
+89
-79
@@ -406,84 +406,94 @@ describe('connectPanePty', () => {
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not resume a live provider session while legacy worker recovery owns the pane', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const retainedPtyId = 'wt-1@@lost-pty'
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) =>
|
||||
sessionId
|
||||
? {
|
||||
id: 'fresh-pty',
|
||||
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }
|
||||
it.each(['live', 'undefined', 'thrown', 'unverifiable'] as const)(
|
||||
'reattaches a fenced local worker without retiring its binding on %s',
|
||||
async (outcome) => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const retainedPtyId = 'wt-1@@lost-pty'
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
||||
if (outcome === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
if (outcome === 'thrown') {
|
||||
throw new Error('daemon unavailable')
|
||||
}
|
||||
if (outcome === 'unverifiable') {
|
||||
return { id: retainedPtyId, reattachUnverifiable: true }
|
||||
}
|
||||
return sessionId ? { id: sessionId, isReattach: true } : 'fresh-pty'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings,
|
||||
agentCmdOverrides: {}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' }
|
||||
}
|
||||
: 'fresh-pty'
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings,
|
||||
agentCmdOverrides: {}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' }
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
},
|
||||
legacyWorkerResumeFencesByPaneKey: { [paneKey]: true }
|
||||
} as StoreState
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
},
|
||||
legacyWorkerResumeFencesByPaneKey: { [paneKey]: true }
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
})
|
||||
const binding = connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
deps as never
|
||||
)
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
const attachOptions = transport.attach.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(attachOptions).not.toHaveProperty('cols')
|
||||
expect(attachOptions).not.toHaveProperty('rows')
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
|
||||
expect(transport.connect).toHaveBeenCalledTimes(1)
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
const connectOptions = transport.connect.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(connectOptions).toMatchObject({ sessionId: retainedPtyId })
|
||||
expect(connectOptions).not.toHaveProperty('attachOnly')
|
||||
expect(connectOptions).not.toHaveProperty('command')
|
||||
expect(connectOptions).not.toHaveProperty('launchConfig')
|
||||
expect(connectOptions).not.toHaveProperty('resumeProviderSession')
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
binding.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not replace a missing retained legacy worker over direct SSH', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const retainedPtyId = toAppSshPtyId('ssh-a', 'missing-legacy-worker')
|
||||
const transport = createMockTransport()
|
||||
transport.getConnectionId.mockReturnValue('ssh-a')
|
||||
transport.attach.mockImplementation(() => {
|
||||
throw new Error('remote PTY missing')
|
||||
})
|
||||
transport.connect.mockRejectedValue(new Error('remote PTY missing'))
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
@@ -517,10 +527,12 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
expect(transport.connect.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionId: retainedPtyId
|
||||
})
|
||||
// An unproven failure is `unverifiable`: the pane keeps its binding rather than retiring a
|
||||
// session it may never replace (docs/reference/ssh-execution-boundary.md).
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -530,9 +542,7 @@ describe('connectPanePty', () => {
|
||||
const retainedPtyId = toAppSshPtyId('ssh-a', 'missing-legacy-worker')
|
||||
const transport = createMockTransport()
|
||||
transport.getConnectionId.mockReturnValue('ssh-a')
|
||||
transport.attach.mockImplementation(() => {
|
||||
throw new Error('remote PTY missing')
|
||||
})
|
||||
transport.connect.mockRejectedValue(new Error('remote PTY missing'))
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
@@ -569,11 +579,11 @@ describe('connectPanePty', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'ssh-a' })
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled()
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
// Same deferred-SSH flow as every other restored pane.
|
||||
expect(transport.connect.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionId: retainedPtyId
|
||||
})
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
+3
-12
@@ -1,4 +1,3 @@
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host'
|
||||
import { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate'
|
||||
@@ -11,7 +10,7 @@ import { toProcessExitStartup } from './process-exit-startup'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
import { runDeferredSessionReattachChoice } from './deferred-session-reattach-choice'
|
||||
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
|
||||
import { recoverUnverifiableReattach } from './unverifiable-reattach-recovery'
|
||||
|
||||
export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
const isCurrentPaneTransport = (): boolean =>
|
||||
@@ -61,8 +60,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
console.warn(
|
||||
`[pty-connection] SSH tab=${session.deps.tabId} connectionId=${session.connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}`
|
||||
)
|
||||
const legacyWorkerOwnsPane = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) {
|
||||
if (gate.enterDeferredFlow) {
|
||||
// Paint main's parked model while SSH recovery continues off the render path.
|
||||
session.prepaintParkedSshSnapshot(pendingSessionId)
|
||||
void (async () => {
|
||||
@@ -115,13 +113,6 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
}
|
||||
useAppStore.getState().removeDeferredSshReconnectTarget(session.connectionId)
|
||||
if (pendingSessionId) {
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
if (session.attachRetainedLegacyPty(pendingSessionId)) {
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
scheduleRuntimeGraphSync()
|
||||
}
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[pty-connection] Attempting reattach for tab=${session.deps.tabId} sessionId=${pendingSessionId}`
|
||||
)
|
||||
@@ -294,7 +285,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
return
|
||||
}
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
recoverUnverifiableDirectSshReattach(session, pendingSessionId)
|
||||
recoverUnverifiableReattach(session, pendingSessionId)
|
||||
})
|
||||
session.armDirectSshPaneRetryTimeout(
|
||||
trackedReattachPromise,
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runDeferredSessionReattachChoice } from './deferred-session-reattach-choice'
|
||||
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => ({ tabsByWorktree: {}, ptyIdsByTabId: {} }) }
|
||||
}))
|
||||
vi.mock('../pty-dispatcher', () => ({ getEagerPtyBufferHandle: vi.fn() }))
|
||||
|
||||
describe('fenced sleeping paired pane choice', () => {
|
||||
afterEach(() => vi.clearAllMocks())
|
||||
it('attaches the retained remote leaf without clearing its binding or resuming', () => {
|
||||
const id = 'remote:env-1@@terminal-1'
|
||||
const session = {
|
||||
deps: {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'tab-1',
|
||||
restoredLeafId: 'leaf-1',
|
||||
restoredPtyIdByLeafId: { 'leaf-1': id },
|
||||
paneTransportsRef: { current: new Map() },
|
||||
clearTabPtyId: vi.fn()
|
||||
},
|
||||
pane: { id: 1 },
|
||||
getSleepingRecordForPane: () => ({}),
|
||||
isLegacyWorkerAutomaticResumeBlocked: () => true,
|
||||
buildColdRestoreAgentResumeStartup: vi.fn(),
|
||||
syncPanePtyLayoutBinding: vi.fn(),
|
||||
clearPaneMode2031State: vi.fn(),
|
||||
clearHiddenOutputRestoreState: vi.fn(),
|
||||
captureTransportOutputCallbacks: () => ({ callbacks: {} }),
|
||||
transport: { attach: vi.fn(), getPtyId: () => id },
|
||||
bindActivePanePty: vi.fn(),
|
||||
registerPaneSerializerFor: vi.fn(),
|
||||
reportError: vi.fn(),
|
||||
startFreshSpawn: vi.fn(),
|
||||
startFreshColdRestoreAgentResume: vi.fn()
|
||||
}
|
||||
runDeferredSessionReattachChoice(session as never)
|
||||
expect(session.transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: id })
|
||||
)
|
||||
expect(session.deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(session.syncPanePtyLayoutBinding).not.toHaveBeenCalled()
|
||||
expect(session.startFreshSpawn).not.toHaveBeenCalled()
|
||||
expect(session.startFreshColdRestoreAgentResume).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+45
-57
@@ -27,7 +27,11 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
const existingPtyId = storeSnapshot.tabsByWorktree[session.deps.worktreeId]?.find(
|
||||
(t) => t.id === session.deps.tabId
|
||||
)?.ptyId
|
||||
const hasSleepingAgentSession = Boolean(session.getSleepingRecordForPane(storeSnapshot))
|
||||
// Why: the legacy-worker fence forbids resuming this pane's provider session, so its sleeping
|
||||
// record must not steer the pane into any cold-restore or wake branch. It still reattaches.
|
||||
const hasResumableSleepingAgentSession =
|
||||
Boolean(session.getSleepingRecordForPane(storeSnapshot)) &&
|
||||
!session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
|
||||
// Why: the tab-level fallback must not steal a PTY a setup sibling already published while the main pane waited for split geometry.
|
||||
const tabFallbackPtyId =
|
||||
@@ -41,7 +45,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
|
||||
const restoredSessionId = restoredPtyId ?? null
|
||||
const sleptRemoteRuntimeSessionId =
|
||||
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasSleepingAgentSession
|
||||
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && hasResumableSleepingAgentSession
|
||||
? restoredSessionId
|
||||
: null
|
||||
const detachedLivePtyId =
|
||||
@@ -53,7 +57,9 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
: tabFallbackPtyId
|
||||
: null
|
||||
const detachedRemoteLeafPtyId =
|
||||
restoredSessionId && isRemoteRuntimePtyId(restoredSessionId) && !hasSleepingAgentSession
|
||||
restoredSessionId &&
|
||||
isRemoteRuntimePtyId(restoredSessionId) &&
|
||||
!hasResumableSleepingAgentSession
|
||||
? restoredSessionId
|
||||
: null
|
||||
const candidateReattachSessionId =
|
||||
@@ -87,11 +93,6 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
currentTabLivePtyIds.includes(candidateReattachSessionId)
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
// Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree.
|
||||
// Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh.
|
||||
const legacyAttachOnlyPtyId = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const pairedParkedReattachSessionId =
|
||||
session.mountFollowsTerminalPark &&
|
||||
candidateReattachSessionId &&
|
||||
@@ -99,64 +100,51 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
canRestorePairedParkedTerminal(candidateReattachSessionId)
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const deferredReattachSessionId = legacyAttachOnlyPtyId
|
||||
? null
|
||||
: (runtimeHostPtyWakeHint ??
|
||||
pairedParkedReattachSessionId ??
|
||||
(candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
!candidateHasEagerBuffer &&
|
||||
isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId)
|
||||
? candidateReattachSessionId
|
||||
: null))
|
||||
const deferredReattachSessionId =
|
||||
runtimeHostPtyWakeHint ??
|
||||
pairedParkedReattachSessionId ??
|
||||
(candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
!candidateHasEagerBuffer &&
|
||||
isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId)
|
||||
? candidateReattachSessionId
|
||||
: null)
|
||||
recordPtyConnectDiagnostic(
|
||||
`pane=${session.pane.id} tab=${session.deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${session.hadExistingPaneTransportAtConnect} pendingKey=${session.pendingSpawnKey}`
|
||||
)
|
||||
|
||||
if (deferredReattachSessionId) {
|
||||
startDeferredSessionReattach(session, deferredReattachSessionId)
|
||||
} else if (
|
||||
legacyAttachOnlyPtyId ||
|
||||
detachedRemoteLeafPtyId ||
|
||||
detachedLivePtyId ||
|
||||
eagerLivePtyId
|
||||
) {
|
||||
} else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) {
|
||||
// Why: mirrored web-leaf panes must attach to their exact remote PTY, not spawn a replacement host tab.
|
||||
// eagerLivePtyId covers a still-live background PTY (e.g. an automation agent) with a live eager buffer to adopt.
|
||||
const attachPtyId =
|
||||
legacyAttachOnlyPtyId ?? detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId!
|
||||
const attachPtyId = detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId!
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> ATTACH detached=${attachPtyId}`)
|
||||
session.allowInitialIdleCacheSeed = false
|
||||
if (legacyAttachOnlyPtyId) {
|
||||
if (session.attachRetainedLegacyPty(legacyAttachOnlyPtyId) && session.connectionId) {
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
}
|
||||
} else {
|
||||
// Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface.
|
||||
// On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here.
|
||||
try {
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: attachPtyId,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? attachPtyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
session.deps.clearTabPtyId(session.deps.tabId, attachPtyId)
|
||||
session.startFreshSpawn()
|
||||
// Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface.
|
||||
// On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here.
|
||||
try {
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: attachPtyId,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? attachPtyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
session.deps.clearTabPtyId(session.deps.tabId, attachPtyId)
|
||||
session.startFreshSpawn()
|
||||
}
|
||||
} else {
|
||||
session.allowInitialIdleCacheSeed = false
|
||||
@@ -187,7 +175,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
`Pending PTY spawn for tab ${session.deps.tabId} resolved without a PTY id, retrying fresh spawn`
|
||||
)
|
||||
}
|
||||
if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) {
|
||||
if (sleptRemoteColdRestoreStartup || hasResumableSleepingAgentSession) {
|
||||
session.startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined)
|
||||
} else {
|
||||
session.startFreshSpawn()
|
||||
@@ -218,7 +206,7 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
})
|
||||
} else {
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> FRESH SPAWN`)
|
||||
if (sleptRemoteColdRestoreStartup || hasSleepingAgentSession) {
|
||||
if (sleptRemoteColdRestoreStartup || hasResumableSleepingAgentSession) {
|
||||
session.startFreshColdRestoreAgentResume(sleptRemoteColdRestoreStartup ?? undefined)
|
||||
} else {
|
||||
session.startFreshSpawn()
|
||||
|
||||
+6
-3
@@ -2,7 +2,10 @@ import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics'
|
||||
import { isSshSessionGoneError, recordPtyConnectDiagnostic } from './pty-connect-limits'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { toProcessExitStartup } from './process-exit-startup'
|
||||
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
|
||||
import {
|
||||
mayRetireBindingAfterFailedReattach,
|
||||
recoverUnverifiableReattach
|
||||
} from './unverifiable-reattach-recovery'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
const PANE_OWNER_UNVERIFIED_ERROR = 'terminal_pane_owner_unverified'
|
||||
@@ -173,8 +176,8 @@ export function startDeferredSessionReattach(
|
||||
return
|
||||
}
|
||||
session.reportError(message)
|
||||
if (session.connectionId) {
|
||||
recoverUnverifiableDirectSshReattach(session, deferredReattachSessionId)
|
||||
if (!mayRetireBindingAfterFailedReattach(session)) {
|
||||
recoverUnverifiableReattach(session, deferredReattachSessionId)
|
||||
return
|
||||
}
|
||||
session.clearExitedPanePtyLayoutBinding(deferredReattachSessionId)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function mayStartFreshPaneSession(session: ConnectPanePtySession): boolean {
|
||||
return (
|
||||
!session.isLegacyWorkerAutomaticResumeBlocked() &&
|
||||
!useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mayStartFreshPaneSession } from './fresh-spawn-eligibility'
|
||||
import { useAppStore } from '@/store'
|
||||
import { hasPtySerializer } from '../pty-buffer-serializer'
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
@@ -34,15 +35,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
releaseDeferredCwdFence()
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting) {
|
||||
// Why: the worktree is being deleted; its PTYs were just killed for the
|
||||
// filesystem teardown. A fresh shell must not spawn into a directory the
|
||||
// removal is about to delete (main fences it anyway), and the pane is
|
||||
// about to unmount — so skip the doomed respawn instead of racing it.
|
||||
if (!mayStartFreshPaneSession(session)) {
|
||||
releaseDeferredCwdFence()
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import type { ReattachPayloadContext } from './reattach-payload-context'
|
||||
import { createReattachPayloadHandlers } from './apply-reattach-payload'
|
||||
import type { ReattachPayloadSession } from './reattach-payload-session'
|
||||
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
|
||||
import {
|
||||
mayRetireBindingAfterFailedReattach,
|
||||
recoverUnverifiableReattach
|
||||
} from './unverifiable-reattach-recovery'
|
||||
|
||||
type ReattachResultSession = ReattachPayloadSession &
|
||||
Pick<
|
||||
@@ -91,7 +94,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
|
||||
}
|
||||
|
||||
if (connectResult?.exitedBeforeAttach) {
|
||||
// Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach.
|
||||
// Preserve the historical pane when its owner observed exit before attachment.
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -105,7 +108,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
|
||||
const ptyId =
|
||||
connectResult?.id ?? (typeof result === 'string' ? result : session.transport.getPtyId())
|
||||
const hasExplicitPtyId = Boolean(connectResult?.id || typeof result === 'string')
|
||||
if (!ptyId) {
|
||||
if (!ptyId || connectResult?.reattachUnverifiable) {
|
||||
warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', {
|
||||
tabId: session.deps.tabId,
|
||||
worktreeId: session.deps.worktreeId,
|
||||
@@ -113,19 +116,16 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
|
||||
paneId: session.pane.id,
|
||||
ptyId: staleSessionId ?? null
|
||||
})
|
||||
if (session.connectionId) {
|
||||
recoverUnverifiableDirectSshReattach(sessionBag, staleSessionId)
|
||||
if (connectResult?.reattachUnverifiable || !mayRetireBindingAfterFailedReattach(sessionBag)) {
|
||||
recoverUnverifiableReattach(sessionBag, retryPtyId)
|
||||
return false
|
||||
}
|
||||
// Why: a stale restored session can fail reattach after mount; don't leave xterm alive without a backing PTY.
|
||||
if (staleSessionId) {
|
||||
session.clearExitedPanePtyLayoutBinding(staleSessionId)
|
||||
session.deps.clearTabPtyId(session.deps.tabId, staleSessionId)
|
||||
} else {
|
||||
session.syncPanePtyLayoutBinding(null)
|
||||
}
|
||||
if (staleSessionId) {
|
||||
session.deps.clearTabPtyId(session.deps.tabId, staleSessionId)
|
||||
}
|
||||
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bindHandleReattachResult } from './reattach-result-handler'
|
||||
import { startDeferredSessionReattach } from './deferred-session-reattach-connect'
|
||||
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
|
||||
|
||||
vi.mock('../terminal-pane-recovery', () => ({ requestTerminalPaneRecovery: vi.fn() }))
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() }))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
|
||||
function createSession() {
|
||||
const transport = { getPtyId: () => null, connect: vi.fn() }
|
||||
return {
|
||||
transport,
|
||||
pane: { id: 1 },
|
||||
deps: {
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
paneTransportsRef: { current: new Map([[1, transport]]) },
|
||||
clearTabPtyId: vi.fn()
|
||||
},
|
||||
terminalRecoveryInstance: { id: 1 },
|
||||
terminalRecoveryGeneration: 1,
|
||||
transportStreamGeneration: 1,
|
||||
authoritativeReattachGeneration: 0,
|
||||
isLegacyWorkerAutomaticResumeBlocked: () => false,
|
||||
rejectObsoleteDirectSshReattach: () => false,
|
||||
clearExitedPanePtyLayoutBinding: vi.fn(),
|
||||
syncPanePtyLayoutBinding: vi.fn(),
|
||||
startFreshColdRestoreAgentResume: vi.fn(),
|
||||
handleReattachResult: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('unverifiable reattach on every host', () => {
|
||||
it.each([undefined, { id: 'retained-pty', reattachUnverifiable: true }])(
|
||||
'preserves local ownership for %j',
|
||||
async (result) => {
|
||||
vi.clearAllMocks()
|
||||
const session = createSession()
|
||||
session.isLegacyWorkerAutomaticResumeBlocked = () => !result
|
||||
bindHandleReattachResult(session as never)
|
||||
expect(await session.handleReattachResult(result, 'retained-pty')).toBe(false)
|
||||
expect(session.deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(session.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
|
||||
expect(session.startFreshColdRestoreAgentResume).not.toHaveBeenCalled()
|
||||
expect(requestTerminalPaneRecovery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ptyId: 'retained-pty', reason: 'reattach-unverifiable' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves local ownership when connect throws', async () => {
|
||||
vi.clearAllMocks()
|
||||
const base = createSession()
|
||||
base.transport.connect.mockRejectedValue(new Error('daemon unavailable'))
|
||||
const session = {
|
||||
...base,
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
prepaintParkedSshSnapshot: vi.fn(),
|
||||
buildColdRestoreAgentResumeStartup: () => null,
|
||||
captureTransportOutputCallbacks: () => ({ generation: 1, callbacks: {} }),
|
||||
beginReattachLiveDataDeferral: vi.fn(),
|
||||
finishReattachLiveDataDeferral: vi.fn(),
|
||||
shouldDeclareHiddenAtSpawn: () => false,
|
||||
reportError: vi.fn(),
|
||||
armDirectSshPaneRetryTimeout: vi.fn()
|
||||
}
|
||||
startDeferredSessionReattach(session as never, 'retained-pty')
|
||||
await session.armDirectSshPaneRetryTimeout.mock.calls[0][0]
|
||||
expect(session.deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(session.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
|
||||
expect(session.startFreshColdRestoreAgentResume).not.toHaveBeenCalled()
|
||||
expect(requestTerminalPaneRecovery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ptyId: 'retained-pty', reason: 'reattach-unverifiable' })
|
||||
)
|
||||
})
|
||||
})
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindAttachRetainedLegacyPty(session: ConnectPanePtySession): void {
|
||||
session.attachRetainedLegacyPty = (ptyId: string): boolean => {
|
||||
try {
|
||||
session.authoritativeReattachGeneration += 1
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: ptyId,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? ptyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { bindPrepaintParkedSshSnapshot } from './ssh-snapshot-prepaint'
|
||||
import { bindForegroundOutputRefresh } from './foreground-output-refresh'
|
||||
import { bindRegisterPaneSerializer } from './pane-serializer-register'
|
||||
import { bindHandleReattachResult } from './reattach-result-handler'
|
||||
import { bindAttachRetainedLegacyPty } from './retained-legacy-pty-attach'
|
||||
import { runDeferredSessionAttach } from './deferred-session-attach'
|
||||
|
||||
import { bindSerializeHiddenOutputSnapshot } from './hidden-output-snapshot-serialize'
|
||||
@@ -154,7 +153,6 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void
|
||||
|
||||
bindPrepaintParkedSshSnapshot(session)
|
||||
bindHandleReattachResult(session)
|
||||
bindAttachRetainedLegacyPty(session)
|
||||
runDeferredSessionAttach(session)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user