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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions config/reliability-gates.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,78 @@
}
},
"gates": [
{
"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."
},
{
"id": "terminal-performance.padded-fullscreen-redraw",
"title": "Fullscreen redraw padding does not stall terminal delivery",
Expand Down
9 changes: 5 additions & 4 deletions src/main/daemon/daemon-errors.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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'
}
}
Expand Down Expand Up @@ -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)
}
63 changes: 63 additions & 0 deletions src/main/daemon/daemon-pty-adapter-attach-only-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading