mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
* fix(runtime): persist acknowledged terminal tab retirement
* test(runtime): drain tab retirement fixture writes before teardown
* fix(runtime): explain a refused workspace terminal close
The Sleep-workspace path threw the raw refusal enum ("stale-terminal") as an
Error message, which reaches a CLI user verbatim and a Sleep toast via
describeSleepFailure. Map each refusal reason to a sentence instead.
Also pins two behaviours that had no coverage: the user-visible outcome of a
republished stale-terminal refusal on the web client (the caller cannot tell it
from a real close), and the one-call-per-close invariant that keeps a successor
terminal alive.
The bounded close retry was NOT implemented: notifier.closeTerminalTab carries
only a tab id, so a second call destroys whatever successor took that id.
* test(runtime): build refusal fixtures without type assertions
The changed-code quality gate rejects new `as` casts. Replace the
branded-outcome cast with refusedMobileSessionTabClose, and model the
wire-skew reason as a decoded host answer instead of `as never`.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
185 lines
7.5 KiB
Diff
185 lines
7.5 KiB
Diff
--- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts
|
|
+++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts
|
|
@@ -20,6 +20,7 @@
|
|
import type { RuntimeCommandSurfaceHost } from './orca-runtime-core'
|
|
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
|
|
import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close'
|
|
+import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity'
|
|
|
|
export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose {
|
|
async closeMobileSessionTab(
|
|
@@ -177,8 +178,18 @@
|
|
return finishCommittedClose()
|
|
}
|
|
if (closingWholeParent && this.notifier?.closeTerminalTab) {
|
|
- // Why: whole-tab close is a lifecycle transaction. The renderer reply
|
|
- // arrives only after canonical retirement and a forced session flush.
|
|
+ // The renderer flush can rebase its omission; the host commits the acknowledged identity.
|
|
+ const acknowledgeRetirement = captureAcknowledgedTerminalTabRetirement(
|
|
+ worktreeId,
|
|
+ tab.parentTabId,
|
|
+ () => ({
|
|
+ hostId: this.getWorkspaceSessionHostIdForWorktree(worktreeId),
|
|
+ session: this.getWorkspaceSessionForWorktree(worktreeId),
|
|
+ snapshot: this.mobileSessionTabsByWorktree.get(worktreeId),
|
|
+ incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId
|
|
+ })
|
|
+ )
|
|
+ // Wait for the renderer's pin guard, retirement and forced session flush.
|
|
const win = this.getAvailableAuthoritativeWindow()
|
|
if (win?.webContents.isDestroyed?.()) {
|
|
throw new Error('runtime_unavailable')
|
|
@@ -200,6 +211,11 @@
|
|
releasePublicationThrottle()
|
|
}
|
|
const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
|
+ const retirement = acknowledgeRetirement()
|
|
+ if (!retirement.matches) {
|
|
+ this.republishMobileSessionTabsSnapshot(worktreeId)
|
|
+ return refusedMobileSessionTabClose('stale-terminal', { snapshotRepublished: true })
|
|
+ }
|
|
const remainingTab = remainingSnapshot?.tabs.find(
|
|
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
|
candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
|
|
@@ -220,6 +236,10 @@
|
|
...(remainingPtyCloseAuthority ? { authorizedPty: remainingPtyCloseAuthority.pty } : {})
|
|
})
|
|
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
|
+ } else if (retirement.hasPersistedTab) {
|
|
+ this.commitHeadlessTerminalTabRetirement(worktreeId, tab.parentTabId, {
|
|
+ force: options.force
|
|
+ })
|
|
}
|
|
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId)
|
|
return finishCommittedClose()
|
|
--- /dev/null
|
|
+++ b/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts
|
|
@@ -0,0 +1,127 @@
|
|
+import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
|
+import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
|
+import { collectPersistedTerminalLeafIds } from './mobile-session-layout-projection'
|
|
+
|
|
+type PersistedTerminalTabIdentity = {
|
|
+ createdAt: number
|
|
+ generation: number
|
|
+ ptyId: string | null
|
|
+ remoteSessionId: string | null
|
|
+ leaves: Map<string, { ptyId: string | null; incarnationId: string | null }>
|
|
+}
|
|
+function capturePersistedTerminalTabRetirementIdentity(
|
|
+ session: WorkspaceSessionState | null,
|
|
+ worktreeId: string,
|
|
+ tabId: string
|
|
+): PersistedTerminalTabIdentity | null {
|
|
+ const row = session?.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId)
|
|
+ if (!row || !session) {
|
|
+ return null
|
|
+ }
|
|
+ const layout = session.terminalLayoutsByTabId[tabId]
|
|
+ return {
|
|
+ createdAt: row.createdAt,
|
|
+ generation: row.generation ?? 0,
|
|
+ ptyId: row.ptyId,
|
|
+ remoteSessionId: session.remoteSessionIdsByTabId?.[tabId] ?? null,
|
|
+ leaves: new Map(
|
|
+ collectPersistedTerminalLeafIds(layout).map((leafId) => [
|
|
+ leafId,
|
|
+ {
|
|
+ ptyId: layout?.ptyIdsByLeafId?.[leafId] ?? null,
|
|
+ incarnationId: session.terminalPtyIncarnationsByPaneKey?.[`${tabId}:${leafId}`] ?? null
|
|
+ }
|
|
+ ])
|
|
+ )
|
|
+ }
|
|
+}
|
|
+function isRetainedTerminalTabRetirementIdentity(
|
|
+ captured: PersistedTerminalTabIdentity | null,
|
|
+ current: PersistedTerminalTabIdentity | null
|
|
+): boolean {
|
|
+ if (!current) {
|
|
+ return true
|
|
+ }
|
|
+ if (
|
|
+ !captured ||
|
|
+ captured.createdAt !== current.createdAt ||
|
|
+ captured.generation !== current.generation ||
|
|
+ (current.ptyId !== null &&
|
|
+ current.ptyId !== captured.ptyId &&
|
|
+ ![...captured.leaves.values()].some((leaf) => leaf.ptyId === current.ptyId)) ||
|
|
+ (current.remoteSessionId !== null &&
|
|
+ current.remoteSessionId !== captured.remoteSessionId &&
|
|
+ ![...captured.leaves.values()].some((leaf) => leaf.ptyId === current.remoteSessionId))
|
|
+ ) {
|
|
+ return false
|
|
+ }
|
|
+ // Physical exits may retire original leaves while the renderer close is awaiting its flush.
|
|
+ return [...current.leaves].every(([leafId, binding]) => {
|
|
+ const previous = captured.leaves.get(leafId)
|
|
+ return (
|
|
+ previous !== undefined &&
|
|
+ (binding.ptyId === null || binding.ptyId === previous.ptyId) &&
|
|
+ (binding.incarnationId === null || binding.incarnationId === previous.incarnationId)
|
|
+ )
|
|
+ })
|
|
+}
|
|
+
|
|
+type TerminalTabRetirementState = {
|
|
+ hostId: string
|
|
+ session: WorkspaceSessionState | null
|
|
+ snapshot: RuntimeMobileSessionTabsSnapshot | undefined
|
|
+ incarnationOf: (ptyId: string) => string | null | undefined
|
|
+}
|
|
+
|
|
+export function captureAcknowledgedTerminalTabRetirement(
|
|
+ worktreeId: string,
|
|
+ tabId: string,
|
|
+ readState: () => TerminalTabRetirementState
|
|
+): () => { matches: boolean; hasPersistedTab: boolean } {
|
|
+ const { hostId, session, snapshot, incarnationOf } = readState()
|
|
+ const identity = capturePersistedTerminalTabRetirementIdentity(session, worktreeId, tabId)
|
|
+ const surfaces = new Map(
|
|
+ snapshot?.tabs.flatMap((tab) =>
|
|
+ tab.type === 'terminal' && tab.parentTabId === tabId
|
|
+ ? [[tab.leafId, tab.ptyId ?? null] as const]
|
|
+ : []
|
|
+ )
|
|
+ )
|
|
+ const ptyIds = new Set([
|
|
+ identity?.ptyId,
|
|
+ identity?.remoteSessionId,
|
|
+ ...[...(identity?.leaves.values() ?? [])].map((leaf) => leaf.ptyId),
|
|
+ ...surfaces.values(),
|
|
+ ...(snapshot?.tabs.flatMap((tab) =>
|
|
+ tab.type === 'terminal' && tab.parentTabId === tabId
|
|
+ ? Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})
|
|
+ : []
|
|
+ ) ?? [])
|
|
+ ])
|
|
+ const incarnations = new Map(
|
|
+ [...ptyIds].flatMap((ptyId) => (ptyId ? [[ptyId, incarnationOf(ptyId) ?? null] as const] : []))
|
|
+ )
|
|
+ return () => {
|
|
+ const current = readState()
|
|
+ const remaining = capturePersistedTerminalTabRetirementIdentity(
|
|
+ current.session,
|
|
+ worktreeId,
|
|
+ tabId
|
|
+ )
|
|
+ const matches =
|
|
+ current.hostId === hostId &&
|
|
+ isRetainedTerminalTabRetirementIdentity(identity, remaining) &&
|
|
+ [...incarnations].every(([ptyId, incarnationId]) => {
|
|
+ const next = current.incarnationOf(ptyId)
|
|
+ return next === undefined || next === incarnationId
|
|
+ }) &&
|
|
+ !current.snapshot?.tabs.some(
|
|
+ (tab) =>
|
|
+ tab.type === 'terminal' &&
|
|
+ tab.parentTabId === tabId &&
|
|
+ (!surfaces.has(tab.leafId) ||
|
|
+ (tab.ptyId != null && surfaces.get(tab.leafId) !== tab.ptyId))
|
|
+ )
|
|
+ return { matches, hasPersistedTab: remaining !== null }
|
|
+ }
|
|
+}
|