Revert "Merge #33720 (restart: attach surviving terminals) into integration"

This reverts commit d4b0c6c5f7, reversing
changes made to 53f9735bfe.
This commit is contained in:
Jinwoo-H
2026-10-28 17:44:43 -04:00
parent d6d48ba7c8
commit 2f945fa715
33 changed files with 150 additions and 496 deletions
@@ -623,7 +623,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
protocolVersion: GET_SIZE_PROTOCOL_VERSION - 1
})
try {
await expect(rig.adapter.attach(sessionId)).resolves.toMatchObject({ id: sessionId })
await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined()
expect(rig.request).not.toHaveBeenCalledWith('getSize', expect.anything())
expect(rig.request).toHaveBeenCalledWith('listSessions', undefined)
expect(rig.request).toHaveBeenCalledWith(
@@ -647,7 +647,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
getSizeError: new Error('Unknown request type: getSize')
})
try {
await expect(rig.adapter.attach(sessionId)).resolves.toMatchObject({ id: sessionId })
await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined()
expect(rig.request).toHaveBeenCalledWith('listSessions', undefined)
expect(rig.request).toHaveBeenCalledWith(
'createOrAttach',
@@ -142,7 +142,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSubprocess._simulateData('preserved output')
await new Promise((r) => setTimeout(r, 50))
await expect(adapter.attach(sessionId)).resolves.toMatchObject({
await expect(adapter.attach(sessionId)).resolves.toEqual({
providerSequence: {
value: 'preserved output'.length,
generation: 'continued'
@@ -367,13 +367,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
)
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
try {
const attached = await legacy.attach('legacy-session')
expect(attached).toMatchObject({
id: 'legacy-session',
isReattach: true,
incarnationId: 'legacy-attach-incarnation'
})
expect(attached.providerSequence).toBeUndefined()
await expect(legacy.attach('legacy-session')).resolves.toBeUndefined()
} finally {
legacy.dispose()
request.mockRestore()
+2 -5
View File
@@ -78,17 +78,14 @@ it('preserves client-only unverifiable inspection from the owning legacy daemon'
it('forwards the owning legacy daemon sequence from attach', async () => {
const legacy = createAdapter('legacy', ['legacy-session'])
const providerSequence = { value: 204, generation: 'continued' as const }
vi.mocked(legacy.attach).mockResolvedValueOnce({ id: 'legacy-session', providerSequence })
vi.mocked(legacy.attach).mockResolvedValueOnce({ providerSequence })
const router = new DaemonPtyRouter({
current: createAdapter('current'),
legacy: [legacy]
})
await router.discoverLegacySessions()
await expect(router.attach('legacy-session')).resolves.toEqual({
id: 'legacy-session',
providerSequence
})
await expect(router.attach('legacy-session')).resolves.toEqual({ providerSequence })
})
describe('DaemonPtyRouter', () => {
@@ -25,7 +25,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
deadlineMs?: number
): Promise<void>
async attach(id: string): Promise<PtySpawnResult> {
async attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence' | 'lastTitle'> | void> {
await this.ensureConnected()
if (!this.canDelegateBackgroundToDaemon) {
this.setPtyBackgrounded(id, false)
@@ -46,8 +46,13 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
if (result.exitedBeforeSpawnReply) {
throw new SessionNotFoundError(id)
}
// A restarted runtime has never seen the surviving session's screen or title.
return result
// A restarted runtime has never seen the surviving session's title.
return result.providerSequence || result.lastTitle
? {
...(result.providerSequence ? { providerSequence: result.providerSequence } : {}),
...(result.lastTitle ? { lastTitle: result.lastTitle } : {})
}
: undefined
}
hasPty(id: string): boolean {
@@ -323,7 +323,7 @@ describe('DegradedDaemonPtyProvider', () => {
it('forwards the owning daemon sequence from attach', async () => {
const legacy = createDaemonAdapter('legacy', ['daemon-session'])
const providerSequence = { value: 204, generation: 'continued' as const }
vi.mocked(legacy.attach).mockResolvedValueOnce({ id: 'daemon-session', providerSequence })
vi.mocked(legacy.attach).mockResolvedValueOnce({ providerSequence })
const provider = new DegradedDaemonPtyProvider({
current: createDaemonAdapter('current'),
legacy: [legacy],
@@ -331,10 +331,7 @@ describe('DegradedDaemonPtyProvider', () => {
})
await provider.discoverDaemonSessions()
await expect(provider.attach('daemon-session')).resolves.toEqual({
id: 'daemon-session',
providerSequence
})
await expect(provider.attach('daemon-session')).resolves.toEqual({ providerSequence })
})
it('only delegates owner-listing authority to the provider that owns the id', async () => {
@@ -10,11 +10,6 @@ export const visibleRendererPtys = new Set<string>()
export const rendererVisibilityKnownPtys = new Set<string>()
// Visible attaches suppress main-start hiding; remember it so failed attaches can hide the PTY again.
export const attachingRendererViewPtys = new Map<string, { views: number; skippedHide: boolean }>()
/** A visible view, or one mid-attach, owns delivery; hiding it would drop output it is about to paint. */
export function isRendererViewShowingPty(id: string): boolean {
return visibleRendererPtys.has(id) || attachingRendererViewPtys.has(id)
}
// Keep null-initialized wrapper bridges to avoid Rolldown no-op folding (STA-5661; see debug.ts).
let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null
let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null
@@ -39,9 +39,6 @@ export function seedTerminalRestoreRecordsFromSpawnResult(
...(lastTitle !== undefined ? { lastTitle } : {})
})
}
if (result.isReattach === true) {
runtime?.startReattachedOutputClock?.(result.id)
}
}
export function assertSpawnReplyWasLive(result: PtySpawnResult): void {
@@ -180,36 +180,17 @@ describe('registerPtyHandlers', () => {
0
)
})
it('seeds the screen and title of a session adopted by main attach', async () => {
it('seeds the title of a session adopted by subscriber-driven attach', async () => {
const daemonPtyId = 'repo-1::/tmp/wt@@title-handoff'
const localProvider = createAgentClaimProvider({})
localProvider.attach.mockResolvedValueOnce({
id: daemonPtyId,
snapshot: 'idle composer\r\n',
snapshotCols: 120,
snapshotRows: 40,
lastTitle: '⠋ Claude'
})
localProvider.attach.mockResolvedValueOnce({ lastTitle: '⠋ Claude' })
setLocalPtyProvider(localProvider as never)
const seedHeadlessTerminal = vi.fn()
const seedTerminalRestoreTail = vi.fn()
const controller = registerAgentClaimController({
seedHeadlessTerminal,
seedTerminalRestoreTail
})
const controller = registerAgentClaimController({ seedTerminalRestoreTail })
await expect(controller.attach(daemonPtyId)).resolves.toBe(true)
expect(seedHeadlessTerminal).toHaveBeenCalledWith(
daemonPtyId,
'idle composer\r\n',
{ cols: 120, rows: 40 },
{}
)
expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, {
text: 'idle composer\r\n',
lastTitle: '⠋ Claude'
})
expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, { lastTitle: '⠋ Claude' })
})
it('does not dispatch a runtime PTY spawn after its client disconnects', async () => {
const provider = createAgentClaimProvider({})
@@ -108,7 +108,7 @@ describe('PTY registration without renderer delivery', () => {
const local = installObservableDaemonTestProvider()
const localProvider = getLocalPtyProvider()
const localClear = vi.spyOn(localProvider, 'clearBuffer')
const attach = vi.spyOn(localProvider, 'attach').mockResolvedValue()
const attach = vi.spyOn(localProvider, 'attach').mockResolvedValue({})
const runtime = new OrcaRuntimeService()
const setController = vi.spyOn(runtime, 'setPtyController')
const remoteId = 'ssh:ssh-a@@remote-pty'
+3 -14
View File
@@ -3,11 +3,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type { IPtyProvider } from '../../../providers/pty-provider-contract'
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
import { ptyOwnership } from '../provider/ownership-state'
import { isRendererViewShowingPty, ptySizes } from '../delivery/visibility-state'
import { isHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate'
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { hideRuntimeOwnedRendererDelivery } from './spawn-hidden-delivery'
import { ptySizes } from '../delivery/visibility-state'
import { rendererSerializerReadiness } from '../pane/serializer-state'
import { getProviderForPty, localProvider, resolvePtyExecutionHost } from '../provider/registry'
import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection'
@@ -129,10 +125,6 @@ export async function attachPtyFromRuntimeController(
if (provider !== localProvider || provider instanceof LocalPtyProvider) {
return false
}
// A pane's own hidden claim stays its own; a reveal or exit clears main's.
if (!isRendererViewShowingPty(ptyId) && !isHiddenRendererPty(ptyId)) {
hideRuntimeOwnedRendererDelivery(deps, ptyId)
}
try {
const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0
const attachResult = await provider.attach(ptyId)
@@ -143,12 +135,9 @@ export async function attachPtyFromRuntimeController(
sequenceBeforeProviderAttach
)
}
if (attachResult) {
// A restarted runtime has never seen this screen; an idle agent may print nothing after attach.
seedHeadlessTerminalFromSpawnResult(deps.runtime, attachResult, null)
seedTerminalRestoreRecordsFromSpawnResult(deps.runtime, attachResult)
if (attachResult?.lastTitle) {
deps.runtime?.seedTerminalRestoreTail?.(ptyId, { lastTitle: attachResult.lastTitle })
}
deps.syncPtyBackgroundedDelivery?.(ptyId, 'main-attach')
return true
} catch {
return false
@@ -1,8 +1,7 @@
import { markRuntimeOwnedHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate'
import { attachingRendererViewPtys, isRendererViewShowingPty } from '../delivery/visibility-state'
import { attachingRendererViewPtys, visibleRendererPtys } from '../delivery/visibility-state'
import { closeStartupQueryAuthorityForPty } from '../provider/registry'
import type { RuntimePtySpawnState } from './spawn-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
// Background launches have no renderer to answer startup queries; main must own hidden-query replies.
@@ -43,7 +42,7 @@ export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): voi
if (attaching) {
attaching.skippedHide = true
}
if (isRendererViewShowingPty(id)) {
if (visibleRendererPtys.has(id) || attaching) {
releaseRuntimeSpawnPreSpawnHiddenMark(ctx)
ctx.deps.syncPtyBackgroundedDelivery?.(id, 'spawn')
return
@@ -66,15 +65,7 @@ export function releaseRuntimeSpawnPreSpawnHiddenMark(ctx: RuntimePtySpawnState)
}
function markRuntimeSpawnHidden(ctx: RuntimePtySpawnState, id: string): void {
hideRuntimeOwnedRendererDelivery(ctx.deps, id)
}
/** Main-held streams with no view drop renderer bytes until a pane reveals them. */
export function hideRuntimeOwnedRendererDelivery(
deps: Pick<PtyRuntimeControllerDeps, 'transitionSpawnHiddenRendererPtyDeliveryState'>,
id: string
): void {
const transition = deps.transitionSpawnHiddenRendererPtyDeliveryState
const transition = ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState
if (!transition) {
return
}
+1 -1
View File
@@ -136,7 +136,7 @@ export type IPtyProvider = {
killFenced?: (request: PtyFencedTerminationRequest) => Promise<PtyFencedTerminationOutcome>
/** Whether fresh structured creates can replay one spawn across a lost relay response. */
supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise<boolean>
attach(id: string): Promise<PtySpawnResult | void>
attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence' | 'lastTitle'> | void>
hasPty?: (id: string) => boolean
/** Exact provider readback: false only when the provider answered that the PTY is absent. */
probePtyLiveness?: (id: string) => Promise<boolean | null>
@@ -109,7 +109,6 @@ export abstract class RuntimeConstructionServices {
protected abstract readCaughtUpRuledScreen(ptyId: string): RuledScreen | null
protected abstract clearWaitBlockedStamp(ptyId: string): void
protected abstract readRuledScreen(ptyId: string | null | undefined): RuledScreen | null
protected abstract readOutputClockAt(ptyId: string | null | undefined): number | null
protected abstract headlessTerminals: Map<string, RuntimeHeadlessTerminal>
protected abstract readHeadlessVisibleTerminalState(
ptyId: string
@@ -342,7 +342,6 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic
getHookTurn: (ptyId, agent) => this.readTuiIdleHookTurnForPty(ptyId, agent),
readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId),
readRuledScreen: (ptyId) => this.readRuledScreen(ptyId),
getOutputClockAt: (ptyId) => this.readOutputClockAt(ptyId),
getTitleObservedAtEpochMs: (ptyId) =>
(ptyId ? this.ptysById.get(ptyId)?.lastOscTitleEpochMs : null) ?? null
}
@@ -23,7 +23,8 @@ async function subscribePausable(
const viewSubscribers = new RuntimeTerminalViewSubscribers({
notifyPresenceChanged: () => {},
hasMobileSubscribers: () => false,
ensureProviderAttach: () => {}
isUnattachedLocalCandidate: () => false,
attachProvider: () => null
})
const harness = startDesktopMultiplexSubscribe({
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
@@ -125,8 +125,6 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
/** The name a rename gave this terminal: display only, never title evidence (#20626). */
manualTitle?: string | null
lastOutputAt: number | null
/** When main began receiving a stream that had printed nothing yet; silence since then is measured quiet. */
outputObservedSince?: number | null
/** See terminal-command-paint.ts; absent until the pane's first output, and again after a gap or a new process. */
commandPaint?: TerminalCommandPaint
}
@@ -1,7 +1,8 @@
type RuntimeTerminalViewSubscriberDependencies = {
notifyPresenceChanged: (ptyId: string) => void
hasMobileSubscribers: (ptyId: string) => boolean
ensureProviderAttach: (ptyId: string) => void
isUnattachedLocalCandidate: (ptyId: string) => boolean
attachProvider: (ptyId: string) => Promise<boolean> | null
}
type SilenceableRemoteView = { isSilent: () => boolean }
@@ -10,9 +11,23 @@ export class RuntimeTerminalViewSubscribers {
private readonly remoteCounts = new Map<string, number>()
private readonly silenceableRemoteViews = new Map<string, Set<SilenceableRemoteView>>()
private readonly rawCounts = new Map<string, number>()
private readonly providerAttaches = new Map<string, Promise<boolean>>()
private readonly attachInventoryWaiters = new Set<string>()
private readonly spawnPublishedPtys = new Set<string>()
constructor(private readonly deps: RuntimeTerminalViewSubscriberDependencies) {}
markSpawnPublished(ptyId: string): void {
this.spawnPublishedPtys.add(ptyId)
}
resetGeneration(ptyId: string): void {
this.providerAttaches.delete(ptyId)
this.attachInventoryWaiters.delete(ptyId)
this.spawnPublishedPtys.delete(ptyId)
}
// Why: exit drops subscribers only; attach/publish state survives until the respawn advances the generation.
clearSubscribers(ptyId: string): void {
this.remoteCounts.delete(ptyId)
this.silenceableRemoteViews.delete(ptyId)
@@ -27,7 +42,7 @@ export class RuntimeTerminalViewSubscribers {
views.add(view)
this.silenceableRemoteViews.set(ptyId, views)
}
this.deps.ensureProviderAttach(ptyId)
this.ensureProviderAttach(ptyId)
this.deps.notifyPresenceChanged(ptyId)
return this.releaseOnce(() => {
this.decrement(this.remoteCounts, ptyId)
@@ -71,6 +86,65 @@ export class RuntimeTerminalViewSubscribers {
return (this.remoteCounts.get(ptyId) ?? 0) - silent
}
isKnownUnattachedLocal(ptyId: string): boolean {
return !this.spawnPublishedPtys.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId)
}
hasPendingProviderAttach(ptyId: string): boolean {
return this.providerAttaches.has(ptyId)
}
attachInventoryWaiterIds(): string[] {
return [...this.attachInventoryWaiters]
}
reconcileProviderAttach(ptyId: string): void {
if (!this.hasRemote(ptyId)) {
return
}
const pending = this.providerAttaches.get(ptyId)
if (!pending) {
this.ensureProviderAttach(ptyId)
return
}
if (this.attachInventoryWaiters.has(ptyId)) {
return
}
this.attachInventoryWaiters.add(ptyId)
void pending.then((attached) => {
this.attachInventoryWaiters.delete(ptyId)
if (attached || !this.hasRemote(ptyId)) {
return
}
if (this.providerAttaches.get(ptyId) === pending) {
this.providerAttaches.delete(ptyId)
}
this.ensureProviderAttach(ptyId)
})
}
private ensureProviderAttach(ptyId: string): void {
if (this.providerAttaches.has(ptyId) || !this.isKnownUnattachedLocal(ptyId)) {
return
}
let attempt: Promise<boolean> | null
try {
attempt = this.deps.attachProvider(ptyId)
} catch {
attempt = Promise.resolve(false)
}
if (!attempt) {
return
}
const guardedAttempt = Promise.resolve(attempt).catch(() => false)
this.providerAttaches.set(ptyId, guardedAttempt)
void guardedAttempt.then((attached) => {
if (!attached && this.providerAttaches.get(ptyId) === guardedAttempt) {
this.providerAttaches.delete(ptyId)
}
})
}
private decrement(counts: Map<string, number>, ptyId: string): void {
const next = (counts.get(ptyId) ?? 1) - 1
if (next <= 0) {
@@ -25,6 +25,10 @@ type RuntimeInternals = {
) => unknown
issuePtyHandle: (pty: unknown) => string
headlessTerminals: Map<string, unknown>
terminalViewSubscribers: {
hasPendingProviderAttach: (ptyId: string) => boolean
attachInventoryWaiterIds: () => string[]
}
refreshPtyWorktreeRecordsWithControllerInventory: (
worktrees: [],
targetWorktreeId: string | null
@@ -406,6 +410,7 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
sendSubscribe(harness, 1, handle)
await waitForSubscribed(harness, 1)
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
expect(internals(runtime).terminalViewSubscribers.hasPendingProviderAttach(PTY_ID)).toBe(true)
expect(runtime.hasRemoteTerminalViewSubscriber(PTY_ID)).toBe(true)
expect(model.emitData(PTY_ID, 'before inventory\r\n')).toBe(false)
@@ -413,24 +418,19 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
expect(model.attachCalls).toEqual([PTY_ID])
expect(internals(runtime).terminalViewSubscribers.attachInventoryWaiterIds()).toEqual([PTY_ID])
refuseFirstAttach()
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID]))
await vi.waitFor(() =>
expect(internals(runtime).terminalViewSubscribers.attachInventoryWaiterIds()).not.toContain(
PTY_ID
)
)
expect(model.emitData(PTY_ID, 'after inventory\r\n')).toBe(true)
await vi.waitFor(() => expect(outputText(harness)).toContain('after inventory'))
})
it('attaches a surviving session at inventory adoption with no view subscribed', async () => {
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
expect(model.emitData(PTY_ID, 'worker still printing\r\n')).toBe(true)
const read = await runtime.readTerminal(handle)
expect(read.tail.join('\n')).toContain('worker still printing')
})
it('does not subscriber-attach or provider-read a session this app already spawned', async () => {
const { runtime, model, handle } = setupNeverAttachedDaemonSession({
snapshotCapable: true,
@@ -65,7 +65,11 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext
/** Live local daemon sessions with no ingested bytes need attachment; existing headless state or snapshot reconciliation means attach already began. */
protected isKnownUnattachedLocalDaemonPty(ptyId: string): boolean {
return this.mainDaemonAttachments.isKnownUnattachedLocal(ptyId)
return this.terminalViewSubscribers.isKnownUnattachedLocal(ptyId)
}
protected reconcileSubscriberDrivenProviderAttach(ptyId: string): void {
this.terminalViewSubscribers.reconcileProviderAttach(ptyId)
}
/** Mark a raw-output viewer without transferring terminal query authority. */
@@ -1,91 +0,0 @@
type MainDaemonAttachmentDependencies = {
isUnattachedLocalCandidate: (ptyId: string) => boolean
attachProvider: (ptyId: string) => Promise<boolean> | null
}
// Why capped: 100 concurrent snapshot attaches delayed the visible terminal's first echo by ~0.7 s.
const MAX_CONCURRENT_ATTACHES = 4
/** Main holds the stream for every live local daemon session it adopts, so its records fill without a mounted pane. */
export class MainDaemonAttachments {
// Sessions whose stream this run already holds: spawned, pane-attached or main-attached.
private readonly attached = new Set<string>()
private readonly attaching = new Map<string, Promise<boolean>>()
private readonly queued: (() => void)[] = []
private running = 0
constructor(private readonly deps: MainDaemonAttachmentDependencies) {}
markSpawnPublished(ptyId: string): void {
this.attached.add(ptyId)
}
/** Exit or respawn ends the generation whose stream this described. */
forget(ptyId: string): void {
this.attached.delete(ptyId)
this.attaching.delete(ptyId)
}
isKnownUnattachedLocal(ptyId: string): boolean {
return !this.attached.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId)
}
/** Inventory proved the session live; attach it, or retry once an in-flight refusal settles. */
reconcile(ptyId: string): void {
const attempt = this.attaching.get(ptyId)
if (!attempt) {
this.ensure(ptyId)
return
}
void attempt.then((attached) => {
if (!attached) {
this.ensure(ptyId)
}
})
}
ensure(ptyId: string): void {
if (this.attaching.has(ptyId) || !this.isKnownUnattachedLocal(ptyId)) {
return
}
const attempt: Promise<boolean> = this.acquireSlot().then(() => this.attach(ptyId, attempt))
this.attaching.set(ptyId, attempt)
void attempt.then((attached) => {
if (this.attaching.get(ptyId) !== attempt) {
return
}
this.attaching.delete(ptyId)
if (attached) {
this.attached.add(ptyId)
}
})
}
private acquireSlot(): Promise<void> {
if (this.running < MAX_CONCURRENT_ATTACHES) {
this.running += 1
return Promise.resolve()
}
// The finishing attach hands its slot over without releasing it.
return new Promise<void>((resolve) => this.queued.push(resolve))
}
private async attach(ptyId: string, attempt: Promise<boolean>): Promise<boolean> {
try {
// While this queued, an exit or respawn may have superseded it, or a pane may have attached.
if (this.attaching.get(ptyId) !== attempt || !this.isKnownUnattachedLocal(ptyId)) {
return false
}
return (await this.deps.attachProvider(ptyId)) ?? false
} catch {
return false
} finally {
const next = this.queued.shift()
if (next) {
next()
} else {
this.running -= 1
}
}
}
}
@@ -190,7 +190,7 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve
this.restoredOrchestrationAuthorityByPtyId.delete(session.id)
}
pty.controllerTitle = session.title?.trim() || null
this.mainDaemonAttachments.reconcile(session.id)
this.reconcileSubscriberDrivenProviderAttach(session.id)
}
// Do not serialize relay probes or let one throw abort inventory cleanup.
if (inventoryOptions?.refreshForegroundAgents !== false) {
@@ -166,7 +166,6 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi
advertisedUrlWatcher.unbindPty(ptyId)
this.mobileSubscribers.delete(ptyId)
this.terminalViewSubscribers.clearSubscribers(ptyId)
this.mainDaemonAttachments.forget(ptyId)
this.mobileDisplayModes.delete(ptyId)
this.resizeListeners.delete(ptyId)
this.lastRendererSizes.delete(ptyId)
@@ -176,7 +176,7 @@ export abstract class OrcaRuntimeWithInvalidateAllHandlesForPty extends OrcaRunt
})
}
}
this.mainDaemonAttachments.markSpawnPublished(ptyId)
this.terminalViewSubscribers.markSpawnPublished(ptyId)
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
if (pty) {
if (incarnationId) {
@@ -89,7 +89,7 @@ export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalida
}
}
this.ptyLivenessVerdictByPtyId.delete(ptyId)
this.mainDaemonAttachments.markSpawnPublished(ptyId)
this.terminalViewSubscribers.markSpawnPublished(ptyId)
// Record spawn-time pane identity so stalled graph sync cannot hide the live PTY backing a pending mobile create.
const paneKey =
binding && isValidTerminalTabId(binding.tabId) && isTerminalLeafId(binding.leafId)
@@ -13,7 +13,6 @@ import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/termin
import type { PtyProviderBufferSnapshot } from '../../../providers/pty-provider-contract'
import type { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc'
import { RuntimeTerminalViewSubscribers } from '../../runtime-terminal-view-subscribers'
import { MainDaemonAttachments } from './main-daemon-attachments'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequestedPtyIds {
@@ -142,7 +141,10 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
>
>()
protected readonly mainDaemonAttachments = new MainDaemonAttachments({
// Remote xterms own query replies; ref-count streams and retain suppression through mobile soft-leave grace (terminal-query-authority.md).
protected readonly terminalViewSubscribers = new RuntimeTerminalViewSubscribers({
notifyPresenceChanged: (ptyId) => this.notifyRemoteTerminalViewPresenceChanged(ptyId),
hasMobileSubscribers: (ptyId) => (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0,
isUnattachedLocalCandidate: (ptyId) => {
if (
this.headlessTerminals.has(ptyId) ||
@@ -155,13 +157,9 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
const pty = this.ptysById.get(ptyId)
return pty !== undefined && pty.connectionId === null && pty.connected
},
attachProvider: (ptyId) => this.ptyController?.attach?.(ptyId) ?? null
})
// Remote xterms own query replies; ref-count streams and retain suppression through mobile soft-leave grace (terminal-query-authority.md).
protected readonly terminalViewSubscribers = new RuntimeTerminalViewSubscribers({
notifyPresenceChanged: (ptyId) => this.notifyRemoteTerminalViewPresenceChanged(ptyId),
hasMobileSubscribers: (ptyId) => (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0,
ensureProviderAttach: (ptyId) => this.mainDaemonAttachments.ensure(ptyId)
attachProvider: (ptyId) => {
const attach = this.ptyController?.attach
return attach ? (async () => attach(ptyId))() : null
}
})
}
@@ -225,19 +225,6 @@ export abstract class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRun
})
}
/** A reattached live process may print nothing; its silence is observed quiet from the first attach of this generation. */
startReattachedOutputClock(ptyId: string): void {
const pty = this.ptysById.get(ptyId)
if (pty && pty.lastOutputAt === null) {
pty.outputObservedSince ??= Date.now()
}
}
protected readOutputClockAt(ptyId: string | null | undefined): number | null {
const pty = ptyId ? this.ptysById.get(ptyId) : undefined
return pty ? (pty.lastOutputAt ?? pty.outputObservedSince ?? null) : null
}
// Restore RPC bytes bypass onPtyData; seed read/list state without waiters, orchestration events or fresh-output clocks.
seedTerminalRestoreTail(ptyId: string, restore: { text?: string; lastTitle?: string }): void {
const seed = restore.text ? buildRestoredTerminalTailSeed(restore.text) : null
@@ -109,12 +109,8 @@ export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends Orc
this.agentPromptPermissionSequenceByPtyId.delete(ptyId)
this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now())
this.legacyWorkerRecovery.deleteRecoveredPty(ptyId)
// Why: a respawn under the same session id needs its own main attach.
this.mainDaemonAttachments.forget(ptyId)
const pty = this.ptysById.get(ptyId)
if (pty) {
pty.outputObservedSince = null
}
// Why: a respawn under the same session id needs its own subscriber-driven attach.
this.terminalViewSubscribers.resetGeneration(ptyId)
// Why: a provider response belongs to the process generation that issued
// it; a respawn must neither reuse its frame nor join its in-flight call.
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
@@ -18,7 +18,6 @@ import {
} from '../../tui-idle-evidence'
import {
leafTuiIdleEvidence,
outputClockOf,
ptyTuiIdleEvidence,
type TuiIdleEvidenceSource
} from '../../tui-idle-evidence-source'
@@ -146,7 +145,7 @@ export class RuntimeTerminalIdlePolls {
ready: () => buildPtyTerminalWaitResult(handle, 'tui-idle', pty),
blocked: (reason) => buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, reason),
isQuiet: (lane) =>
isQuietForQuiescence(outputClockOf(this.deps, pty), this.deps.quiescenceMs, lane, () =>
isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs, lane, () =>
this.deps.hasCommandPainted(pty.ptyId)
)
}
@@ -166,7 +165,7 @@ export class RuntimeTerminalIdlePolls {
blocked: (reason) => buildTerminalWaitBlockedResult(handle, 'tui-idle', live(), reason),
isQuiet: (lane) =>
isQuietForQuiescence(
outputClockOf(this.deps, live()),
live().lastOutputAt,
this.deps.quiescenceMs,
lane,
() => !leaf.ptyId || this.deps.hasCommandPainted(leaf.ptyId)
@@ -25,7 +25,6 @@ import {
buildTerminalWaitBlockedResult,
buildTerminalWaitResult
} from '../../terminal-wait-results'
import { outputClockOf } from '../../tui-idle-evidence-source'
import { createSetupCompletionScanner } from '../../orchestration/setup-completion-signal'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -80,11 +79,10 @@ export abstract class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRu
agent,
() => restoreProjectedComposerDraft(projection.tail, projection.draft),
// Why read now: output since the probe started makes the pane clocked.
outputClockOf(
this.tuiIdleEvidenceSource,
(
this.getLivePtyForHandle(waiter.handle)?.pty ??
this.getLiveLeafForHandle(waiter.handle).leaf
) !== null
this.getLiveLeafForHandle(waiter.handle).leaf
).lastOutputAt !== null
)
: isKnownReadyPromptSettled(snapshotText)
if (!blockedReason && !ready) {
@@ -15,7 +15,6 @@ import { buildTerminalWaitText } from '../../terminal-wait-tail-state'
import { evaluateTuiIdle, type TuiIdleVerdict } from '../../tui-idle-evidence'
import {
leafTuiIdleEvidence,
outputClockOf,
ptyTuiIdleEvidence,
type TuiIdleEvidenceSource
} from '../../tui-idle-evidence-source'
@@ -31,14 +30,13 @@ import type { RuntimeTerminalWaiterRegistry } from './terminal-waiter-registry'
/** Probe unseen/provider-only panes and clockless screen-ruled agents with untrusted restored models; clocked panes settle through quiet polling. profile.screenProbeBanner also opts in. */
function shouldProbeVisibleScreen(
paneAgent: TuiAgent | null,
record: Pick<RuntimePtyWorktreeRecord, 'lastAgentStatus'>,
outputClockAt: number | null,
record: Pick<RuntimePtyWorktreeRecord, 'lastAgentStatus' | 'lastOutputAt'>,
waitText: string
): boolean {
return (
(record.lastAgentStatus === null && waitText.length === 0) ||
showsScreenProbeBanner(waitText) ||
(readsTrustedScreen(paneAgent) && outputClockAt === null)
(readsTrustedScreen(paneAgent) && record.lastOutputAt === null)
)
}
@@ -156,14 +154,7 @@ export class RuntimeTerminalWait {
} else {
this.polls.startPty(waiter, live.pty, verdict)
const paneAgent = this.deps.getPaneAgent(live.pty.ptyId)
if (
shouldProbeVisibleScreen(
paneAgent,
live.pty,
outputClockOf(this.deps, live.pty),
livePtyWaitText
)
) {
if (shouldProbeVisibleScreen(paneAgent, live.pty, livePtyWaitText)) {
this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent)
}
}
@@ -244,14 +235,7 @@ export class RuntimeTerminalWait {
// Ready previews can precede idle OSC titles; keep polling both.
this.polls.startLeaf(waiter, live.leaf, verdict)
const paneAgent = this.deps.getPaneAgent(live.leaf.ptyId)
if (
shouldProbeVisibleScreen(
paneAgent,
live.leaf,
outputClockOf(this.deps, live.leaf),
liveLeafWaitText
)
) {
if (shouldProbeVisibleScreen(paneAgent, live.leaf, liveLeafWaitText)) {
this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent)
}
}
+6 -29
View File
@@ -35,27 +35,6 @@ export type TuiIdleEvidenceSource = {
readRuledScreen?(ptyId: string | null | undefined): RuledScreen | null
/** When the PTY last observed a title of its own. Absent, no title has an age. */
getTitleObservedAtEpochMs?(ptyId: string | null | undefined): number | null
/** The PTY's quiet clock: its last output, else when main began receiving its silent stream. */
getOutputClockAt?(ptyId: string | null | undefined): number | null
}
/** One clock per terminal, read by ptyId, so a pane leaf and its PTY settle alike. */
export function outputClockOf(
source: Pick<TuiIdleEvidenceSource, 'getOutputClockAt'>,
record: { ptyId: string | null; lastOutputAt: number | null }
): number | null {
return source.getOutputClockAt?.(record.ptyId) ?? record.lastOutputAt
}
function evidenceRecord(
source: TuiIdleEvidenceSource,
record: TuiIdleEvidenceRecord & { ptyId: string | null }
): TuiIdleEvidenceRecord {
return {
lastAgentStatus: record.lastAgentStatus,
lastOscTitle: record.lastOscTitle,
lastOutputAt: outputClockOf(source, record)
}
}
// Why per agent: every other agent keeps the live screen its rules were recorded against.
@@ -126,15 +105,14 @@ export function leafTuiIdleEvidence(
const waitText = lazyWaitText(readWaitText)
const agent = source.getPaneAgent(leaf.ptyId)
const readScreen = screenReader(source, agent, leaf.ptyId)
const record = evidenceRecord(source, leaf)
return {
record,
record: leaf,
readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()),
rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId),
readPositiveBodyEvidence: () =>
isKnownReadyPromptBody(waitText(), agent, readScreen, record.lastOutputAt !== null),
isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null),
readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen),
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, record, readScreen, waitText),
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText),
readScreenInputVeto: screenInputVetoReader(source, agent, leaf.ptyId),
titleObservedAtEpochMs: source.getTitleObservedAtEpochMs?.(leaf.ptyId) ?? null,
agent,
@@ -152,18 +130,17 @@ export function ptyTuiIdleEvidence(
const waitText = lazyWaitText(readWaitText)
const agent = source.getPaneAgent(pty.ptyId)
const readScreen = screenReader(source, agent, pty.ptyId)
const record = evidenceRecord(source, pty)
return {
record,
record: pty,
readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()),
readPositiveBodyEvidence: () =>
(agent !== 'qoder' &&
agent !== 'qoder-cn' &&
agent !== 'hermes' &&
source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
isKnownReadyPromptBody(waitText(), agent, readScreen, record.lastOutputAt !== null),
isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null),
readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen),
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, record, readScreen, waitText),
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText),
readScreenInputVeto: screenInputVetoReader(source, agent, pty.ptyId),
titleObservedAtEpochMs: pty.lastOscTitleEpochMs,
agent,
@@ -1,40 +0,0 @@
import './rpc/unused-default-rpc-methods.test-fixture'
import { afterEach, expect, it, vi } from 'vitest'
import { runtimeWithLeaf } from './orca-runtime-pty-leaf.test-fixture'
import { evaluateTuiIdle } from './tui-idle-evidence'
import { leafTuiIdleEvidence, ptyTuiIdleEvidence } from './tui-idle-evidence-source'
const PTY_ID = 'repo-1::/tmp/wt@@1a2b3c4d'
const CODEX_COMPOSER = '› Ask Codex to do anything'
afterEach(() => {
vi.useRealTimers()
})
it('a silent reattached Codex reads quiet on its PTY and its pane leaf alike, and stays quiet when its pane mounts', () => {
vi.useFakeTimers({ now: 100_000 })
const { runtime, leaf } = runtimeWithLeaf(PTY_ID)
runtime.seedTerminalRestoreTail(PTY_ID, { text: CODEX_COMPOSER })
runtime.startReattachedOutputClock(PTY_ID)
const pty = runtime['ptysById'].get(PTY_ID)!
const source = {
...runtime['tuiIdleEvidenceSource'],
getPaneAgent: () => 'codex' as const,
readScreenLines: () => [CODEX_COMPOSER],
readRuledScreen: () => ({ lines: [CODEX_COMPOSER], alternateScreen: false })
}
const verdicts = () =>
[
evaluateTuiIdle(ptyTuiIdleEvidence(source, pty, () => pty.preview)),
evaluateTuiIdle(leafTuiIdleEvidence(source, leaf, () => leaf.preview))
].map((verdict) => verdict.kind)
expect(verdicts()).toEqual(['pending', 'pending'])
vi.advanceTimersByTime(source.quiescenceMs)
// Neither record ever saw output; the clock is main's observation since the reattach.
expect([pty.lastOutputAt, leaf.lastOutputAt]).toEqual([null, null])
expect(verdicts()).toEqual(['ready-strong', 'ready-strong'])
// A pane mounting later reattaches the same process; its observed quiet must not restart.
runtime.startReattachedOutputClock(PTY_ID)
expect(verdicts()).toEqual(['ready-strong', 'ready-strong'])
})
@@ -1,174 +0,0 @@
// A terminal that survives a quit in a workspace the window never opened must stream to main after relaunch.
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { RuntimeClient } from '../../src/cli/runtime-client'
import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types'
import { expect, test } from './helpers/orca-app'
import { TEST_REPO_PATH_FILE } from './global-setup'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { createRetentionFixtureDirectory } from './helpers/host-created-terminal-retention-oracle'
const scratch = createRetentionFixtureDirectory()
const printerPath = path.join(scratch, 'printer.mjs')
writeFileSync(
printerPath,
'setInterval(() => process.stdout.write(`tick ${Date.now()}\\r\\n`), 250)\n'
)
test.afterAll(() => rmSync(scratch, { recursive: true, force: true }))
type RuntimeCall = <T>(method: string, params?: unknown) => Promise<T>
async function launchWithUnopenedWorkspace(session: ReturnType<typeof createRestartSession>) {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
const { app } = await session.launch()
const page = await app.firstWindow()
const active = await attachRepoAndOpenTerminal(page, repoPath)
await waitForSessionReady(page)
const call: RuntimeCall = async <T>(method: string, params?: unknown) =>
(await new RuntimeClient(session.userDataDir, 30_000, null, null).call<T>(method, params))
.result
await expect
.poll(
() =>
page.evaluate(async () => {
const state = window.__store!.getState()
await state.fetchWorktrees(state.repos[0]!.id)
return window.__store!.getState().allWorktrees().length
}),
{ timeout: 60_000 }
)
.toBeGreaterThan(1)
const unopened = await page.evaluate(
(activeId) =>
window
.__store!.getState()
.allWorktrees()
.find((worktree) => worktree.id !== activeId)!.id,
active
)
await expect
.poll(
() =>
call('worktree.show', { worktree: `id:${unopened}` }).then(
() => true,
() => false
),
{ timeout: 120_000 }
)
.toBe(true)
return { app, page, call, active, unopened }
}
// Scoped to one workspace: an unscoped list refuses while any other pane is still binding its PTY.
function listTerminals(call: RuntimeCall, worktreeId: string): Promise<RuntimeTerminalSummary[]> {
return call<{ terminals: RuntimeTerminalSummary[] }>('terminal.list', {
worktree: `id:${worktreeId}`
}).then((listed) => listed.terminals)
}
// oxlint-disable-next-line no-empty-pattern -- Owns both app launches; Playwright needs the fixtures argument.
test('a surviving printer in a never-opened workspace streams to main after relaunch', async ({}, testInfo) => {
// Two full app launches with a quit between them.
test.setTimeout(300_000)
const session = createRestartSession(testInfo)
const first = await launchWithUnopenedWorkspace(session)
let app = first.app
try {
const { call, unopened } = first
const { terminal } = await call<{ terminal: { handle: string } }>('terminal.create', {
worktree: `id:${unopened}`,
command: `node ${JSON.stringify(printerPath)}`,
focus: false,
activate: false,
presentation: 'background'
})
await expect
.poll(
async () =>
(
await call<{ terminal: { tail: string[] } }>('terminal.read', {
terminal: terminal.handle,
limit: 20
})
).terminal.tail.join('\n'),
{ timeout: 30_000 }
)
.toContain('tick ')
const { ptyId } = (await listTerminals(call, unopened)).find(
(t) => t.handle === terminal.handle
)!
await session.close(app)
const second = await session.launch()
app = second.app
await waitForSessionReady(second.page)
await waitForActiveWorktree(second.page)
// Precondition: no window pane holds the printer, so only main can attach it.
expect(
await second.page.evaluate(
(worktreeId) => window.__store!.getState().activeWorktreeId === worktreeId,
unopened
)
).toBe(false)
const survivor = async (): Promise<RuntimeTerminalSummary | undefined> =>
(await listTerminals(call, unopened)).find((terminal) => terminal.ptyId === ptyId)
await expect.poll(async () => (await survivor())?.connected, { timeout: 60_000 }).toBe(true)
const expectMainReceiving = async (message: string): Promise<void> => {
let baseline: number | null = null
await expect
.poll(
async () => {
const lastOutputAt = (await survivor())?.lastOutputAt ?? null
baseline ??= lastOutputAt
return baseline !== null && lastOutputAt !== null && lastOutputAt > baseline
},
{ timeout: 5_000, message }
)
.toBe(true)
}
await expectMainReceiving('main never received the surviving printer output')
// A pane mounting later shares main's daemon client, so main keeps receiving.
await expect
.poll(
() =>
second.page.evaluate((worktreeId) => {
let state = window.__store!.getState()
if (state.activeWorktreeId !== worktreeId) {
state.setActiveWorktree(worktreeId)
return false
}
const tab = state.tabsByWorktree[worktreeId]?.[0]
if (!tab) {
return false
}
if (state.activeTabId !== tab.id) {
state.setActiveTab(tab.id)
state.setActiveTabType('terminal', worktreeId)
}
state = window.__store!.getState()
return state.activeWorktreeId === worktreeId && state.activeTabId === tab.id
}, unopened),
{ timeout: 30_000 }
)
.toBe(true)
// The pane paints from its own attach; xterm draws on a canvas, so read its buffer.
await expect
.poll(
() =>
second.page.evaluate(() => {
const state = window.__store!.getState()
const buffer = window.__paneManagers?.get(state.activeTabId ?? '')?.getActivePane()
?.terminal.buffer.active
const line = buffer?.getLine(Math.max(0, buffer.baseY + buffer.cursorY - 1))
return line?.translateToString(true) ?? ''
}),
{ timeout: 30_000 }
)
.toContain('tick ')
await expectMainReceiving('main stopped receiving after the pane mounted')
} finally {
await session.dispose(app).catch(() => undefined)
}
})