Files
orca/src/main/daemon/client.ts
T
Jinjing ca5a821600 Stop relaunching creation-time agents on workspace activation (#10647)
* fix(activation): stop relaunching the creation-time agent on workspace activation

Activating a workspace with zero renderable tabs launched the agent it was
created with, unprompted and in approval-bypass mode. Navigation is not consent
to start a process: the same fallback fired from post-delete focus handoff, the
jump palette, keyboard cycling, CLI/relay activation, and notification clicks.

The mechanism was superseded. #1814 added it when relaunching the created agent
*was* the resume feature; #4706 later added real provider-session resume six
lines above and left the fallback in place. What remained fired whenever a
workspace had no renderable tabs -- including when nothing had ever slept -- and
reported itself as `request_kind: 'resume'` while resuming nothing, discarding
any resumable session a plain tab close had already purged.

No caller depends on it. All seven intent-carrying callers pass an explicit
`startup` on the branch where they intend a launch, and every no-startup branch
either declined an agent, already has one running (host `didSpawnStartup`), or
is this same defect arriving over IPC.

Drops the now-orphaned imports, retargets the stale comment in
launch-work-item-direct that cited reopen-relaunch as the reason to persist
`createdWithAgent`, and moves the WSL default-args quoting assertion to
launch-agent-in-new-tab, whose launch path still resolves those args.

Regression tests are revert-sensitive -- all four fail if the fallback returns.

* test(activation): name the relaunch regression tests after what they reach

Three tests were named after scenarios they never invoked, which is the
failure mode that lets a coverage gap read as closed.

- The "host-originated" test's `notifyHostRuntime: false` is inert here: both
  gates resolve through `isWebRuntimeSessionActive`, false with no runtime
  environment seeded, so it was byte-identical to the plain reopen test. It no
  longer claims to cover the host `didSpawnStartup` leg, which lives in main and
  is unreachable from this layer.
- The "post-delete focus handoff" test never deleted anything and never touched
  `prepareActiveWorktreeFocusAfterDelete`. That caller is asserted directly in
  active-worktree-focus-after-delete.test.ts, which locks out any opts.
- The activate/close loop resets state instead of calling `closeTab`, so it does
  not exercise the sleeping-record purge its comment claimed.

Also folds the primary reopen test onto `seedEmptyActivatableWorktree` — the
fixture extracted for exactly that state, which its inline copy had drifted from
by hardcoding a POSIX repo path.

`preflight` is dropped from the launch-work-item-direct comment: the trust
preflight reads the create-time argument (worktree-remote.ts), not the persisted
meta. Removal safety and ownership do read the field and remain accurate.

Renames the ported quoting test to what it pins. Under vitest's node
environment `navigator.userAgent` carries no "Windows", so platform resolution
bails before the WSL branch and the WSL preference is inert — the real coverage
is single-quote escaping of user-configured agentDefaultArgs.

* transfer large terminal history seeds across bounded protocol messages

- Oversized cold-restore snapshots (>1MB) now upload via chunked startHistorySeedTransfer/appendHistorySeedTransfer protocol instead of inline, avoiding NDJSON line-size violations
- Checkpoints automatically trim oldest rows to fit within configured byte limit (200MB) before commit
- Protocol v30 required for chunked transfers; v29 daemons gracefully fall back to renderer-only recovery
- NDJSON encodeNdjson() validates line size and rejects oversized payloads; notifications silently swallow encoding errors

* fix(daemon): drop held output when teardown checkpoint fails to serializ

When a final snapshot checkpoint fails to serialize (returns retryable), the
pending output records must not be appended later—doing so would splice them
over the seq gap left by the failed snapshot, defeating gap detection. Drop
the records and retry the checkpoint instead.

* Bump daemon protocol version to 30

* Bump daemon protocol version to 30
2026-07-28 15:12:44 -07:00

538 lines
17 KiB
TypeScript

/* eslint-disable max-lines -- Why: daemon handshake, RPC, stream events, and reconnect cleanup share one socket lifecycle. */
import { connect, type Socket } from 'node:net'
import { readFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { StringDecoder } from 'node:string_decoder'
import { encodeNdjson, createNdjsonParser } from './ndjson'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
NOTIFY_PREFIX,
DaemonProtocolError
} from './types'
import type {
DaemonEndpointIdentity,
HelloMessage,
HelloResponse,
RpcResponse,
DaemonEvent
} from './types'
import { addNodePtyRecoveryHint } from './node-pty-error-hints'
const CONNECT_TIMEOUT_MS = 5000
const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4
const REQUEST_TIMEOUT_MS = 30000
export type DaemonClientOptions = {
socketPath: string
tokenPath: string
protocolVersion?: number
}
type PendingRequest = {
resolve: (value: unknown) => void
reject: (reason: Error) => void
timer: ReturnType<typeof setTimeout>
}
export class DaemonClient {
private socketPath: string
private tokenPath: string
private protocolVersion: number
private clientId = randomUUID()
private controlSocket: Socket | null = null
private streamSocket: Socket | null = null
private connected = false
private disconnectArmed = false
// Why: after a disconnect + reconnect (daemon respawn), a stale 'close'
// event from the old sockets can fire. Without a generation check, that
// event would tear down the fresh connection. Each doConnect() increments
// the generation; handleDisconnect ignores events from old generations.
private connectionGeneration = 0
// Why: multiple concurrent spawn() calls from simultaneous pane mounts
// all call ensureConnected(). Without a lock, each starts a separate
// connection attempt, overwriting sockets and triggering "Connection lost".
private connectingPromise: Promise<void> | null = null
private connectionAttemptGeneration = 0
private daemonIdentity: DaemonEndpointIdentity | null = null
private observedAuthenticatedDisconnect = false
private pendingRequests = new Map<string, PendingRequest>()
private eventListeners: ((event: unknown) => void)[] = []
private disconnectedListeners: (() => void)[] = []
private requestCounter = 0
private cleanupSocketListeners: (() => void) | null = null
constructor(opts: DaemonClientOptions) {
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
}
isConnected(): boolean {
return this.connected
}
getDaemonIdentity(): DaemonEndpointIdentity | null {
return this.daemonIdentity ? { ...this.daemonIdentity } : null
}
hasObservedAuthenticatedDisconnect(): boolean {
return this.observedAuthenticatedDisconnect
}
async ensureConnected(): Promise<void> {
return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false)
}
async ensureConnectedWithin(timeoutMs: number): Promise<void> {
return this.ensureConnectedWithTimeout(timeoutMs, true)
}
private async ensureConnectedWithTimeout(
timeoutMs: number,
sharedBudget: boolean
): Promise<void> {
if (this.connected) {
return
}
if (this.connectingPromise) {
// Why: a normal connection may legitimately consume one timeout for each
// socket and hello; bounded teardown calls instead keep their one shared budget.
const waiterTimeoutMs = sharedBudget ? timeoutMs : CONNECTION_ATTEMPT_WAIT_MS
return this.waitForConnectionAttempt(this.connectingPromise, waiterTimeoutMs)
}
const attemptGeneration = this.connectionAttemptGeneration
this.connectingPromise = this.doConnect(timeoutMs, attemptGeneration, sharedBudget)
try {
await this.connectingPromise
} finally {
this.connectingPromise = null
}
}
private async doConnect(
timeoutMs: number,
attemptGeneration: number,
sharedBudget: boolean
): Promise<void> {
const token = readFileSync(this.tokenPath, 'utf-8').trim()
const deadlineMs = Date.now() + timeoutMs
const remainingMs = (): number =>
sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs
const pendingListenerCleanups: (() => void)[] = []
const cleanupPendingListeners = (): void => {
for (const cleanup of pendingListenerCleanups.splice(0)) {
cleanup()
}
}
try {
// Sequential: control first, then stream
const pendingControlSocket = await this.connectSocket(remainingMs())
this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket)
this.controlSocket = pendingControlSocket
const controlIdentity = await this.sendHello(
this.controlSocket,
token,
'control',
remainingMs()
)
this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket)
pendingListenerCleanups.push(this.setupControlParser(this.controlSocket))
const pendingStreamSocket = await this.connectSocket(remainingMs())
this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket)
this.streamSocket = pendingStreamSocket
const streamIdentity = await this.sendHello(this.streamSocket, token, 'stream', remainingMs())
this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket)
if (!sameDaemonIdentity(controlIdentity, streamIdentity)) {
throw new DaemonProtocolError('Daemon identity changed during connection')
}
pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket))
this.assertConnectionAttemptCurrent(attemptGeneration)
this.connected = true
this.observedAuthenticatedDisconnect = false
this.daemonIdentity = controlIdentity
this.disconnectArmed = true
this.connectionGeneration++
const gen = this.connectionGeneration
const handleClose = () => this.handleDisconnect(gen)
const controlSocket = this.controlSocket
const streamSocket = this.streamSocket
controlSocket.on('close', handleClose)
controlSocket.on('error', handleClose)
streamSocket.on('close', handleClose)
streamSocket.on('error', handleClose)
pendingListenerCleanups.push(() => {
controlSocket.off('close', handleClose)
controlSocket.off('error', handleClose)
streamSocket.off('close', handleClose)
streamSocket.off('error', handleClose)
})
this.cleanupSocketListeners = cleanupPendingListeners
} catch (error) {
cleanupPendingListeners()
this.controlSocket?.destroy()
this.streamSocket?.destroy()
this.controlSocket = null
this.streamSocket = null
this.connected = false
this.daemonIdentity = null
this.disconnectArmed = false
throw error
}
}
async request<T = unknown>(
type: string,
payload: unknown,
timeoutMs = REQUEST_TIMEOUT_MS
): Promise<T> {
if (!this.connected || !this.controlSocket) {
throw new DaemonProtocolError('Not connected')
}
const id = `req-${++this.requestCounter}`
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
const encoded = encodeNdjson(msg)
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id)
reject(new DaemonProtocolError(`Request ${type} timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timer
})
this.controlSocket!.write(encoded)
})
}
// Why: fire-and-forget writes need a local delivery signal to trigger dead-endpoint recovery.
notify(type: string, payload: unknown): boolean {
if (!this.connected || !this.controlSocket) {
return false
}
const id = `${NOTIFY_PREFIX}${++this.requestCounter}`
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
try {
this.controlSocket.write(encodeNdjson(msg))
return true
} catch {
// Notifications are best-effort; an oversized payload must not tear down the caller.
return false
}
}
onEvent(listener: (event: unknown) => void): () => void {
this.eventListeners.push(listener)
return () => {
const idx = this.eventListeners.indexOf(listener)
if (idx !== -1) {
this.eventListeners.splice(idx, 1)
}
}
}
onDisconnected(listener: () => void): () => void {
this.disconnectedListeners.push(listener)
return () => {
const idx = this.disconnectedListeners.indexOf(listener)
if (idx !== -1) {
this.disconnectedListeners.splice(idx, 1)
}
}
}
disconnect(): void {
this.connectionAttemptGeneration++
this.connected = false
this.daemonIdentity = null
this.disconnectArmed = false
this.cleanupActiveSocketListeners()
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new DaemonProtocolError('Disconnected'))
this.pendingRequests.delete(id)
}
this.controlSocket?.destroy()
this.streamSocket?.destroy()
this.controlSocket = null
this.streamSocket = null
}
private connectSocket(timeoutMs: number): Promise<Socket> {
return new Promise((resolve, reject) => {
const socket = connect(this.socketPath)
const cleanup = (): void => {
clearTimeout(timer)
socket.removeListener('connect', onConnect)
socket.removeListener('error', onError)
}
const onConnect = (): void => {
cleanup()
resolve(socket)
}
const onError = (err: Error): void => {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
socket.destroy()
reject(new DaemonProtocolError('Connection timed out'))
}, timeoutMs)
socket.on('connect', onConnect)
socket.on('error', onError)
})
}
private waitForConnectionAttempt(attempt: Promise<void>, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new DaemonProtocolError('Connection attempt wait timed out'))
}, timeoutMs)
attempt.then(
() => {
clearTimeout(timer)
resolve()
},
(error) => {
clearTimeout(timer)
reject(error)
}
)
})
}
private assertConnectionAttemptCurrent(attemptGeneration: number, socket?: Socket): void {
if (attemptGeneration === this.connectionAttemptGeneration) {
return
}
socket?.destroy()
throw new DaemonProtocolError('Disconnected')
}
private sendHello(
socket: Socket,
token: string,
role: 'control' | 'stream',
timeoutMs: number
): Promise<DaemonEndpointIdentity | null> {
return new Promise((resolve, reject) => {
const hello: HelloMessage = {
type: 'hello',
version: this.protocolVersion,
token,
clientId: this.clientId,
role
}
let buffer = ''
let settled = false
let timer: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
socket.removeListener('data', onData)
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
}
const finish = (error?: Error, identity: DaemonEndpointIdentity | null = null): void => {
if (settled) {
return
}
settled = true
cleanup()
if (error) {
reject(error)
return
}
resolve(identity)
}
// Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes.
// Decoding each Buffer independently would permanently inject U+FFFD.
const decoder = new StringDecoder('utf8')
const onData = (chunk: Buffer): void => {
buffer += decoder.write(chunk)
const newlineIdx = buffer.indexOf('\n')
if (newlineIdx === -1) {
return
}
const line = buffer.slice(0, newlineIdx)
try {
const response = JSON.parse(line) as HelloResponse
if (response.ok) {
const identity = parseDaemonEndpointIdentity(response.daemonIdentity)
if (
(this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION && identity === null) ||
(response.daemonIdentity !== undefined && identity === null)
) {
finish(new DaemonProtocolError('Invalid daemon identity'))
return
}
finish(undefined, identity)
} else {
finish(
new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected'))
)
}
} catch {
finish(new DaemonProtocolError('Invalid hello response'))
}
}
const onError = (error: Error): void => finish(error)
const onClose = (): void =>
finish(new DaemonProtocolError('Connection closed before hello response'))
timer = setTimeout(() => {
// Why: a stale daemon can accept the socket but never answer hello;
// without a handshake timeout, startup waits forever on ensureConnected().
finish(new DaemonProtocolError('Hello response timed out'))
socket.destroy()
}, timeoutMs)
socket.on('data', onData)
socket.on('error', onError)
socket.on('close', onClose)
socket.write(encodeNdjson(hello))
})
}
private setupControlParser(socket: Socket): () => void {
// Why: control responses may contain terminal/startup data with multibyte
// text; keep incomplete UTF-8 bytes until the next socket chunk.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => {
const response = msg as RpcResponse
if (response.id) {
const pending = this.pendingRequests.get(response.id)
if (pending) {
this.pendingRequests.delete(response.id)
clearTimeout(pending.timer)
if (response.ok) {
pending.resolve(response.payload)
} else {
pending.reject(new DaemonProtocolError(addNodePtyRecoveryHint(response.error)))
}
}
}
},
() => {} // Ignore parse errors on control socket
)
const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk))
socket.on('data', onData)
return () => socket.off('data', onData)
}
private setupStreamParser(socket: Socket): () => void {
// Why: PTY output streams include emoji/box-drawing tables; socket chunks
// can split those UTF-8 sequences across packets.
const decoder = new StringDecoder('utf8')
const parser = createNdjsonParser(
(msg) => {
const event = msg as DaemonEvent
if (event.type === 'event') {
for (const listener of this.eventListeners) {
listener(event)
}
}
},
() => {} // Ignore parse errors on stream socket
)
const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk))
socket.on('data', onData)
return () => socket.off('data', onData)
}
private handleDisconnect(generation: number): void {
if (!this.disconnectArmed || generation !== this.connectionGeneration) {
return
}
this.disconnectArmed = false
this.connectionAttemptGeneration++
if (this.daemonIdentity) {
this.observedAuthenticatedDisconnect = true
}
this.connected = false
this.daemonIdentity = null
this.cleanupActiveSocketListeners()
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer)
pending.reject(new DaemonProtocolError('Connection lost'))
this.pendingRequests.delete(id)
}
this.controlSocket?.destroy()
this.streamSocket?.destroy()
this.controlSocket = null
this.streamSocket = null
for (const listener of this.disconnectedListeners) {
listener()
}
}
private cleanupActiveSocketListeners(): void {
const cleanup = this.cleanupSocketListeners
this.cleanupSocketListeners = null
cleanup?.()
}
}
function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | null {
if (!value || typeof value !== 'object') {
return null
}
const identity = value as { pid?: unknown; startedAtMs?: unknown; launchNonce?: unknown }
if (
!Number.isSafeInteger(identity.pid) ||
(identity.pid as number) <= 0 ||
typeof identity.startedAtMs !== 'number' ||
!Number.isFinite(identity.startedAtMs) ||
identity.startedAtMs <= 0 ||
typeof identity.launchNonce !== 'string' ||
identity.launchNonce.length === 0
) {
return null
}
return {
pid: identity.pid as number,
startedAtMs: identity.startedAtMs,
launchNonce: identity.launchNonce
}
}
function sameDaemonIdentity(
left: DaemonEndpointIdentity | null,
right: DaemonEndpointIdentity | null
): boolean {
return (
(left === null && right === null) ||
(left !== null &&
right !== null &&
left.pid === right.pid &&
left.startedAtMs === right.startedAtMs &&
left.launchNonce === right.launchNonce)
)
}