mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
* 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
538 lines
17 KiB
TypeScript
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)
|
|
)
|
|
}
|