mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
fix(terminal): keep surviving terminals streaming to main after a restart
Main now attaches every live local daemon session it adopts at startup, not only ones a window pane or remote view is showing, and re-attaches after a daemon endpoint drop. The attach snapshot seeds main's screen, tail and title, and an attached-but-silent agent gets an output clock so readiness can see it has gone quiet. Waits on a survivor main could not attach fail at once instead of timing out.
This commit is contained in:
@@ -971,7 +971,6 @@
|
||||
1 test-no-wall-clock:src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts
|
||||
2 test-no-wall-clock:src/main/runtime/terminal/surface/pty-pane-copies-untouched-main.test.ts
|
||||
2 test-no-wall-clock:src/main/runtime/tui-idle-claude-task-wakeup.test.ts
|
||||
12 test-no-wall-clock:src/main/runtime/tui-idle-evidence.test.ts
|
||||
18 test-no-wall-clock:src/main/runtime/tui-idle-hook-lane-runtime.test.ts
|
||||
10 test-no-wall-clock:src/main/runtime/tui-idle-hook-lane.test.ts
|
||||
4 test-no-wall-clock:src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts
|
||||
|
||||
@@ -623,7 +623,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
protocolVersion: GET_SIZE_PROTOCOL_VERSION - 1
|
||||
})
|
||||
try {
|
||||
await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined()
|
||||
await expect(rig.adapter.attach(sessionId)).resolves.toMatchObject({ id: sessionId })
|
||||
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.toBeUndefined()
|
||||
await expect(rig.adapter.attach(sessionId)).resolves.toMatchObject({ id: sessionId })
|
||||
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.toEqual({
|
||||
await expect(adapter.attach(sessionId)).resolves.toMatchObject({
|
||||
providerSequence: {
|
||||
value: 'preserved output'.length,
|
||||
generation: 'continued'
|
||||
@@ -367,7 +367,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
)
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
|
||||
try {
|
||||
await expect(legacy.attach('legacy-session')).resolves.toBeUndefined()
|
||||
await expect(legacy.attach('legacy-session')).resolves.not.toHaveProperty(
|
||||
'providerSequence'
|
||||
)
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
request.mockRestore()
|
||||
|
||||
@@ -78,14 +78,17 @@ 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({ providerSequence })
|
||||
vi.mocked(legacy.attach).mockResolvedValueOnce({ id: 'legacy-session', providerSequence })
|
||||
const router = new DaemonPtyRouter({
|
||||
current: createAdapter('current'),
|
||||
legacy: [legacy]
|
||||
})
|
||||
await router.discoverLegacySessions()
|
||||
|
||||
await expect(router.attach('legacy-session')).resolves.toEqual({ providerSequence })
|
||||
await expect(router.attach('legacy-session')).resolves.toEqual({
|
||||
id: 'legacy-session',
|
||||
providerSequence
|
||||
})
|
||||
})
|
||||
|
||||
describe('DaemonPtyRouter', () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
|
||||
deadlineMs?: number
|
||||
): Promise<void>
|
||||
|
||||
async attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence' | 'lastTitle'> | void> {
|
||||
async attach(id: string): Promise<PtySpawnResult> {
|
||||
await this.ensureConnected()
|
||||
if (!this.canDelegateBackgroundToDaemon) {
|
||||
this.setPtyBackgrounded(id, false)
|
||||
@@ -43,13 +43,8 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
|
||||
if (result.exitedBeforeSpawnReply) {
|
||||
throw new SessionNotFoundError(id)
|
||||
}
|
||||
// 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
|
||||
// A restarted runtime has never seen the surviving session's screen or title.
|
||||
return result
|
||||
}
|
||||
|
||||
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({ providerSequence })
|
||||
vi.mocked(legacy.attach).mockResolvedValueOnce({ id: 'daemon-session', providerSequence })
|
||||
const provider = new DegradedDaemonPtyProvider({
|
||||
current: createDaemonAdapter('current'),
|
||||
legacy: [legacy],
|
||||
@@ -331,7 +331,10 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
})
|
||||
await provider.discoverDaemonSessions()
|
||||
|
||||
await expect(provider.attach('daemon-session')).resolves.toEqual({ providerSequence })
|
||||
await expect(provider.attach('daemon-session')).resolves.toEqual({
|
||||
id: 'daemon-session',
|
||||
providerSequence
|
||||
})
|
||||
})
|
||||
|
||||
it('only delegates owner-listing authority to the provider that owns the id', async () => {
|
||||
|
||||
@@ -27,6 +27,8 @@ export function bindProviderListeners(session: PtyIpcSession): void {
|
||||
localProvider.onWriteUnavailable?.((payload) => {
|
||||
// Before the window check: a headless host has no window, only remote streams.
|
||||
publishPtyEndpointLoss(payload.id)
|
||||
// Panes remount and re-attach themselves; main must restore its own stream for unmounted ones.
|
||||
session.runtime?.reattachPtyAfterEndpointLoss(payload.id)
|
||||
if (
|
||||
!session.mainWindow ||
|
||||
session.mainWindow.isDestroyed() ||
|
||||
|
||||
@@ -180,17 +180,36 @@ describe('registerPtyHandlers', () => {
|
||||
0
|
||||
)
|
||||
})
|
||||
it('seeds the title of a session adopted by subscriber-driven attach', async () => {
|
||||
it('seeds the screen and title of a session adopted by main attach', async () => {
|
||||
const daemonPtyId = 'repo-1::/tmp/wt@@title-handoff'
|
||||
const localProvider = createAgentClaimProvider({})
|
||||
localProvider.attach.mockResolvedValueOnce({ lastTitle: '⠋ Claude' })
|
||||
localProvider.attach.mockResolvedValueOnce({
|
||||
id: daemonPtyId,
|
||||
snapshot: 'idle composer\r\n',
|
||||
snapshotCols: 120,
|
||||
snapshotRows: 40,
|
||||
lastTitle: '⠋ Claude'
|
||||
})
|
||||
setLocalPtyProvider(localProvider as never)
|
||||
const seedHeadlessTerminal = vi.fn()
|
||||
const seedTerminalRestoreTail = vi.fn()
|
||||
const controller = registerAgentClaimController({ seedTerminalRestoreTail })
|
||||
const controller = registerAgentClaimController({
|
||||
seedHeadlessTerminal,
|
||||
seedTerminalRestoreTail
|
||||
})
|
||||
|
||||
await expect(controller.attach(daemonPtyId)).resolves.toBe(true)
|
||||
|
||||
expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, { lastTitle: '⠋ Claude' })
|
||||
expect(seedHeadlessTerminal).toHaveBeenCalledWith(
|
||||
daemonPtyId,
|
||||
'idle composer\r\n',
|
||||
{ cols: 120, rows: 40 },
|
||||
{}
|
||||
)
|
||||
expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, {
|
||||
text: 'idle composer\r\n',
|
||||
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,7 +3,10 @@ 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 { ptySizes } from '../delivery/visibility-state'
|
||||
import { ptySizes, visibleRendererPtys } from '../delivery/visibility-state'
|
||||
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
|
||||
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
|
||||
import { hideRuntimeOwnedRendererDelivery } from './spawn-hidden-delivery'
|
||||
import { rendererSerializerReadiness } from '../pane/serializer-state'
|
||||
import { getProviderForPty, localProvider, resolvePtyExecutionHost } from '../provider/registry'
|
||||
import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection'
|
||||
@@ -125,21 +128,28 @@ export async function attachPtyFromRuntimeController(
|
||||
if (provider !== localProvider || provider instanceof LocalPtyProvider) {
|
||||
return false
|
||||
}
|
||||
const hidden = !visibleRendererPtys.has(ptyId) && hideRuntimeOwnedRendererDelivery(deps, ptyId)
|
||||
try {
|
||||
const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0
|
||||
const attachResult = await provider.attach(ptyId)
|
||||
if (attachResult?.providerSequence) {
|
||||
deps.runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
ptyId,
|
||||
attachResult.providerSequence,
|
||||
sequenceBeforeProviderAttach
|
||||
)
|
||||
}
|
||||
if (attachResult?.lastTitle) {
|
||||
deps.runtime?.seedTerminalRestoreTail?.(ptyId, { lastTitle: attachResult.lastTitle })
|
||||
if (attachResult) {
|
||||
if (attachResult.providerSequence) {
|
||||
deps.runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
ptyId,
|
||||
attachResult.providerSequence,
|
||||
sequenceBeforeProviderAttach
|
||||
)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
deps.syncPtyBackgroundedDelivery?.(ptyId, 'main-attach')
|
||||
return true
|
||||
} catch {
|
||||
if (hidden) {
|
||||
deps.transitionSpawnHiddenRendererPtyDeliveryState?.(ptyId, false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { markRuntimeOwnedHiddenRendererPty } from '../delivery/pty-hidden-delive
|
||||
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.
|
||||
|
||||
@@ -65,14 +66,23 @@ export function releaseRuntimeSpawnPreSpawnHiddenMark(ctx: RuntimePtySpawnState)
|
||||
}
|
||||
|
||||
function markRuntimeSpawnHidden(ctx: RuntimePtySpawnState, id: string): void {
|
||||
const transition = ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState
|
||||
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
|
||||
): boolean {
|
||||
const transition = deps.transitionSpawnHiddenRendererPtyDeliveryState
|
||||
if (!transition) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
// Transition first so a fresh mark still invalidates the drain policy.
|
||||
transition(id, true)
|
||||
// No renderer exists to re-mark this PTY after reload.
|
||||
markRuntimeOwnedHiddenRendererPty(id)
|
||||
return true
|
||||
}
|
||||
|
||||
function isAdoptedAgentSession(ensure: unknown): boolean {
|
||||
|
||||
@@ -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<Pick<PtySpawnResult, 'providerSequence' | 'lastTitle'> | void>
|
||||
attach(id: string): Promise<PtySpawnResult | 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>
|
||||
|
||||
@@ -23,8 +23,7 @@ async function subscribePausable(
|
||||
const viewSubscribers = new RuntimeTerminalViewSubscribers({
|
||||
notifyPresenceChanged: () => {},
|
||||
hasMobileSubscribers: () => false,
|
||||
isUnattachedLocalCandidate: () => false,
|
||||
attachProvider: () => null
|
||||
ensureProviderAttach: () => {}
|
||||
})
|
||||
const harness = startDesktopMultiplexSubscribe({
|
||||
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
|
||||
|
||||
@@ -125,6 +125,8 @@ 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,8 +1,7 @@
|
||||
type RuntimeTerminalViewSubscriberDependencies = {
|
||||
notifyPresenceChanged: (ptyId: string) => void
|
||||
hasMobileSubscribers: (ptyId: string) => boolean
|
||||
isUnattachedLocalCandidate: (ptyId: string) => boolean
|
||||
attachProvider: (ptyId: string) => Promise<boolean> | null
|
||||
ensureProviderAttach: (ptyId: string) => void
|
||||
}
|
||||
|
||||
type SilenceableRemoteView = { isSilent: () => boolean }
|
||||
@@ -11,22 +10,9 @@ 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)
|
||||
@@ -42,7 +28,7 @@ export class RuntimeTerminalViewSubscribers {
|
||||
views.add(view)
|
||||
this.silenceableRemoteViews.set(ptyId, views)
|
||||
}
|
||||
this.ensureProviderAttach(ptyId)
|
||||
this.deps.ensureProviderAttach(ptyId)
|
||||
this.deps.notifyPresenceChanged(ptyId)
|
||||
return this.releaseOnce(() => {
|
||||
this.decrement(this.remoteCounts, ptyId)
|
||||
@@ -86,65 +72,6 @@ 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,9 +25,9 @@ type RuntimeInternals = {
|
||||
) => unknown
|
||||
issuePtyHandle: (pty: unknown) => string
|
||||
headlessTerminals: Map<string, unknown>
|
||||
terminalViewSubscribers: {
|
||||
hasPendingProviderAttach: (ptyId: string) => boolean
|
||||
attachInventoryWaiterIds: () => string[]
|
||||
mainDaemonAttachments: {
|
||||
hasPendingAttach: (ptyId: string) => boolean
|
||||
inventoryWaiterIds: () => string[]
|
||||
}
|
||||
refreshPtyWorktreeRecordsWithControllerInventory: (
|
||||
worktrees: [],
|
||||
@@ -410,7 +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(internals(runtime).mainDaemonAttachments.hasPendingAttach(PTY_ID)).toBe(true)
|
||||
expect(runtime.hasRemoteTerminalViewSubscriber(PTY_ID)).toBe(true)
|
||||
expect(model.emitData(PTY_ID, 'before inventory\r\n')).toBe(false)
|
||||
|
||||
@@ -418,19 +418,55 @@ 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])
|
||||
expect(internals(runtime).mainDaemonAttachments.inventoryWaiterIds()).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(internals(runtime).mainDaemonAttachments.inventoryWaiterIds()).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('re-attaches a session main already holds after the daemon endpoint drops', async () => {
|
||||
const { runtime, model } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
|
||||
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
|
||||
expect(model.emitData(PTY_ID, 'before loss\r\n')).toBe(true)
|
||||
model.sessions.get(PTY_ID)!.attached = false
|
||||
|
||||
runtime.reattachPtyAfterEndpointLoss(PTY_ID)
|
||||
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID]))
|
||||
expect(model.emitData(PTY_ID, 'after loss\r\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('fails a wait at once when main could not attach the surviving session', async () => {
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
|
||||
const refuse = model.deferNextAttach(false)
|
||||
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
|
||||
refuse()
|
||||
await vi.waitFor(() =>
|
||||
expect(internals(runtime).mainDaemonAttachments.hasPendingAttach(PTY_ID)).toBe(false)
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 })
|
||||
).rejects.toThrow('terminal_not_connected_since_restart')
|
||||
})
|
||||
|
||||
it('does not subscriber-attach or provider-read a session this app already spawned', async () => {
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({
|
||||
snapshotCapable: true,
|
||||
|
||||
@@ -138,6 +138,13 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun
|
||||
launchReadiness?: boolean
|
||||
}
|
||||
): Promise<RuntimeTerminalWait> {
|
||||
if (options?.condition !== 'exit') {
|
||||
try {
|
||||
this.assertTerminalOutputReachesMain(handle)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
// Switch point: a saved pane main holds with no terminal yet starts first (once, shared).
|
||||
const starting = ORCA_LAYOUT_OWNER_IS_MAIN
|
||||
? this.startRestoredHandlePane(handle, options ?? {})
|
||||
|
||||
@@ -65,11 +65,28 @@ 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.terminalViewSubscribers.isKnownUnattachedLocal(ptyId)
|
||||
return this.mainDaemonAttachments.isKnownUnattachedLocal(ptyId)
|
||||
}
|
||||
|
||||
protected reconcileSubscriberDrivenProviderAttach(ptyId: string): void {
|
||||
this.terminalViewSubscribers.reconcileProviderAttach(ptyId)
|
||||
/** A failed attach leaves a surviving session silent to main; waiting on it could only time out. */
|
||||
protected assertTerminalOutputReachesMain(handle: string): void {
|
||||
const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId
|
||||
if (
|
||||
ptyId &&
|
||||
this.isKnownUnattachedLocalDaemonPty(ptyId) &&
|
||||
!this.mainDaemonAttachments.hasPendingAttach(ptyId)
|
||||
) {
|
||||
throw new Error('terminal_not_connected_since_restart')
|
||||
}
|
||||
}
|
||||
|
||||
protected reconcileMainDaemonAttach(ptyId: string): void {
|
||||
this.mainDaemonAttachments.reconcile(ptyId)
|
||||
}
|
||||
|
||||
/** The provider endpoint dropped this session's stream; restore main's attachment once it reconnects. */
|
||||
reattachPtyAfterEndpointLoss(ptyId: string): void {
|
||||
this.mainDaemonAttachments.reattachAfterEndpointLoss(ptyId)
|
||||
}
|
||||
|
||||
/** Mark a raw-output viewer without transferring terminal query authority. */
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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 {
|
||||
private readonly attaches = new Map<string, Promise<boolean>>()
|
||||
private readonly inventoryWaiters = new Set<string>()
|
||||
private readonly spawnPublishedPtys = new Set<string>()
|
||||
// Endpoint loss detached these; they need a re-attach although main already holds their model.
|
||||
private readonly lostPtys = new Set<string>()
|
||||
private readonly queued: (() => void)[] = []
|
||||
private running = 0
|
||||
|
||||
constructor(private readonly deps: MainDaemonAttachmentDependencies) {}
|
||||
|
||||
markSpawnPublished(ptyId: string): void {
|
||||
this.spawnPublishedPtys.add(ptyId)
|
||||
}
|
||||
|
||||
resetGeneration(ptyId: string): void {
|
||||
this.attaches.delete(ptyId)
|
||||
this.inventoryWaiters.delete(ptyId)
|
||||
this.spawnPublishedPtys.delete(ptyId)
|
||||
this.lostPtys.delete(ptyId)
|
||||
}
|
||||
|
||||
isKnownUnattachedLocal(ptyId: string): boolean {
|
||||
return !this.spawnPublishedPtys.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId)
|
||||
}
|
||||
|
||||
hasPendingAttach(ptyId: string): boolean {
|
||||
return this.attaches.has(ptyId)
|
||||
}
|
||||
|
||||
inventoryWaiterIds(): string[] {
|
||||
return [...this.inventoryWaiters]
|
||||
}
|
||||
|
||||
/** Inventory proved the session live; attach it, or retry once an in-flight refusal settles. */
|
||||
reconcile(ptyId: string): void {
|
||||
const pending = this.attaches.get(ptyId)
|
||||
if (!pending) {
|
||||
this.ensure(ptyId)
|
||||
return
|
||||
}
|
||||
if (this.inventoryWaiters.has(ptyId)) {
|
||||
return
|
||||
}
|
||||
this.inventoryWaiters.add(ptyId)
|
||||
void pending.then((attached) => {
|
||||
this.inventoryWaiters.delete(ptyId)
|
||||
if (attached) {
|
||||
return
|
||||
}
|
||||
if (this.attaches.get(ptyId) === pending) {
|
||||
this.attaches.delete(ptyId)
|
||||
}
|
||||
this.ensure(ptyId)
|
||||
})
|
||||
}
|
||||
|
||||
/** A dropped daemon endpoint detaches every stream; panes remount, sessions without one re-attach here. */
|
||||
reattachAfterEndpointLoss(ptyId: string): void {
|
||||
this.attaches.delete(ptyId)
|
||||
this.lostPtys.add(ptyId)
|
||||
this.ensure(ptyId)
|
||||
}
|
||||
|
||||
ensure(ptyId: string): void {
|
||||
if (
|
||||
this.attaches.has(ptyId) ||
|
||||
(!this.lostPtys.has(ptyId) && !this.isKnownUnattachedLocal(ptyId))
|
||||
) {
|
||||
return
|
||||
}
|
||||
const attempt = this.attach(ptyId)
|
||||
this.attaches.set(ptyId, attempt)
|
||||
void attempt.then((attached) => {
|
||||
if (this.attaches.get(ptyId) !== attempt) {
|
||||
return
|
||||
}
|
||||
if (attached) {
|
||||
this.lostPtys.delete(ptyId)
|
||||
} else {
|
||||
this.attaches.delete(ptyId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async attach(ptyId: string): Promise<boolean> {
|
||||
if (this.running >= MAX_CONCURRENT_ATTACHES) {
|
||||
// The finishing attach hands its slot over without releasing it.
|
||||
await new Promise<void>((resolve) => this.queued.push(resolve))
|
||||
} else {
|
||||
this.running += 1
|
||||
}
|
||||
try {
|
||||
// A pane may have attached, or the session respawned, while this waited.
|
||||
if (!this.lostPtys.has(ptyId) && !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.reconcileSubscriberDrivenProviderAttach(session.id)
|
||||
this.reconcileMainDaemonAttach(session.id)
|
||||
}
|
||||
// Do not serialize relay probes or let one throw abort inventory cleanup.
|
||||
if (inventoryOptions?.refreshForegroundAgents !== false) {
|
||||
|
||||
@@ -176,7 +176,7 @@ export abstract class OrcaRuntimeWithInvalidateAllHandlesForPty extends OrcaRunt
|
||||
})
|
||||
}
|
||||
}
|
||||
this.terminalViewSubscribers.markSpawnPublished(ptyId)
|
||||
this.mainDaemonAttachments.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.terminalViewSubscribers.markSpawnPublished(ptyId)
|
||||
this.mainDaemonAttachments.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,6 +13,7 @@ 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 {
|
||||
@@ -146,10 +147,7 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
|
||||
>
|
||||
>()
|
||||
|
||||
// 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,
|
||||
protected readonly mainDaemonAttachments = new MainDaemonAttachments({
|
||||
isUnattachedLocalCandidate: (ptyId) => {
|
||||
if (
|
||||
this.headlessTerminals.has(ptyId) ||
|
||||
@@ -164,7 +162,25 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
|
||||
},
|
||||
attachProvider: (ptyId) => {
|
||||
const attach = this.ptyController?.attach
|
||||
return attach ? (async () => attach(ptyId))() : null
|
||||
if (!attach) {
|
||||
return null
|
||||
}
|
||||
return (async () => {
|
||||
const attached = await attach(ptyId)
|
||||
const pty = attached ? this.ptysById.get(ptyId) : undefined
|
||||
// An idle agent may print nothing after attach; from now on its silence is observed.
|
||||
if (pty && pty.lastOutputAt === null) {
|
||||
pty.outputObservedSince = Date.now()
|
||||
}
|
||||
return attached
|
||||
})()
|
||||
}
|
||||
})
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,8 +95,12 @@ 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 subscriber-driven attach.
|
||||
this.terminalViewSubscribers.resetGeneration(ptyId)
|
||||
// Why: a respawn under the same session id needs its own main attach.
|
||||
this.mainDaemonAttachments.resetGeneration(ptyId)
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty) {
|
||||
pty.outputObservedSince = null
|
||||
}
|
||||
// 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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { buildTerminalWaitText } from '../../terminal-wait-tail-state'
|
||||
import {
|
||||
evaluateTuiIdle,
|
||||
outputClockAt,
|
||||
type QuietForegroundLane,
|
||||
type TuiIdleVerdict
|
||||
} from '../../tui-idle-evidence'
|
||||
@@ -145,7 +146,7 @@ export class RuntimeTerminalIdlePolls {
|
||||
ready: () => buildPtyTerminalWaitResult(handle, 'tui-idle', pty),
|
||||
blocked: (reason) => buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, reason),
|
||||
isQuiet: (lane) =>
|
||||
isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs, lane, () =>
|
||||
isQuietForQuiescence(outputClockAt(pty), this.deps.quiescenceMs, lane, () =>
|
||||
this.deps.hasCommandPainted(pty.ptyId)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
type AgentStateVerdict
|
||||
} from './agent-state-rules/agent-state-rules-engine'
|
||||
import type { TuiIdleHookTurn } from './tui-idle-hook-lane'
|
||||
import type {
|
||||
FirstPartyAgentStatus,
|
||||
TuiIdleEvaluationInput,
|
||||
TuiIdleEvidenceRecord
|
||||
import {
|
||||
outputClockAt,
|
||||
type FirstPartyAgentStatus,
|
||||
type TuiIdleEvaluationInput,
|
||||
type TuiIdleEvidenceRecord
|
||||
} from './tui-idle-evidence'
|
||||
|
||||
/** The runtime state every tui-idle site reads its evidence from. */
|
||||
@@ -61,7 +62,7 @@ function readAgentRuleVerdict(
|
||||
readScreenLines,
|
||||
readText: () => waitText().toLowerCase(),
|
||||
readTitleStatus: () => record.lastAgentStatus,
|
||||
hasOutputClock: record.lastOutputAt !== null
|
||||
hasOutputClock: outputClockAt(record) !== null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,7 +139,7 @@ export function ptyTuiIdleEvidence(
|
||||
agent !== 'qoder-cn' &&
|
||||
agent !== 'hermes' &&
|
||||
source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
|
||||
isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null),
|
||||
isKnownReadyPromptBody(waitText(), agent, readScreen, outputClockAt(pty) !== null),
|
||||
readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen),
|
||||
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText),
|
||||
readScreenInputVeto: screenInputVetoReader(source, agent, pty.ptyId),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { detectAgentStatusFromTitle } from '../../shared/agent-title-status'
|
||||
import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
@@ -63,6 +63,18 @@ describe('hasQuietReadyScreen', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('measures quiet from when main began receiving a stream that has printed nothing since', () => {
|
||||
vi.useFakeTimers({ now: 100_000 })
|
||||
try {
|
||||
const silent = record({ lastOutputAt: null, outputObservedSince: 100_000 })
|
||||
expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(false)
|
||||
vi.advanceTimersByTime(QUIESCENCE_MS)
|
||||
expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses without a ready screen', () => {
|
||||
expect(hasQuietReadyScreen(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -27,9 +27,15 @@ import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane'
|
||||
export type TuiIdleEvidenceRecord = {
|
||||
lastAgentStatus: AgentStatus | null
|
||||
lastOutputAt: number | null
|
||||
outputObservedSince?: number | null
|
||||
lastOscTitle?: string | null
|
||||
}
|
||||
|
||||
/** The output clock: last output, or when main began receiving a stream that has stayed silent since. */
|
||||
export function outputClockAt(record: TuiIdleEvidenceRecord): number | null {
|
||||
return record.lastOutputAt ?? record.outputObservedSince ?? null
|
||||
}
|
||||
|
||||
export type FirstPartyAgentStatus = {
|
||||
state: AgentStatusState
|
||||
updatedAt: number
|
||||
@@ -103,7 +109,8 @@ export function hasSustainedTitleIdle(
|
||||
|
||||
/** Why a missing clock is not quiet: an adopted or restored pane cannot measure it. */
|
||||
function hasQuietOutput(record: TuiIdleEvidenceRecord, quiescenceMs: number): boolean {
|
||||
return record.lastOutputAt !== null && Date.now() - record.lastOutputAt >= quiescenceMs
|
||||
const clock = outputClockAt(record)
|
||||
return clock !== null && Date.now() - clock >= quiescenceMs
|
||||
}
|
||||
|
||||
/** Closed lanes require stronger rest evidence; after-paint lanes require agent output beyond shell echo; open unknown panes may settle without an output clock. */
|
||||
|
||||
@@ -221,6 +221,7 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime
|
||||
signal
|
||||
}: { requireComposerMarker?: boolean; stopOnDialog?: boolean; signal?: AbortSignal } = {}
|
||||
): Promise<RuntimeTerminalWait> {
|
||||
this.assertTerminalOutputReachesMain(handle)
|
||||
const initialPtyId =
|
||||
this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
|
||||
const stop = new AbortController()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// 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 }
|
||||
}
|
||||
|
||||
function listTerminals(call: RuntimeCall): Promise<RuntimeTerminalSummary[]> {
|
||||
return call<{ terminals: RuntimeTerminalSummary[] }>('terminal.list').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)).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)).find((terminal) => terminal.ptyId === ptyId)
|
||||
await expect.poll(async () => (await survivor())?.connected, { timeout: 60_000 }).toBe(true)
|
||||
let firstOutputAt: number | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const lastOutputAt = (await survivor())?.lastOutputAt ?? null
|
||||
firstOutputAt ??= lastOutputAt
|
||||
return firstOutputAt !== null && lastOutputAt !== null && lastOutputAt > firstOutputAt
|
||||
},
|
||||
{ timeout: 5_000, message: 'main never received the surviving printer output' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// 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 ')
|
||||
const afterMount = (await survivor())!.lastOutputAt!
|
||||
await expect
|
||||
.poll(async () => (await survivor())?.lastOutputAt ?? 0, { timeout: 5_000 })
|
||||
.toBeGreaterThan(afterMount)
|
||||
} finally {
|
||||
await session.dispose(app).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user