mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
fix(runtime): publish a terminal retirement proof on the exit's own evidence
A paired client may drop a mirrored terminal on exactly two kinds of host evidence: a `retiredTerminalSurfaces` proof naming the handle, or two authoritative `terminal.list` inventories that omit it. The second needs two host publications, and a quiet workspace publishes one, so the proof is the only evidence that rides the frame carrying the retraction. That proof was minted only as a byproduct of persistence *accepting a change*, which made one value carry two meanings: "a change was accepted" and "the PTY exited". The host renderer's close transaction de-persists the surface and republishes without it, so when it got there first the exit found nothing left to accept and the attestation died with it. Measured on a real paired client: the host retracted in under 500ms, published no proof, then froze its snapshotVersion for 60s while the client kept a dead pane in its tab bar. Persistence still gates *removal* — publishing absence before the membership fence is durable would let a crash resurrect the surface. It no longer gates the proof: the observed exit is itself the attestation. The exit-first ordering already had a passing test; the renderer-first ordering had none, and that is the one users hit. Both orderings are now pinned, with exit-first as the control that makes the renderer-first failures mean something. Wire: `retiredTerminalSurfaces` is an existing optional field on an existing path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is Rule 1 — an old client that ignores it degrades to the two-inventory route it already uses today, so no capability gate is needed. The sentence "the host starts sending a frame it did not send before" reads like Rule 3; it is not, because the frame shape, the field, and the reader contract are all unchanged.
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
||||
import type {
|
||||
RuntimeMobileSessionRetiredTerminalSurface,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
import {
|
||||
appendRetiredTerminalSurfaceProofs,
|
||||
dropRetirementProofsForLiveSurfaces
|
||||
dropRetirementProofsForLiveSurfaces,
|
||||
retirementProofKey
|
||||
} from '../../shared/terminal-retirement-proof-ledger'
|
||||
|
||||
export {
|
||||
@@ -48,3 +52,46 @@ export function preserveTerminalRetirementProofs(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches durable retirement proofs to a stored snapshot, bumping its version so clients that
|
||||
* gate on a strictly newer `snapshotVersion` accept the frame. Returns null when the snapshot
|
||||
* already carries exactly these proofs, so a no-op cannot fan out.
|
||||
*
|
||||
* Separate from `retireTerminalSurfacesFromSnapshot`: that one only produces a proof as a
|
||||
* byproduct of removing the surface, and by the time a close's durable half runs the surface may
|
||||
* already be gone from the snapshot. The proof still has to ship — it is the only host evidence
|
||||
* that rides the frame carrying the retraction.
|
||||
*/
|
||||
export function attachRetirementProofsToSnapshot(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
proofs: readonly RuntimeMobileSessionRetiredTerminalSurface[]
|
||||
): RuntimeMobileSessionTabsSnapshot | null {
|
||||
if (proofs.length === 0) {
|
||||
return null
|
||||
}
|
||||
const merged = appendRetiredTerminalSurfaceProofs(snapshot.retiredTerminalSurfaces, proofs)
|
||||
const existing = snapshot.retiredTerminalSurfaces
|
||||
// Why by value, not identity: the append rebuilds every re-supplied proof, so an identity
|
||||
// check would call a re-delivered exit a change and fan out a version bump carrying nothing.
|
||||
const unchanged =
|
||||
existing !== undefined &&
|
||||
merged.length === existing.length &&
|
||||
merged.every((proof, index) => {
|
||||
const prior = existing[index]
|
||||
return (
|
||||
prior !== undefined &&
|
||||
retirementProofKey(proof) === retirementProofKey(prior) &&
|
||||
proof.ptyId === prior.ptyId &&
|
||||
proof.incarnationId === prior.incarnationId
|
||||
)
|
||||
})
|
||||
if (unchanged) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
retiredTerminalSurfaces: merged
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { OrcaRuntimeWithTouchMobileSessionTabsForWorktree } from './orca-runtime-touch-mobile-session-tabs-for-worktree'
|
||||
import type { RetiredTerminalSurface } from './mobile-session-terminal-retirement'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { RuntimeMobileSessionRetiredTerminalSurface } from '../../shared/runtime-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement'
|
||||
import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement'
|
||||
import { attachRetirementProofsToSnapshot } from './mobile-session-terminal-retirement-proof'
|
||||
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
|
||||
@@ -145,37 +147,63 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim
|
||||
)
|
||||
}
|
||||
// Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear.
|
||||
const publishableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted]
|
||||
if (publishableRetiredSurfaces.length === 0) {
|
||||
return
|
||||
}
|
||||
const removableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted]
|
||||
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
|
||||
const retired = retireTerminalSurfacesFromSnapshot({
|
||||
snapshot,
|
||||
ptyId,
|
||||
exactSurfaces: publishableRetiredSurfaces.filter(
|
||||
(surface) => surface.worktreeId === worktreeId
|
||||
),
|
||||
// Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted.
|
||||
exactOnly: true,
|
||||
...(terminalHandle
|
||||
? {
|
||||
retirementProofs: publishableRetiredSurfaces
|
||||
.filter((surface) => surface.worktreeId === worktreeId)
|
||||
.map((surface) => ({
|
||||
parentTabId: surface.parentTabId,
|
||||
leafId: surface.leafId,
|
||||
ptyId: surface.ptyId,
|
||||
terminal: terminalHandle,
|
||||
incarnationId
|
||||
}))
|
||||
}
|
||||
: {})
|
||||
})
|
||||
// Why proofs are not gated on `removable`: the observed exit is itself the attestation that
|
||||
// this surface is retired. Persistence gates *removal* — publishing absence before the
|
||||
// membership fence is durable would let a crash resurrect the surface — but a surface the
|
||||
// renderer's own close transaction already de-persisted and dropped leaves persistence with
|
||||
// nothing to accept, and gating the proof on that acceptance withheld the one piece of host
|
||||
// evidence a paired mirror can act on. Its only other route needs two authoritative
|
||||
// inventories, and a quiet workspace publishes one frame, so the pane stayed forever.
|
||||
const retirementProofs = terminalHandle
|
||||
? retiredSurfaces
|
||||
.filter((surface) => surface.worktreeId === worktreeId)
|
||||
.map((surface) => ({
|
||||
parentTabId: surface.parentTabId,
|
||||
leafId: surface.leafId,
|
||||
ptyId: surface.ptyId,
|
||||
terminal: terminalHandle,
|
||||
incarnationId
|
||||
}))
|
||||
: []
|
||||
const removableSurfaces = removableRetiredSurfaces.filter(
|
||||
(surface) => surface.worktreeId === worktreeId
|
||||
)
|
||||
const retired =
|
||||
removableSurfaces.length > 0
|
||||
? retireTerminalSurfacesFromSnapshot({
|
||||
snapshot,
|
||||
ptyId,
|
||||
exactSurfaces: removableSurfaces,
|
||||
// Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted.
|
||||
exactOnly: true,
|
||||
...(retirementProofs.length > 0 ? { retirementProofs } : {})
|
||||
})
|
||||
: null
|
||||
if (retired) {
|
||||
this.storeMobileSessionSnapshot(worktreeId, retired.snapshot)
|
||||
this.notifyMobileSessionTabsChanged(worktreeId)
|
||||
continue
|
||||
}
|
||||
this.publishRetiredTerminalSurfaceProofs(worktreeId, retirementProofs)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ships durable retirement proofs on their own frame when no surface removal carries them. */
|
||||
protected publishRetiredTerminalSurfaceProofs(
|
||||
worktreeId: string,
|
||||
proofs: readonly RuntimeMobileSessionRetiredTerminalSurface[]
|
||||
): void {
|
||||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
const next = attachRetirementProofsToSnapshot(snapshot, proofs)
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
this.storeMobileSessionSnapshot(worktreeId, next)
|
||||
this.notifyMobileSessionTabsChanged(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeMobileSessionTabsSnapshot
|
||||
} from '../../shared/runtime-types'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
/**
|
||||
* A paired client may only drop a mirrored terminal on host evidence: a `retiredTerminalSurfaces`
|
||||
* proof naming the handle, or two authoritative `terminal.list` inventories that omit it. The
|
||||
* second needs two host publications, and a quiet workspace produces one — so the proof is the
|
||||
* only evidence that rides the frame carrying the retraction, and it has to be published whichever
|
||||
* order the close's two halves (renderer republication, PTY exit) land in.
|
||||
*/
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const LIVE_REPO = {
|
||||
id: 'repo',
|
||||
path: '/worktree',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
} as const
|
||||
|
||||
function makeSnapshot(): RuntimeMobileSessionTabsSnapshot {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'renderer',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: `tab::${LEAF_ID}`,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `tab::${LEAF_ID}`,
|
||||
parentTabId: 'tab',
|
||||
leafId: LEAF_ID,
|
||||
ptyId: 'pty-left',
|
||||
title: 'Left',
|
||||
parentLayout: {
|
||||
root: { type: 'leaf' as const, leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-left' }
|
||||
},
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function makePersistedSession(): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: 'tab',
|
||||
ptyId: 'pty-left',
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
tab: {
|
||||
root: { type: 'leaf' as const, leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-left' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createHost(): {
|
||||
runtime: OrcaRuntimeService
|
||||
handle: string
|
||||
retirePersistedSurface: () => void
|
||||
} {
|
||||
let session = makePersistedSession()
|
||||
const runtime = new OrcaRuntimeService({
|
||||
getRepos: () => [LIVE_REPO],
|
||||
getWorkspaceSession: () => session,
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
},
|
||||
flushOrThrow: vi.fn()
|
||||
} as never)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab',
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: { type: 'leaf', leafId: LEAF_ID }
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab',
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-left'
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [makeSnapshot()]
|
||||
})
|
||||
runtime.registerPty('pty-left', WORKTREE_ID, null, {
|
||||
tabId: 'tab',
|
||||
leafId: LEAF_ID,
|
||||
incarnationId: 'incarnation-a'
|
||||
})
|
||||
// The mirror binds panes by terminal handle, so the handle has to exist before the close.
|
||||
const handle = runtime.preAllocateHandleForPty('pty-left')
|
||||
runtime.registerPreAllocatedHandleForPty('pty-left', handle)
|
||||
return {
|
||||
runtime,
|
||||
handle,
|
||||
// The renderer's close transaction de-persists the tab and flushes before it republishes.
|
||||
retirePersistedSurface: () => {
|
||||
session = { ...session, tabsByWorktree: {}, terminalLayoutsByTabId: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the host renderer publishes once it has retired the tab it was told to close. */
|
||||
function republishWithoutTheSurface(runtime: OrcaRuntimeService): void {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'renderer',
|
||||
snapshotVersion: 5,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
describe('retirement proof publication vs. renderer republication order', () => {
|
||||
it('publishes the proof when the exit lands before the renderer drops the surface', async () => {
|
||||
const { runtime, handle } = createHost()
|
||||
|
||||
runtime.onPtyExit('pty-left', 0, 'incarnation-a')
|
||||
republishWithoutTheSurface(runtime)
|
||||
|
||||
const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
expect(published.tabs).toEqual([])
|
||||
expect(published.retiredTerminalSurfaces).toEqual([
|
||||
expect.objectContaining({ parentTabId: 'tab', leafId: LEAF_ID, terminal: handle })
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes the proof when the renderer drops the surface before the exit lands', async () => {
|
||||
const { runtime, handle, retirePersistedSurface } = createHost()
|
||||
|
||||
retirePersistedSurface()
|
||||
republishWithoutTheSurface(runtime)
|
||||
runtime.onPtyExit('pty-left', 0, 'incarnation-a')
|
||||
|
||||
const published = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
|
||||
expect(published.tabs).toEqual([])
|
||||
expect(published.retiredTerminalSurfaces).toEqual([
|
||||
expect.objectContaining({ parentTabId: 'tab', leafId: LEAF_ID, terminal: handle })
|
||||
])
|
||||
})
|
||||
|
||||
// Why a subscriber and not just the stored snapshot: a mirror only ever sees frames. A proof
|
||||
// that lands in state without a frame to carry it is the same silence from the client's side.
|
||||
it('fans the proof out to a paired subscriber, not just into stored state', () => {
|
||||
const { runtime, handle, retirePersistedSurface } = createHost()
|
||||
const frames: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged(
|
||||
(frame) => frames.push(frame),
|
||||
'paired-client'
|
||||
)
|
||||
|
||||
try {
|
||||
retirePersistedSurface()
|
||||
republishWithoutTheSurface(runtime)
|
||||
runtime.onPtyExit('pty-left', 0, 'incarnation-a')
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
expect(
|
||||
frames.some((frame) =>
|
||||
frame.retiredTerminalSurfaces?.some(
|
||||
(proof) =>
|
||||
proof.terminal === handle && proof.parentTabId === 'tab' && proof.leafId === LEAF_ID
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user