mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
Revert "Merge #33720 (restart: attach surviving terminals) into integration"
This reverts commitd4b0c6c5f7, reversing changes made to53f9735bfe.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user