2026-02-26 11:00:09 +01:00
import crypto from "node:crypto" ;
import { getAcpSessionManager } from "../acp/control-plane/manager.js" ;
import {
cleanupFailedAcpSpawn ,
type AcpSpawnRuntimeCloseHandle ,
} from "../acp/control-plane/spawn.js" ;
import { isAcpEnabledByPolicy , resolveAcpAgentPolicyError } from "../acp/policy.js" ;
import {
resolveAcpSessionCwd ,
resolveAcpThreadSessionDetailLines ,
} from "../acp/runtime/session-identifiers.js" ;
import type { AcpRuntimeSessionMode } from "../acp/runtime/types.js" ;
2026-03-10 13:42:15 -07:00
import { DEFAULT_HEARTBEAT_EVERY } from "../auto-reply/heartbeat.js" ;
2026-02-26 11:00:09 +01:00
import {
resolveThreadBindingIntroText ,
resolveThreadBindingThreadName ,
} from "../channels/thread-bindings-messages.js" ;
import {
formatThreadBindingDisabledError ,
formatThreadBindingSpawnDisabledError ,
2026-02-27 10:02:39 +01:00
resolveThreadBindingIdleTimeoutMsForChannel ,
resolveThreadBindingMaxAgeMsForChannel ,
2026-02-26 11:00:09 +01:00
resolveThreadBindingSpawnPolicy ,
} from "../channels/thread-bindings-policy.js" ;
2026-03-10 13:42:15 -07:00
import { parseDurationMs } from "../cli/parse-duration.js" ;
2026-02-26 11:00:09 +01:00
import { loadConfig } from "../config/config.js" ;
import type { OpenClawConfig } from "../config/config.js" ;
2026-03-08 19:37:00 +01:00
import { loadSessionStore , resolveStorePath , type SessionEntry } from "../config/sessions.js" ;
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js" ;
2026-02-26 11:00:09 +01:00
import { callGateway } from "../gateway/call.js" ;
2026-03-10 13:42:15 -07:00
import { areHeartbeatsEnabled } from "../infra/heartbeat-wake.js" ;
2026-02-26 11:00:09 +01:00
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js" ;
import {
getSessionBindingService ,
isSessionBindingError ,
type SessionBindingRecord ,
} from "../infra/outbound/session-binding-service.js" ;
2026-03-08 19:37:00 +01:00
import { createSubsystemLogger } from "../logging/subsystem.js" ;
2026-03-10 13:42:15 -07:00
import {
isSubagentSessionKey ,
normalizeAgentId ,
parseAgentSessionKey ,
} from "../routing/session-key.js" ;
import { deliveryContextFromSession , normalizeDeliveryContext } from "../utils/delivery-context.js" ;
2026-03-04 11:44:20 +01:00
import {
type AcpSpawnParentRelayHandle ,
resolveAcpSpawnStreamLogPath ,
startAcpSpawnParentStreamRelay ,
} from "./acp-spawn-parent-stream.js" ;
2026-03-10 13:42:15 -07:00
import { resolveAgentConfig , resolveDefaultAgentId } from "./agent-scope.js" ;
2026-03-02 23:50:38 +01:00
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js" ;
2026-03-08 19:37:00 +01:00
import { resolveInternalSessionKey , resolveMainSessionAlias } from "./tools/sessions-helpers.js" ;
const log = createSubsystemLogger ( "agents/acp-spawn" ) ;
2026-02-26 11:00:09 +01:00
export const ACP_SPAWN_MODES = [ "run" , "session" ] as const ;
export type SpawnAcpMode = ( typeof ACP_SPAWN_MODES ) [ number ] ;
2026-03-02 23:50:38 +01:00
export const ACP_SPAWN_SANDBOX_MODES = [ "inherit" , "require" ] as const ;
export type SpawnAcpSandboxMode = ( typeof ACP_SPAWN_SANDBOX_MODES ) [ number ] ;
2026-03-04 11:44:20 +01:00
export const ACP_SPAWN_STREAM_TARGETS = [ "parent" ] as const ;
export type SpawnAcpStreamTarget = ( typeof ACP_SPAWN_STREAM_TARGETS ) [ number ] ;
2026-02-26 11:00:09 +01:00
export type SpawnAcpParams = {
task : string ;
label? : string ;
agentId? : string ;
2026-03-10 02:36:13 -07:00
resumeSessionId? : string ;
2026-02-26 11:00:09 +01:00
cwd? : string ;
mode? : SpawnAcpMode ;
thread? : boolean ;
2026-03-02 23:50:38 +01:00
sandbox? : SpawnAcpSandboxMode ;
2026-03-04 11:44:20 +01:00
streamTo? : SpawnAcpStreamTarget ;
2026-02-26 11:00:09 +01:00
} ;
export type SpawnAcpContext = {
agentSessionKey? : string ;
agentChannel? : string ;
agentAccountId? : string ;
agentTo? : string ;
agentThreadId? : string | number ;
2026-03-02 23:50:38 +01:00
sandboxed? : boolean ;
2026-02-26 11:00:09 +01:00
} ;
export type SpawnAcpResult = {
status : "accepted" | "forbidden" | "error" ;
childSessionKey? : string ;
runId? : string ;
mode? : SpawnAcpMode ;
2026-03-04 11:44:20 +01:00
streamLogPath? : string ;
2026-02-26 11:00:09 +01:00
note? : string ;
error? : string ;
} ;
export const ACP_SPAWN_ACCEPTED_NOTE =
"initial ACP task queued in isolated session; follow-ups continue in the bound thread." ;
export const ACP_SPAWN_SESSION_ACCEPTED_NOTE =
"thread-bound ACP session stays active after this task; continue in-thread for follow-ups." ;
2026-03-08 00:22:57 +00:00
export function resolveAcpSpawnRuntimePolicyError ( params : {
cfg : OpenClawConfig ;
requesterSessionKey? : string ;
requesterSandboxed? : boolean ;
sandbox? : SpawnAcpSandboxMode ;
} ) : string | undefined {
const sandboxMode = params . sandbox === "require" ? "require" : "inherit" ;
const requesterRuntime = resolveSandboxRuntimeStatus ( {
cfg : params.cfg ,
sessionKey : params.requesterSessionKey ,
} ) ;
const requesterSandboxed = params . requesterSandboxed === true || requesterRuntime . sandboxed ;
if ( requesterSandboxed ) {
return 'Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.' ;
}
if ( sandboxMode === "require" ) {
return 'sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".' ;
}
return undefined ;
}
2026-02-26 11:00:09 +01:00
type PreparedAcpThreadBinding = {
channel : string ;
accountId : string ;
conversationId : string ;
} ;
function resolveSpawnMode ( params : {
requestedMode? : SpawnAcpMode ;
threadRequested : boolean ;
} ) : SpawnAcpMode {
if ( params . requestedMode === "run" || params . requestedMode === "session" ) {
return params . requestedMode ;
}
// Thread-bound spawns should default to persistent sessions.
return params . threadRequested ? "session" : "run" ;
}
function resolveAcpSessionMode ( mode : SpawnAcpMode ) : AcpRuntimeSessionMode {
return mode === "session" ? "persistent" : "oneshot" ;
}
2026-03-10 13:42:15 -07:00
function isHeartbeatEnabledForSessionAgent ( params : {
cfg : OpenClawConfig ;
sessionKey? : string ;
} ) : boolean {
if ( ! areHeartbeatsEnabled ( ) ) {
return false ;
}
const requesterAgentId = parseAgentSessionKey ( params . sessionKey ) ? . agentId ;
if ( ! requesterAgentId ) {
return true ;
}
const agentEntries = params . cfg . agents ? . list ? ? [ ] ;
const hasExplicitHeartbeatAgents = agentEntries . some ( ( entry ) = > Boolean ( entry ? . heartbeat ) ) ;
const enabledByPolicy = hasExplicitHeartbeatAgents
? agentEntries . some (
( entry ) = > Boolean ( entry ? . heartbeat ) && normalizeAgentId ( entry ? . id ) === requesterAgentId ,
)
: requesterAgentId === resolveDefaultAgentId ( params . cfg ) ;
if ( ! enabledByPolicy ) {
return false ;
}
const heartbeatEvery =
resolveAgentConfig ( params . cfg , requesterAgentId ) ? . heartbeat ? . every ? ?
params . cfg . agents ? . defaults ? . heartbeat ? . every ? ?
DEFAULT_HEARTBEAT_EVERY ;
const trimmedEvery = typeof heartbeatEvery === "string" ? heartbeatEvery . trim ( ) : "" ;
if ( ! trimmedEvery ) {
return false ;
}
try {
return parseDurationMs ( trimmedEvery , { defaultUnit : "m" } ) > 0 ;
} catch {
return false ;
}
}
function resolveHeartbeatConfigForAgent ( params : {
cfg : OpenClawConfig ;
agentId : string ;
} ) : NonNullable < NonNullable < OpenClawConfig [ "agents" ] > [ "defaults" ] > [ "heartbeat" ] {
const defaults = params . cfg . agents ? . defaults ? . heartbeat ;
const overrides = resolveAgentConfig ( params . cfg , params . agentId ) ? . heartbeat ;
if ( ! defaults && ! overrides ) {
return undefined ;
}
return {
. . . defaults ,
. . . overrides ,
} ;
}
function hasSessionLocalHeartbeatRelayRoute ( params : {
cfg : OpenClawConfig ;
parentSessionKey : string ;
requesterAgentId : string ;
} ) : boolean {
const scope = params . cfg . session ? . scope ? ? "per-sender" ;
if ( scope === "global" ) {
return false ;
}
const heartbeat = resolveHeartbeatConfigForAgent ( {
cfg : params.cfg ,
agentId : params.requesterAgentId ,
} ) ;
if ( ( heartbeat ? . target ? ? "none" ) !== "last" ) {
return false ;
}
// Explicit delivery overrides are not session-local and can route updates
// to unrelated destinations (for example a pinned ops channel).
if ( typeof heartbeat ? . to === "string" && heartbeat . to . trim ( ) . length > 0 ) {
return false ;
}
if ( typeof heartbeat ? . accountId === "string" && heartbeat . accountId . trim ( ) . length > 0 ) {
return false ;
}
const storePath = resolveStorePath ( params . cfg . session ? . store , {
agentId : params.requesterAgentId ,
} ) ;
const sessionStore = loadSessionStore ( storePath ) ;
const parentEntry = sessionStore [ params . parentSessionKey ] ;
const parentDeliveryContext = deliveryContextFromSession ( parentEntry ) ;
return Boolean ( parentDeliveryContext ? . channel && parentDeliveryContext . to ) ;
}
2026-02-26 11:00:09 +01:00
function resolveTargetAcpAgentId ( params : {
requestedAgentId? : string ;
cfg : OpenClawConfig ;
} ) : { ok : true ; agentId : string } | { ok : false ; error : string } {
const requested = normalizeOptionalAgentId ( params . requestedAgentId ) ;
if ( requested ) {
return { ok : true , agentId : requested } ;
}
const configuredDefault = normalizeOptionalAgentId ( params . cfg . acp ? . defaultAgent ) ;
if ( configuredDefault ) {
return { ok : true , agentId : configuredDefault } ;
}
return {
ok : false ,
error :
"ACP target agent is not configured. Pass `agentId` in `sessions_spawn` or set `acp.defaultAgent` in config." ,
} ;
}
function normalizeOptionalAgentId ( value : string | undefined | null ) : string | undefined {
const trimmed = ( value ? ? "" ) . trim ( ) ;
if ( ! trimmed ) {
return undefined ;
}
return normalizeAgentId ( trimmed ) ;
}
function summarizeError ( err : unknown ) : string {
if ( err instanceof Error ) {
return err . message ;
}
if ( typeof err === "string" ) {
return err ;
}
return "error" ;
}
2026-03-08 19:37:00 +01:00
function resolveRequesterInternalSessionKey ( params : {
cfg : OpenClawConfig ;
requesterSessionKey? : string ;
} ) : string {
const { mainKey , alias } = resolveMainSessionAlias ( params . cfg ) ;
const requesterSessionKey = params . requesterSessionKey ? . trim ( ) ;
return requesterSessionKey
? resolveInternalSessionKey ( {
key : requesterSessionKey ,
alias ,
mainKey ,
} )
: alias ;
}
async function persistAcpSpawnSessionFileBestEffort ( params : {
sessionId : string ;
sessionKey : string ;
sessionEntry : SessionEntry | undefined ;
sessionStore : Record < string , SessionEntry > ;
storePath : string ;
agentId : string ;
threadId? : string | number ;
stage : "spawn" | "thread-bind" ;
} ) : Promise < SessionEntry | undefined > {
try {
const resolvedSessionFile = await resolveSessionTranscriptFile ( {
sessionId : params.sessionId ,
sessionKey : params.sessionKey ,
sessionEntry : params.sessionEntry ,
sessionStore : params.sessionStore ,
storePath : params.storePath ,
agentId : params.agentId ,
threadId : params.threadId ,
} ) ;
return resolvedSessionFile . sessionEntry ;
} catch ( error ) {
log . warn (
` ACP session-file persistence failed during ${ params . stage } for ${ params . sessionKey } : ${ summarizeError ( error ) } ` ,
) ;
return params . sessionEntry ;
}
}
2026-02-26 11:00:09 +01:00
function resolveConversationIdForThreadBinding ( params : {
to? : string ;
threadId? : string | number ;
} ) : string | undefined {
return resolveConversationIdFromTargets ( {
threadId : params.threadId ,
targets : [ params . to ] ,
} ) ;
}
function prepareAcpThreadBinding ( params : {
cfg : OpenClawConfig ;
channel? : string ;
accountId? : string ;
to? : string ;
threadId? : string | number ;
} ) : { ok : true ; binding : PreparedAcpThreadBinding } | { ok : false ; error : string } {
const channel = params . channel ? . trim ( ) . toLowerCase ( ) ;
if ( ! channel ) {
return {
ok : false ,
error : "thread=true for ACP sessions requires a channel context." ,
} ;
}
const accountId = params . accountId ? . trim ( ) || "default" ;
const policy = resolveThreadBindingSpawnPolicy ( {
cfg : params.cfg ,
channel ,
accountId ,
kind : "acp" ,
} ) ;
if ( ! policy . enabled ) {
return {
ok : false ,
error : formatThreadBindingDisabledError ( {
channel : policy.channel ,
accountId : policy.accountId ,
kind : "acp" ,
} ) ,
} ;
}
if ( ! policy . spawnEnabled ) {
return {
ok : false ,
error : formatThreadBindingSpawnDisabledError ( {
channel : policy.channel ,
accountId : policy.accountId ,
kind : "acp" ,
} ) ,
} ;
}
const bindingService = getSessionBindingService ( ) ;
const capabilities = bindingService . getCapabilities ( {
channel : policy.channel ,
accountId : policy.accountId ,
} ) ;
if ( ! capabilities . adapterAvailable ) {
return {
ok : false ,
error : ` Thread bindings are unavailable for ${ policy . channel } . ` ,
} ;
}
if ( ! capabilities . bindSupported || ! capabilities . placements . includes ( "child" ) ) {
return {
ok : false ,
error : ` Thread bindings do not support ACP thread spawn for ${ policy . channel } . ` ,
} ;
}
const conversationId = resolveConversationIdForThreadBinding ( {
to : params.to ,
threadId : params.threadId ,
} ) ;
if ( ! conversationId ) {
return {
ok : false ,
error : ` Could not resolve a ${ policy . channel } conversation for ACP thread spawn. ` ,
} ;
}
return {
ok : true ,
binding : {
channel : policy.channel ,
accountId : policy.accountId ,
conversationId ,
} ,
} ;
}
export async function spawnAcpDirect (
params : SpawnAcpParams ,
ctx : SpawnAcpContext ,
) : Promise < SpawnAcpResult > {
const cfg = loadConfig ( ) ;
2026-03-08 19:37:00 +01:00
const requesterInternalKey = resolveRequesterInternalSessionKey ( {
cfg ,
requesterSessionKey : ctx.agentSessionKey ,
} ) ;
2026-02-26 11:00:09 +01:00
if ( ! isAcpEnabledByPolicy ( cfg ) ) {
return {
status : "forbidden" ,
error : "ACP is disabled by policy (`acp.enabled=false`)." ,
} ;
}
2026-03-04 11:44:20 +01:00
const streamToParentRequested = params . streamTo === "parent" ;
const parentSessionKey = ctx . agentSessionKey ? . trim ( ) ;
if ( streamToParentRequested && ! parentSessionKey ) {
return {
status : "error" ,
error : 'sessions_spawn streamTo="parent" requires an active requester session context.' ,
} ;
}
2026-03-10 13:42:15 -07:00
const requestThreadBinding = params . thread === true ;
2026-03-08 00:22:57 +00:00
const runtimePolicyError = resolveAcpSpawnRuntimePolicyError ( {
2026-03-02 23:50:38 +01:00
cfg ,
2026-03-08 00:22:57 +00:00
requesterSessionKey : ctx.agentSessionKey ,
requesterSandboxed : ctx.sandboxed ,
sandbox : params.sandbox ,
2026-03-02 23:50:38 +01:00
} ) ;
2026-03-08 00:22:57 +00:00
if ( runtimePolicyError ) {
2026-03-02 23:50:38 +01:00
return {
status : "forbidden" ,
2026-03-08 00:22:57 +00:00
error : runtimePolicyError ,
2026-03-02 23:50:38 +01:00
} ;
}
2026-02-26 11:00:09 +01:00
const spawnMode = resolveSpawnMode ( {
requestedMode : params.mode ,
threadRequested : requestThreadBinding ,
} ) ;
if ( spawnMode === "session" && ! requestThreadBinding ) {
return {
status : "error" ,
error : 'mode="session" requires thread=true so the ACP session can stay bound to a thread.' ,
} ;
}
2026-03-10 13:42:15 -07:00
const bindingService = getSessionBindingService ( ) ;
const requesterParsedSession = parseAgentSessionKey ( parentSessionKey ) ;
const requesterIsSubagentSession =
Boolean ( requesterParsedSession ) && isSubagentSessionKey ( parentSessionKey ) ;
const requesterHasActiveSubagentBinding =
requesterIsSubagentSession && parentSessionKey
? bindingService
. listBySession ( parentSessionKey )
. some ( ( record ) = > record . targetKind === "subagent" && record . status !== "ended" )
: false ;
const requesterHasThreadContext =
typeof ctx . agentThreadId === "string"
? ctx . agentThreadId . trim ( ) . length > 0
: ctx . agentThreadId != null ;
const requesterHeartbeatEnabled = isHeartbeatEnabledForSessionAgent ( {
cfg ,
sessionKey : parentSessionKey ,
} ) ;
const requesterAgentId = requesterParsedSession ? . agentId ;
const requesterHeartbeatRelayRouteUsable =
parentSessionKey && requesterAgentId
? hasSessionLocalHeartbeatRelayRoute ( {
cfg ,
parentSessionKey ,
requesterAgentId ,
} )
: false ;
// For mode=run without thread binding, implicitly route output to parent
// only for spawned subagent orchestrator sessions with heartbeat enabled
// AND a session-local heartbeat delivery route (target=last + usable last route).
// Skip requester sessions that are thread-bound (or carrying thread context)
// so user-facing threads do not receive unsolicited ACP progress chatter
// unless streamTo="parent" is explicitly requested. Use resolved spawnMode
// (not params.mode) so default mode selection works.
const implicitStreamToParent =
! streamToParentRequested &&
spawnMode === "run" &&
! requestThreadBinding &&
requesterIsSubagentSession &&
! requesterHasActiveSubagentBinding &&
! requesterHasThreadContext &&
requesterHeartbeatEnabled &&
requesterHeartbeatRelayRouteUsable ;
const effectiveStreamToParent = streamToParentRequested || implicitStreamToParent ;
2026-02-26 11:00:09 +01:00
const targetAgentResult = resolveTargetAcpAgentId ( {
requestedAgentId : params.agentId ,
cfg ,
} ) ;
if ( ! targetAgentResult . ok ) {
return {
status : "error" ,
error : targetAgentResult.error ,
} ;
}
const targetAgentId = targetAgentResult . agentId ;
const agentPolicyError = resolveAcpAgentPolicyError ( cfg , targetAgentId ) ;
if ( agentPolicyError ) {
return {
status : "forbidden" ,
error : agentPolicyError.message ,
} ;
}
const sessionKey = ` agent: ${ targetAgentId } :acp: ${ crypto . randomUUID ( ) } ` ;
const runtimeMode = resolveAcpSessionMode ( spawnMode ) ;
let preparedBinding : PreparedAcpThreadBinding | null = null ;
if ( requestThreadBinding ) {
const prepared = prepareAcpThreadBinding ( {
cfg ,
channel : ctx.agentChannel ,
accountId : ctx.agentAccountId ,
to : ctx.agentTo ,
threadId : ctx.agentThreadId ,
} ) ;
if ( ! prepared . ok ) {
return {
status : "error" ,
error : prepared.error ,
} ;
}
preparedBinding = prepared . binding ;
}
const acpManager = getAcpSessionManager ( ) ;
let binding : SessionBindingRecord | null = null ;
let sessionCreated = false ;
let initializedRuntime : AcpSpawnRuntimeCloseHandle | undefined ;
try {
await callGateway ( {
method : "sessions.patch" ,
params : {
key : sessionKey ,
2026-03-08 19:37:00 +01:00
spawnedBy : requesterInternalKey ,
2026-02-26 11:00:09 +01:00
. . . ( params . label ? { label : params.label } : { } ) ,
} ,
timeoutMs : 10_000 ,
} ) ;
sessionCreated = true ;
2026-03-08 19:37:00 +01:00
const storePath = resolveStorePath ( cfg . session ? . store , { agentId : targetAgentId } ) ;
const sessionStore = loadSessionStore ( storePath ) ;
let sessionEntry : SessionEntry | undefined = sessionStore [ sessionKey ] ;
const sessionId = sessionEntry ? . sessionId ;
if ( sessionId ) {
sessionEntry = await persistAcpSpawnSessionFileBestEffort ( {
sessionId ,
sessionKey ,
sessionStore ,
storePath ,
sessionEntry ,
agentId : targetAgentId ,
stage : "spawn" ,
} ) ;
}
2026-02-26 11:00:09 +01:00
const initialized = await acpManager . initializeSession ( {
cfg ,
sessionKey ,
agent : targetAgentId ,
mode : runtimeMode ,
2026-03-10 02:36:13 -07:00
resumeSessionId : params.resumeSessionId ,
2026-02-26 11:00:09 +01:00
cwd : params.cwd ,
backendId : cfg.acp?.backend ,
} ) ;
initializedRuntime = {
runtime : initialized.runtime ,
handle : initialized.handle ,
} ;
if ( preparedBinding ) {
binding = await bindingService . bind ( {
targetSessionKey : sessionKey ,
targetKind : "session" ,
conversation : {
channel : preparedBinding.channel ,
accountId : preparedBinding.accountId ,
conversationId : preparedBinding.conversationId ,
} ,
placement : "child" ,
metadata : {
threadName : resolveThreadBindingThreadName ( {
agentId : targetAgentId ,
label : params.label || targetAgentId ,
} ) ,
agentId : targetAgentId ,
label : params.label || undefined ,
boundBy : "system" ,
introText : resolveThreadBindingIntroText ( {
agentId : targetAgentId ,
label : params.label || undefined ,
2026-02-27 10:02:39 +01:00
idleTimeoutMs : resolveThreadBindingIdleTimeoutMsForChannel ( {
cfg ,
channel : preparedBinding.channel ,
accountId : preparedBinding.accountId ,
} ) ,
maxAgeMs : resolveThreadBindingMaxAgeMsForChannel ( {
2026-02-26 11:00:09 +01:00
cfg ,
channel : preparedBinding.channel ,
accountId : preparedBinding.accountId ,
} ) ,
sessionCwd : resolveAcpSessionCwd ( initialized . meta ) ,
sessionDetails : resolveAcpThreadSessionDetailLines ( {
sessionKey ,
meta : initialized.meta ,
} ) ,
} ) ,
} ,
} ) ;
if ( ! binding ? . conversation . conversationId ) {
throw new Error (
` Failed to create and bind a ${ preparedBinding . channel } thread for this ACP session. ` ,
) ;
}
2026-03-08 19:37:00 +01:00
if ( sessionId ) {
const boundThreadId = String ( binding . conversation . conversationId ) . trim ( ) || undefined ;
if ( boundThreadId ) {
sessionEntry = await persistAcpSpawnSessionFileBestEffort ( {
sessionId ,
sessionKey ,
sessionStore ,
storePath ,
sessionEntry ,
agentId : targetAgentId ,
threadId : boundThreadId ,
stage : "thread-bind" ,
} ) ;
}
}
2026-02-26 11:00:09 +01:00
}
} catch ( err ) {
await cleanupFailedAcpSpawn ( {
cfg ,
sessionKey ,
shouldDeleteSession : sessionCreated ,
deleteTranscript : true ,
runtimeCloseHandle : initializedRuntime ,
} ) ;
return {
status : "error" ,
error : isSessionBindingError ( err ) ? err.message : summarizeError ( err ) ,
} ;
}
const requesterOrigin = normalizeDeliveryContext ( {
channel : ctx.agentChannel ,
accountId : ctx.agentAccountId ,
to : ctx.agentTo ,
threadId : ctx.agentThreadId ,
} ) ;
// For thread-bound ACP spawns, force bootstrap delivery to the new child thread.
const boundThreadIdRaw = binding ? . conversation . conversationId ;
const boundThreadId = boundThreadIdRaw ? String ( boundThreadIdRaw ) . trim ( ) || undefined : undefined ;
const fallbackThreadIdRaw = requesterOrigin ? . threadId ;
const fallbackThreadId =
fallbackThreadIdRaw != null ? String ( fallbackThreadIdRaw ) . trim ( ) || undefined : undefined ;
const deliveryThreadId = boundThreadId ? ? fallbackThreadId ;
const inferredDeliveryTo = boundThreadId
? ` channel: ${ boundThreadId } `
: requesterOrigin ? . to ? . trim ( ) || ( deliveryThreadId ? ` channel: ${ deliveryThreadId } ` : undefined ) ;
const hasDeliveryTarget = Boolean ( requesterOrigin ? . channel && inferredDeliveryTo ) ;
2026-03-08 09:37:22 +08:00
// Fresh one-shot ACP runs should bootstrap the worker first, then let higher layers
// decide how to relay status. Inline delivery is reserved for thread-bound sessions.
const useInlineDelivery =
2026-03-10 13:42:15 -07:00
hasDeliveryTarget && spawnMode === "session" && ! effectiveStreamToParent ;
2026-02-26 11:00:09 +01:00
const childIdem = crypto . randomUUID ( ) ;
let childRunId : string = childIdem ;
2026-03-04 11:44:20 +01:00
const streamLogPath =
2026-03-10 13:42:15 -07:00
effectiveStreamToParent && parentSessionKey
2026-03-04 11:44:20 +01:00
? resolveAcpSpawnStreamLogPath ( {
childSessionKey : sessionKey ,
} )
: undefined ;
let parentRelay : AcpSpawnParentRelayHandle | undefined ;
2026-03-10 13:42:15 -07:00
if ( effectiveStreamToParent && parentSessionKey ) {
2026-03-04 11:44:20 +01:00
// Register relay before dispatch so fast lifecycle failures are not missed.
parentRelay = startAcpSpawnParentStreamRelay ( {
runId : childIdem ,
parentSessionKey ,
childSessionKey : sessionKey ,
agentId : targetAgentId ,
logPath : streamLogPath ,
emitStartNotice : false ,
} ) ;
}
2026-02-26 11:00:09 +01:00
try {
const response = await callGateway < { runId? : string } > ( {
method : "agent" ,
params : {
message : params.task ,
sessionKey ,
2026-03-08 09:37:22 +08:00
channel : useInlineDelivery ? requesterOrigin?.channel : undefined ,
to : useInlineDelivery ? inferredDeliveryTo : undefined ,
accountId : useInlineDelivery ? ( requesterOrigin ? . accountId ? ? undefined ) : undefined ,
threadId : useInlineDelivery ? deliveryThreadId : undefined ,
2026-02-26 11:00:09 +01:00
idempotencyKey : childIdem ,
2026-03-08 09:37:22 +08:00
deliver : useInlineDelivery ,
2026-02-26 11:00:09 +01:00
label : params.label || undefined ,
} ,
timeoutMs : 10_000 ,
} ) ;
if ( typeof response ? . runId === "string" && response . runId . trim ( ) ) {
childRunId = response . runId . trim ( ) ;
}
} catch ( err ) {
2026-03-04 11:44:20 +01:00
parentRelay ? . dispose ( ) ;
2026-02-26 11:00:09 +01:00
await cleanupFailedAcpSpawn ( {
cfg ,
sessionKey ,
shouldDeleteSession : true ,
deleteTranscript : true ,
} ) ;
return {
status : "error" ,
error : summarizeError ( err ) ,
childSessionKey : sessionKey ,
} ;
}
2026-03-10 13:42:15 -07:00
if ( effectiveStreamToParent && parentSessionKey ) {
2026-03-04 11:44:20 +01:00
if ( parentRelay && childRunId !== childIdem ) {
parentRelay . dispose ( ) ;
// Defensive fallback if gateway returns a runId that differs from idempotency key.
parentRelay = startAcpSpawnParentStreamRelay ( {
runId : childRunId ,
parentSessionKey ,
childSessionKey : sessionKey ,
agentId : targetAgentId ,
logPath : streamLogPath ,
emitStartNotice : false ,
} ) ;
}
parentRelay ? . notifyStarted ( ) ;
return {
status : "accepted" ,
childSessionKey : sessionKey ,
runId : childRunId ,
mode : spawnMode ,
. . . ( streamLogPath ? { streamLogPath } : { } ) ,
note : spawnMode === "session" ? ACP_SPAWN_SESSION_ACCEPTED_NOTE : ACP_SPAWN_ACCEPTED_NOTE ,
} ;
}
2026-02-26 11:00:09 +01:00
return {
status : "accepted" ,
childSessionKey : sessionKey ,
runId : childRunId ,
mode : spawnMode ,
note : spawnMode === "session" ? ACP_SPAWN_SESSION_ACCEPTED_NOTE : ACP_SPAWN_ACCEPTED_NOTE ,
} ;
}