mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
Review found the completion did not clear the pane STA-2915 actually reports, and that republishing it was a strict regression. - A manual completion now retires a subagent that exists only as a disk snapshot: a /compact only completes at an idle prompt, so a restored child is proof of nothing. Live evidence -- a child observed in this runtime, an unclassifiable running background task, a registered session cron -- still holds the pane. - A completion that cannot clear now publishes nothing instead of restating the row, which was stripping restoredUnconfirmed off a hydrated row and restarting the staleness clock for work the compact never observed. - The relay defers compact ownership to the client that owns pane identity, so a cold relay cache can no longer swallow the one event that clears a remote pane. - claudeConsumedCompactPromptIdByPaneKey joins all three pane-scoped teardown routes, and an auto compact no longer spends the pane's consumed-compact slot. - The promptless completion keeps the summarized turn's label with or without a trigger on the envelope. Tests: the two restart cases now deliver the completion while the hydrated row is still cached, so they exercise the restored-row branch instead of passing through the strict one; the triggerless working replay is asserted from a FINISHED pane so it can fail. Reverting the four source files turns 12 of 21 registration-gated and 8 of 12 relay/ingest tests red, and 18 of 18 targeted mutations are caught.
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
// Relay-side adapter for the shared agent-hook listener: hosts a loopback HTTP server and
|
|
// forwards each parsed payload via a callback so `relay.ts` re-emits it as an `agent.hook`
|
|
// JSON-RPC notification over the SSH channel. Replay cache is bounded one-entry-per-paneKey: a
|
|
// reattaching Orca only needs each pane's current status, never its history, and the bound keeps a
|
|
// long-lived relay from growing with every event.
|
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { join } from 'node:path'
|
|
|
|
import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types'
|
|
import {
|
|
clearAllListenerCaches,
|
|
clearPaneCacheState,
|
|
createHookListenerState,
|
|
getEndpointFileName,
|
|
HOOK_REQUEST_SLOWLORIS_MS,
|
|
normalizeHookPayload,
|
|
readRequestBody,
|
|
resolveHookSource,
|
|
writeEndpointFile,
|
|
type AgentHookEventPayload,
|
|
type HookListenerState
|
|
} from '../shared/agent-hook-listener'
|
|
import {
|
|
createHookTransportInterferenceTracker,
|
|
describeHookTransportInterference,
|
|
isHookRequestTruncatedError
|
|
} from '../shared/agent-hook-transport-interference'
|
|
import {
|
|
REMOTE_AGENT_HOOK_ENV,
|
|
type AgentHookRelayEnvelope,
|
|
type AgentHookSource
|
|
} from '../shared/agent-hook-relay'
|
|
import { buildRelayHookPtyEnv, defaultEndpointDir } from './agent-hook-endpoint-coordinates'
|
|
import { buildRelayHookEnvelope, hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build'
|
|
import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler'
|
|
|
|
export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void
|
|
|
|
// Why: WSL lacks per-pane teardown, so cap replay-cache recency.
|
|
const MAX_CACHED_PANES = 256
|
|
|
|
export type RelayHookServerOptions = {
|
|
/** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */
|
|
endpointDir?: string
|
|
/** Env tag forwarded into hook payloads. Defaults to "remote", which main excludes from dev-vs-prod mismatch warnings. */
|
|
env?: string
|
|
/** Fixed auth token. WSL relay passes the host-issued token (already in guest env via WSLENV) so unmodified hook clients authenticate. Defaults to a fresh UUID. */
|
|
token?: string
|
|
/** Preferred bind port. WSL relay passes the Windows listener's port so env-sourced client coords stay truthful; falls back to :0 if occupied. Defaults to :0. */
|
|
preferredPort?: number
|
|
/** Called once per parsed payload; the relay wires this to `dispatcher.notify('agent.hook', envelope)`. */
|
|
forward: RelayHookForward
|
|
}
|
|
|
|
export type RelayHookServerStartOptions = {
|
|
publishEndpoint?: boolean
|
|
}
|
|
|
|
export class RelayAgentHookServer {
|
|
private server: ReturnType<typeof createServer> | null = null
|
|
private port = 0
|
|
private token = ''
|
|
private env: string
|
|
private endpointDir: string
|
|
private endpointFilePath: string
|
|
private endpointFileWritten = false
|
|
private state: HookListenerState = createHookListenerState()
|
|
private transportInterference = createHookTransportInterferenceTracker((report) => {
|
|
process.stderr.write(`${describeHookTransportInterference(report)}\n`)
|
|
})
|
|
// Why: retain envelope metadata so replays match live POSTs.
|
|
// Invariant: keys mirror state.lastStatusByPaneKey, populated/cleared in lockstep.
|
|
private lastEnvelopeMetaByPaneKey = new Map<
|
|
string,
|
|
{ source: AgentHookSource; env?: string; version?: string }
|
|
>()
|
|
private forward: RelayHookForward
|
|
private fixedToken: string | undefined
|
|
private preferredPort: number
|
|
private portFallbackApplied = false
|
|
private retryScheduler: AgentHookResultRetryScheduler
|
|
|
|
constructor(options: RelayHookServerOptions) {
|
|
this.env = options.env ?? REMOTE_AGENT_HOOK_ENV
|
|
this.endpointDir = options.endpointDir ?? defaultEndpointDir()
|
|
this.endpointFilePath = join(this.endpointDir, getEndpointFileName())
|
|
this.fixedToken = options.token
|
|
this.preferredPort = options.preferredPort ?? 0
|
|
this.forward = options.forward
|
|
this.retryScheduler = new AgentHookResultRetryScheduler({
|
|
state: this.state,
|
|
env: this.env,
|
|
isListening: () => this.server !== null,
|
|
applyEvent: (event, source, env, version) => {
|
|
this.applyEvent(event, source, env, version)
|
|
}
|
|
})
|
|
}
|
|
|
|
async start(options: RelayHookServerStartOptions = {}): Promise<void> {
|
|
if (this.server) {
|
|
return
|
|
}
|
|
this.token = this.fixedToken ?? randomUUID()
|
|
this.endpointFileWritten = false
|
|
this.portFallbackApplied = false
|
|
try {
|
|
await this.listenOn(this.preferredPort)
|
|
} catch (err) {
|
|
// Why: fall back to an ephemeral port on EADDRINUSE; clients use the endpoint file.
|
|
if (this.preferredPort > 0 && (err as NodeJS.ErrnoException)?.code === 'EADDRINUSE') {
|
|
this.portFallbackApplied = true
|
|
await this.listenOn(0)
|
|
} else {
|
|
throw err
|
|
}
|
|
}
|
|
if (options.publishEndpoint !== false) {
|
|
this.publishEndpointFile()
|
|
}
|
|
}
|
|
|
|
/** True when the preferred port was occupied and the server fell back to an ephemeral bind. */
|
|
get usedPortFallback(): boolean {
|
|
return this.portFallbackApplied
|
|
}
|
|
|
|
private listenOn(port: number): Promise<void> {
|
|
this.server = createServer((req, res) => this.handleRequest(req, res))
|
|
return new Promise<void>((resolve, reject) => {
|
|
const onStartupError = (err: Error): void => {
|
|
this.server?.off('listening', onListening)
|
|
// Why: clear failed server refs so later start() calls can retry.
|
|
this.server = null
|
|
reject(err)
|
|
}
|
|
const onListening = (): void => {
|
|
this.server?.off('error', onStartupError)
|
|
this.server?.on('error', (err) => {
|
|
process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`)
|
|
})
|
|
const address = this.server!.address()
|
|
if (address && typeof address === 'object') {
|
|
this.port = address.port
|
|
}
|
|
resolve()
|
|
}
|
|
this.server!.once('error', onStartupError)
|
|
// Why: loopback only — reachable by the in-box agent CLI (127.0.0.1), not from outside the box.
|
|
this.server!.listen(port, '127.0.0.1', onListening)
|
|
})
|
|
}
|
|
|
|
publishEndpointFile(): boolean {
|
|
if (this.port <= 0 || !this.token) {
|
|
this.endpointFileWritten = false
|
|
return false
|
|
}
|
|
this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, {
|
|
port: this.port,
|
|
token: this.token,
|
|
env: this.env,
|
|
version: ORCA_HOOK_PROTOCOL_VERSION
|
|
})
|
|
return this.endpointFileWritten
|
|
}
|
|
|
|
stop(): void {
|
|
this.server?.close()
|
|
this.server = null
|
|
this.port = 0
|
|
this.token = ''
|
|
this.endpointFileWritten = false
|
|
this.retryScheduler.clearAll()
|
|
clearAllListenerCaches(this.state)
|
|
this.lastEnvelopeMetaByPaneKey.clear()
|
|
}
|
|
|
|
/** Request-driven replay: re-forwards each cached paneKey payload as a fresh notification. Forwards are
|
|
* issued before the request handler returns, so the response trails all replayed notifications. */
|
|
replayCachedPayloadsForPanes(): number {
|
|
let count = 0
|
|
for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) {
|
|
const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey)
|
|
// Why: invariant — status-cache keys always have meta; if it drifted, skip rather than guess a source that mis-tags downstream.
|
|
if (!meta) {
|
|
continue
|
|
}
|
|
this.forward(
|
|
buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true })
|
|
)
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
/** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */
|
|
clearPaneState(paneKey: string): void {
|
|
this.retryScheduler.clearAssistantMessageRetry(paneKey)
|
|
this.retryScheduler.clearCodexSubagentPoll(paneKey)
|
|
clearPaneCacheState(this.state, paneKey)
|
|
this.lastEnvelopeMetaByPaneKey.delete(paneKey)
|
|
}
|
|
|
|
/** Env vars to inject into relay-spawned PTYs so the hook script/plugin POSTs back to this loopback server. */
|
|
buildPtyEnv(): Record<string, string> {
|
|
return buildRelayHookPtyEnv({
|
|
port: this.port,
|
|
token: this.token,
|
|
env: this.env,
|
|
endpointFilePath: this.endpointFilePath,
|
|
endpointFileWritten: this.endpointFileWritten
|
|
})
|
|
}
|
|
|
|
/** Test-only / diagnostics accessor. */
|
|
getCoordinates(): { port: number; token: string; endpointFilePath: string } {
|
|
return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath }
|
|
}
|
|
|
|
// ─── Private ──────────────────────────────────────────────────────
|
|
|
|
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
if (req.method !== 'POST') {
|
|
res.writeHead(404)
|
|
res.end()
|
|
return
|
|
}
|
|
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
|
|
res.writeHead(403)
|
|
res.end()
|
|
return
|
|
}
|
|
// Why: track our own destroy so the slowloris cap can't be misread as outside interference.
|
|
let destroyedBySlowlorisCap = false
|
|
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
|
|
destroyedBySlowlorisCap = true
|
|
req.destroy()
|
|
})
|
|
try {
|
|
const body = await readRequestBody(req)
|
|
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
|
const source = resolveHookSource(pathname)
|
|
if (!source) {
|
|
res.writeHead(404)
|
|
res.end()
|
|
return
|
|
}
|
|
const event = normalizeHookPayload(this.state, source, body, this.env, {
|
|
deferCompactOwnershipToClient: true
|
|
})
|
|
if (event) {
|
|
// TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result.
|
|
const env = hookBodyEnv(body)
|
|
const version = hookBodyVersion(body)
|
|
this.applyEvent(event, source, env, version)
|
|
this.retryScheduler.scheduleAssistantMessageRetry(source, body, event, env, version)
|
|
this.retryScheduler.scheduleCodexSubagentPoll(source, body, event, env, version)
|
|
}
|
|
res.writeHead(204)
|
|
res.end()
|
|
} catch (err) {
|
|
// Why (#11217): a remote host can run the same IDS; count truncations here so a blocked SSH
|
|
// relay reports the cause instead of an anonymous "hook request failed".
|
|
if (isHookRequestTruncatedError(err) && !destroyedBySlowlorisCap) {
|
|
this.transportInterference.record({ source: null, error: err })
|
|
}
|
|
// Why: hooks fail open (204 on any error) so a buggy agent never blocks the run; still log so the 204 doesn't mask bugs.
|
|
process.stderr.write(
|
|
`[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n`
|
|
)
|
|
res.writeHead(204)
|
|
res.end()
|
|
}
|
|
}
|
|
|
|
private applyEvent(
|
|
event: AgentHookEventPayload,
|
|
source: AgentHookSource,
|
|
env?: string,
|
|
version?: string
|
|
): void {
|
|
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
|
|
this.retryScheduler.clearAssistantMessageRetry(event.paneKey)
|
|
}
|
|
// Why: a reconnecting client receives the pane's last event as an isReplay rebuild of this
|
|
// cache, and after a compact that entry IS the PostCompact. Cache it stripped of its compact
|
|
// identity so the replay arrives as an ordinary status row the client applies normally —
|
|
// otherwise the only channel that can deliver the clearing `done` to a client that was offline
|
|
// during the compact is the one path a compact-event guard would reject.
|
|
const cachedEvent =
|
|
event.hookEventName === 'PostCompact'
|
|
? { ...event, hookEventName: undefined, compactTrigger: undefined }
|
|
: event
|
|
// Why: delete-then-set makes Map insertion order = recency, so the cap below evicts the longest-idle pane.
|
|
this.state.lastStatusByPaneKey.delete(event.paneKey)
|
|
this.state.lastStatusByPaneKey.set(event.paneKey, cachedEvent)
|
|
this.lastEnvelopeMetaByPaneKey.delete(event.paneKey)
|
|
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
|
|
while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) {
|
|
const oldest = this.state.lastStatusByPaneKey.keys().next().value
|
|
if (oldest === undefined) {
|
|
break
|
|
}
|
|
this.clearPaneState(oldest)
|
|
}
|
|
this.forward(buildRelayHookEnvelope(event, source, env, version))
|
|
}
|
|
}
|