Files
orca/src/relay/agent-hook-request.ts
T
Kelvin AmoabaandNeil 634c43787c Fix premature Claude automation completion and inherited CI failures (#24878)
* fix(agent-status): keep a Claude pane working until owed task wake-ups arrive

Claude wakes the main agent for each background task that ends, after the
task stops running. "Nothing running" was read as done, so automations
closed the terminal before the final turn.

Fixes #23942

* fix(agent-status): keep owed task wake-ups through failed turns, the cap and nested exits

A failed turn's task list now marks a vanished shell owed like a normal turn end does. Past
the cap only an already-announced sub-agent is forgotten. A process exit clears what is owed
only where the server admits it from the pane's owner.

* refactor(agent-status): move pane-scoped cache entry helpers out of listener-state

listener-state.ts passed the 300-line limit once main's and this branch's additions met.

* fix(agent-status): verify Claude task wake-up completion on its execution host

* fix(agent-status): canonicalize Claude background task identifiers

* Preserve out-of-order Claude task notification evidence

* Verify retained released parser in cross-version checkout test

* Reuse watcher directory-cache enumeration without losing fresh keys

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-10-06 01:02:18 -07:00

120 lines
5.0 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'node:http'
import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits'
import { normalizeHookPayload } from '../shared/agent-hook-listener'
import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope'
import { readRequestBody } from '../shared/agent-hook-listener/request-body'
import { resolveHookSource } from '../shared/agent-hook-listener/source-routing'
import type { createHookTransportInterferenceTracker } from '../shared/agent-hook-transport-interference'
import { isHookRequestTruncatedError } from '../shared/agent-hook-transport-interference'
import type { HookListenerState } from '../shared/agent-hook-listener/listener-state'
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
import type { AgentHookSource } from '../shared/agent-hook-relay'
import type { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler'
import { hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build'
import { bindOpenCodeTuiSession } from '../shared/agent-hook-listener/opencode-session-registry'
export async function handleRelayHookRequest(
req: IncomingMessage,
res: ServerResponse,
options: {
token: string
env: string
state: HookListenerState
isPaneSurfaceRetired: (paneKey: string) => boolean
getAgentLaunchToken: (paneKey: string) => string | undefined
applyEvent: (
event: AgentHookEventPayload,
source: AgentHookSource,
env?: string,
version?: string
) => AgentHookEventPayload | undefined
ingestTmuxHook?: (source: AgentHookSource, body: unknown) => Promise<boolean>
retryScheduler: AgentHookResultRetryScheduler
transportInterference: ReturnType<typeof createHookTransportInterferenceTracker>
}
): Promise<void> {
if (req.method !== 'POST') {
res.writeHead(404)
res.end()
return
}
if (req.headers['x-orca-agent-hook-token'] !== options.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 pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
const source = resolveHookSource(pathname)
if (!source) {
res.writeHead(404)
res.end()
return
}
const body = await readRequestBody(req)
const hookBody = mergeAgentHookRequestHeaders(body, req.headers)
if (await options.ingestTmuxHook?.(source, hookBody)) {
res.writeHead(204)
res.end()
return
}
let admittedOpenCodeTuiOwner = false
const event = normalizeHookPayload(options.state, source, hookBody, options.env, {
deferCompactOwnershipToClient: true,
admitOpenCodeTui: (identity) => {
const admission = admitRelayOpenCodeTui(options, identity)
admittedOpenCodeTuiOwner = admission === true
return admission
}
})
if (event) {
// TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result.
const env = hookBodyEnv(hookBody)
const version = hookBodyVersion(hookBody)
const stored = options.applyEvent(event, source, env, version)
if (stored) {
if (admittedOpenCodeTuiOwner) {
bindOpenCodeTuiSession(options.state, source, hookBody, event.providerSession?.id)
}
options.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, stored, env, version)
options.retryScheduler.scheduleTranscriptPoll(source, hookBody, stored, env, version)
options.retryScheduler.armClaudeOwedNotificationExpiry(source, stored.paneKey, 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) {
options.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()
}
}
export function admitRelayOpenCodeTui(
host: {
isPaneSurfaceRetired: (paneKey: string) => boolean
getAgentLaunchToken: (paneKey: string) => string | undefined
},
identity: Pick<AgentHookEventPayload, 'paneKey' | 'launchToken'>
): boolean | 'preserve-poster' {
if (host.isPaneSurfaceRetired(identity.paneKey)) {
return false
}
const expected = host.getAgentLaunchToken(identity.paneKey)
return expected ? identity.launchToken?.trim() === expected : 'preserve-poster'
}