Files
simstudioai--sim/apps/sim/lib/copilot/request/lifecycle/run.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1059 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Context } from '@opentelemetry/api'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository'
import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/copilot/constants'
import {
MothershipStreamV1EventType,
MothershipStreamV1RunKind,
MothershipStreamV1ToolOutcome,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { createStreamingContext } from '@/lib/copilot/request/context/request-context'
import { buildToolCallSummaries } from '@/lib/copilot/request/context/result'
import {
BillingLimitError,
CopilotBackendError,
runStreamLoop,
} from '@/lib/copilot/request/go/stream'
import {
getToolCallTerminalData,
requireToolCallStateResult,
setTerminalToolCallState,
} from '@/lib/copilot/request/tool-call-state'
import { handleBillingLimitResponse } from '@/lib/copilot/request/tools/billing'
import {
executeToolAndReport,
forceFailHungToolCall,
toolWatchdogTimeoutMs,
} from '@/lib/copilot/request/tools/executor'
import type { TraceCollector } from '@/lib/copilot/request/trace'
import { RequestTraceV1SpanStatus } from '@/lib/copilot/request/trace'
import type {
ExecutionContext,
OrchestratorOptions,
OrchestratorResult,
ResumeContinuation,
ResumeFrame,
StreamEvent,
StreamingContext,
} from '@/lib/copilot/request/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context'
import { env } from '@/lib/core/config/env'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
const logger = createLogger('CopilotLifecycle')
const MAX_RESUME_ATTEMPTS = 3
const RESUME_BACKOFF_MS = [250, 500, 1000] as const
function nonBlankString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
function resultContent(context: StreamingContext, options: CopilotLifecycleOptions): string {
if (options.interactive === false && context.sawMainToolCall) {
return context.finalAssistantContent
}
return context.accumulatedContent
}
export interface CopilotLifecycleOptions extends OrchestratorOptions {
userId: string
workflowId?: string
workspaceId?: string
chatId?: string
executionId?: string
runId?: string
goRoute?: string
trace?: TraceCollector
simRequestId?: string
otelContext?: Context
onGoTraceId?: (goTraceId: string) => void
executionContext?: ExecutionContext
}
export async function runCopilotLifecycle(
requestPayload: Record<string, unknown>,
options: CopilotLifecycleOptions
): Promise<OrchestratorResult> {
const {
userId,
workflowId,
workspaceId,
chatId,
executionId,
runId,
goRoute = '/api/copilot',
} = options
const payloadMsgId =
typeof requestPayload?.messageId === 'string' ? requestPayload.messageId : generateId()
const runIdentity = await ensureHeadlessRunIdentity({
requestPayload,
userId,
workflowId,
workspaceId,
chatId,
executionId,
runId,
messageId: payloadMsgId,
})
const resolvedExecutionId = runIdentity.executionId ?? executionId
const resolvedRunId = runIdentity.runId ?? runId
const lifecycleOptions: CopilotLifecycleOptions = {
...options,
executionId: resolvedExecutionId,
runId: resolvedRunId,
...(options.executionContext
? {
executionContext: {
...options.executionContext,
messageId: payloadMsgId,
executionId: resolvedExecutionId,
runId: resolvedRunId,
abortSignal: options.abortSignal,
},
}
: {}),
}
const execContext =
lifecycleOptions.executionContext ??
(await buildExecutionContext(requestPayload, {
userId,
workflowId,
workspaceId,
chatId,
executionId: resolvedExecutionId,
runId: resolvedRunId,
abortSignal: lifecycleOptions.abortSignal,
}))
const context = createStreamingContext({
chatId,
requestId: lifecycleOptions.simRequestId,
executionId: resolvedExecutionId,
runId: resolvedRunId,
messageId: payloadMsgId,
...(lifecycleOptions.trace ? { trace: lifecycleOptions.trace } : {}),
})
let onCompleteStarted = false
try {
await runCheckpointLoop(requestPayload, context, execContext, lifecycleOptions, goRoute)
const result: OrchestratorResult = {
success: context.errors.length === 0 && !context.wasAborted,
// `cancelled` is an explicit discriminator so callers can tell
// "user hit Stop" (persist partial assistant content through the
// cancelled completion path) from "backend errored" (do clear the
// row so the chat isn't stuck with a non-null `conversationId`).
// An error that also
// happens to fire the abort signal still counts as an error
// path, but practically that doesn't happen in the success
// branch here — if there are errors we never reach a
// wasAborted-without-errors state.
cancelled: context.wasAborted && context.errors.length === 0,
content: resultContent(context, lifecycleOptions),
contentBlocks: context.contentBlocks,
toolCalls: buildToolCallSummaries(context),
chatId: context.chatId,
requestId: context.requestId,
errors: context.errors.length ? context.errors : undefined,
usage: context.usage,
cost: context.cost,
}
if (lifecycleOptions.onComplete) {
onCompleteStarted = true
await lifecycleOptions.onComplete(result)
}
return result
} catch (error) {
const err = toError(error)
// A CopilotBackendError carries the upstream HTTP status + body (e.g. a 5xx
// from /api/tools/resume when an oversized tool result — a rendered-doc
// image — is posted back). Log those so a client-side "Stream error" that
// originates from a thrown backend leg (vs an `error` SSE event) is
// explained, not just reduced to a message string.
logger.error('Copilot orchestration failed', {
error: err.message,
name: err.name,
...(error instanceof CopilotBackendError
? { backendStatus: error.status, backendBody: error.body?.slice(0, 2000) }
: {}),
})
// If the abort signal fired, this throw is a consequence of the
// cancel (publisher.publish fails once the client disconnects, a
// downstream Go read throws on ctx cancel, etc.) — NOT a real
// backend error. Don't invoke `onError`, because on the cancel
// path `onComplete(cancelled)` persists partial content with an
// idempotent row-locked finalizer. `onError` would race with it via
// `finalizeAssistantTurn`, clearing `conversationId` before the
// partial content can be appended.
// Return `cancelled: true` so upstream classification stays
// consistent with the success-path cancel result.
const wasCancelled = lifecycleOptions.abortSignal?.aborted ?? false
// Preserve whatever streamed before the throw for both terminals. A thrown
// backend error (as opposed to an `error` SSE event that lets the loop finish
// normally) must still carry the partial assistant turn so onError can
// persist it — otherwise the post-error refetch replaces the rich live turn
// with an empty assistant row and the UI appears to wipe the message +
// subagent work.
const result: OrchestratorResult = {
success: false,
cancelled: wasCancelled,
content: context.accumulatedContent,
contentBlocks: context.contentBlocks,
toolCalls: buildToolCallSummaries(context),
chatId: context.chatId,
requestId: context.requestId,
error: err.message,
errors: context.errors.length ? context.errors : undefined,
usage: context.usage,
cost: context.cost,
}
if (!wasCancelled) {
await lifecycleOptions.onError?.(err, result)
} else if (!onCompleteStarted && lifecycleOptions.onComplete) {
try {
await lifecycleOptions.onComplete(result)
} catch (completeError) {
logger.error('Cancelled copilot completion callback failed', {
error: toError(completeError).message,
})
}
}
return result
}
}
// ---------------------------------------------------------------------------
// Per-subagent checkpoint resume (concurrent fan-out)
// ---------------------------------------------------------------------------
//
// Under the per-subagent checkpoint model each paused subagent is its OWN
// checkpoint chain (frame.checkpointId) joined at the orchestrator. Instead of
// one bundled /resume, Sim drives one resume chain per child CONCURRENTLY so a
// fast child never waits on a slow sibling, and the Go join wakes the
// orchestrator on whichever child finishes last. Gated by the Go
// `parallel-subagents` flag, surfaced here purely by frames carrying their own
// checkpointId.
//
// IMPORTANT (concurrency): JS is single-threaded, so the legs interleave at await
// points rather than running truly in parallel; shared accumulators
// (contentBlocks, toolCalls maps, errors) are appended via atomic synchronous
// ops and stay shared by reference. Only the per-leg STREAM CONTROL flags
// (streamComplete, awaitingAsyncContinuation) and the join-leg scalars
// (accumulatedContent/usage/cost) are isolated per leg and merged back.
type AsyncContinuation = ResumeContinuation
function isPerSubagentContinuation(c: AsyncContinuation): boolean {
return !!c.frames && c.frames.length > 0 && c.frames.every((f) => !!f.checkpointId)
}
// Shared header set for every Sim -> Go mothership request (initial stream and
// every resume leg), so the auth/source/version headers can't drift between the
// sequential path and the concurrent per-subagent resume legs.
function mothershipRequestHeaders(): Record<string, string> {
return {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
...getMothershipSourceEnvHeaders(),
'X-Client-Version': SIM_AGENT_VERSION,
}
}
// makeResumeLegContext / mergeResumeLegOutputs are a PAIR and must stay in
// lockstep: every field reset here is folded back there, and nothing else on
// StreamingContext is per-leg. Everything not listed is shared BY REFERENCE
// across all concurrent legs (the one merged chat: contentBlocks, toolCalls,
// pendingToolPromises, subagent maps, etc.). The per-leg ISOLATED set:
// - streamComplete / awaitingAsyncContinuation: stream-control flags, so a
// finished leg can't stop a sibling's read loop (reset only; not merged).
// - accumulatedContent / finalAssistantContent / usage / cost: join-leg
// scalars — only the join-carrying leg sets them; zeroing per leg keeps the
// `+=` merge from multiplying the orchestrator's pre-fanout content by the
// leg count, and keeps a child leg's stale usage/cost from clobbering the
// join leg's real totals on merge.
// - errors: a leg's transient retryable error (rolled back inside
// runResumeLegWithRetry) must not truncate a concurrent sibling's shared
// error array by index; each leg collects its own and merges the survivors.
// When adding a per-leg field, update BOTH functions (and the contract test in
// resume-leg-context.test.ts). Exported only for that test.
export function makeResumeLegContext(base: StreamingContext): StreamingContext {
return {
...base,
streamComplete: false,
awaitingAsyncContinuation: undefined,
accumulatedContent: '',
finalAssistantContent: '',
usage: undefined,
cost: undefined,
errors: [],
}
}
// mergeResumeLegOutputs folds a finished leg's isolated scalars back into the
// shared context. Child (subagent-lane) legs leave the join scalars empty; only
// the join-carrying leg (which streams the orchestrator continuation) sets them.
export function mergeResumeLegOutputs(context: StreamingContext, leg: StreamingContext): void {
if (leg.accumulatedContent) context.accumulatedContent += leg.accumulatedContent
if (leg.finalAssistantContent) context.finalAssistantContent += leg.finalAssistantContent
if (leg.usage) context.usage = leg.usage
if (leg.cost) context.cost = leg.cost
if (leg.sawMainToolCall) context.sawMainToolCall = true
if (leg.wasAborted) context.wasAborted = true
if (leg.errors.length > 0) context.errors.push(...leg.errors)
}
async function waitForToolIds(context: StreamingContext, toolIds: string[]): Promise<void> {
const promises: Promise<unknown>[] = []
for (const id of toolIds) {
const p = context.pendingToolPromises.get(id)
if (p) promises.push(p)
}
if (promises.length > 0) await Promise.allSettled(promises)
}
function collectResultsForToolIds(
context: StreamingContext,
toolIds: string[],
checkpointId: string
): Array<{ callId: string; name: string; data: unknown; success: boolean }> {
return toolIds.map((toolCallId) => {
const tool = context.toolCalls.get(toolCallId)
if (!tool || !tool.result) {
throw new Error(
`Cannot resume subagent chain ${checkpointId}: missing result for tool call ${toolCallId}`
)
}
return {
callId: toolCallId,
name: tool.name || '',
data: getToolCallTerminalData(tool),
success: requireToolCallStateResult(tool).success,
}
})
}
// runResumeLegWithRetry runs ONE resume POST with the same retryable-error +
// bounded-backoff policy the sequential checkpoint loop uses, so a concurrent
// child leg survives a transient Go 5xx (or network blip) instead of failing the
// whole turn — Go releases the claim on such errors expecting a retry. The leg's
// transient error is rolled back on its OWN (isolated) errors array so a
// recovered retry isn't mis-finalized as `error`. An AbortError (a sibling
// failure cancelling this leg, see driveSubagentChains) is non-retryable and
// propagates immediately.
async function runResumeLegWithRetry(
url: string,
body: Record<string, unknown>,
leg: StreamingContext,
execContext: ExecutionContext,
options: CopilotLifecycleOptions
): Promise<void> {
let attempt = 0
for (;;) {
const errorsBeforeAttempt = leg.errors.length
const willRetryOnStreamError = attempt < MAX_RESUME_ATTEMPTS - 1
const legBody = willRetryOnStreamError ? { ...body, willRetryOnStreamError: true } : body
try {
await runStreamLoop(
url,
{ method: 'POST', headers: mothershipRequestHeaders(), body: JSON.stringify(legBody) },
leg,
execContext,
options
)
return
} catch (error) {
if (isRetryableStreamError(error) && attempt < MAX_RESUME_ATTEMPTS - 1) {
leg.errors.length = errorsBeforeAttempt
attempt++
const backoff = RESUME_BACKOFF_MS[attempt - 1] ?? 1000
logger.warn('Child resume leg failed, retrying', {
attempt: attempt + 1,
maxAttempts: MAX_RESUME_ATTEMPTS,
backoffMs: backoff,
error: toError(error).message,
})
await sleepWithAbort(backoff, options.abortSignal)
continue
}
throw error
}
}
}
// driveOneChildChain resumes a single subagent's checkpoint chain to its end:
// resume -> (re-pause -> resume)* -> fold into join. Returns the orchestrator's
// follow-on continuation when THIS leg is the one the Go join woke (the last
// finisher whose /resume response carried the orchestrator continuation), else
// null. Re-pause vs follow-on is disambiguated by checkpoint id: a re-pause keeps
// the same child id; the join continuation is a different (orchestrator) id.
async function driveOneChildChain(
frame: ResumeFrame,
context: StreamingContext,
execContext: ExecutionContext,
options: CopilotLifecycleOptions,
baseURL: string,
workspaceId?: string
): Promise<AsyncContinuation | null> {
// ParentToolCallID is the SAME subagent's stable identity across re-pauses;
// the checkpoint id rotates each re-pause (the prior one is already claimed).
const parentToolCallId = frame.parentToolCallId
// Guarded (not cast): a per-subagent frame always carries its own checkpointId
// (isPerSubagentContinuation requires it), but a local guard keeps this driver
// correct on its own terms rather than trusting a caller-side invariant.
if (!frame.checkpointId) return null
let checkpointId = frame.checkpointId
let toolIds = frame.pendingToolIds
for (;;) {
if (isAborted(options, context)) return null
await waitForToolIds(context, toolIds)
const results = collectResultsForToolIds(context, toolIds, checkpointId)
const leg = makeResumeLegContext(context)
await runResumeLegWithRetry(
`${baseURL}/api/tools/resume`,
{
streamId: context.messageId,
checkpointId,
userId: options.userId,
...(workspaceId ? { workspaceId } : {}),
results,
},
leg,
execContext,
options
)
mergeResumeLegOutputs(context, leg)
const cont = leg.awaitingAsyncContinuation
if (!cont) {
// The last finisher's leg, whose join continuation streamed the
// orchestrator to completion (done): nothing more to drive on this leg.
return null
}
// A NON-last finisher folds with a TERMINAL pause carrying the join id but
// NO pending tools and NO frames — the child's work is done and the join
// wakes on whichever sibling finishes last. End this leg cleanly; do NOT
// mistake the join id for an orchestrator follow-on and try to resume it.
const hasPending = (cont.pendingToolCallIds?.length ?? 0) > 0
const hasFrames = (cont.frames?.length ?? 0) > 0
if (!hasPending && !hasFrames) {
return null
}
// Re-pause is identified by THIS subagent's stable parentToolCallId (the
// checkpoint id rotates each re-pause). If present, keep driving this child
// with its new id + leaves.
const repaused = cont.frames?.find(
(f) => f.parentToolCallId === parentToolCallId && f.checkpointId
)
if (repaused?.checkpointId) {
checkpointId = repaused.checkpointId
toolIds = repaused.pendingToolIds
continue
}
// No frame for this subagent => the join fired and the orchestrator re-paused
// on this leg. Hand it back to the main loop to continue the turn.
return cont
}
}
// driveSubagentChains fans out one resume chain per child frame concurrently and
// returns the single orchestrator follow-on continuation (if the orchestrator
// re-paused after the join), or null when the turn completed.
//
// Failure isolation: the legs share a per-fanout AbortController so the FIRST leg
// to fail cancels its siblings' in-flight resumes (otherwise a `Promise.all`
// reject leaves the siblings running detached — still mutating shared context and
// POSTing /resume after the turn has errored). The controller also chains off the
// caller's abort signal so a user stop cancels every leg. Each leg's failure is
// caught (so Promise.all can't reject before its siblings unwind); we then
// rethrow the first REAL error, not the AbortErrors it triggered in the siblings.
async function driveSubagentChains(
continuation: AsyncContinuation,
context: StreamingContext,
execContext: ExecutionContext,
options: CopilotLifecycleOptions,
baseURL: string,
workspaceId?: string
): Promise<AsyncContinuation | null> {
const frames = continuation.frames ?? []
logger.info('Driving subagent checkpoint chains concurrently', {
childCount: frames.length,
checkpointIds: frames.map((f) => f.checkpointId),
})
const fanoutController = new AbortController()
const parentSignal = options.abortSignal
const onParentAbort = () => fanoutController.abort()
if (parentSignal) {
if (parentSignal.aborted) fanoutController.abort()
else parentSignal.addEventListener('abort', onParentAbort, { once: true })
}
const legOptions: CopilotLifecycleOptions = { ...options, abortSignal: fanoutController.signal }
let firstError: unknown
try {
const followOns = await Promise.all(
frames.map((frame) =>
driveOneChildChain(frame, context, execContext, legOptions, baseURL, workspaceId).catch(
(error) => {
// First real failure wins and cancels the siblings; their resulting
// AbortErrors arrive later and don't overwrite it. Swallow here so
// Promise.all doesn't reject before every leg has unwound.
if (firstError === undefined) firstError = error
fanoutController.abort()
return null
}
)
)
)
if (firstError !== undefined) throw firstError
return followOns.find((c): c is AsyncContinuation => !!c) ?? null
} finally {
parentSignal?.removeEventListener('abort', onParentAbort)
}
}
// ---------------------------------------------------------------------------
// Checkpoint loop the core state machine
// ---------------------------------------------------------------------------
async function runCheckpointLoop(
initialPayload: Record<string, unknown>,
context: StreamingContext,
execContext: ExecutionContext,
options: CopilotLifecycleOptions,
initialRoute: string
): Promise<void> {
let route = initialRoute
let payload: Record<string, unknown> = initialPayload
let resumeAttempt = 0
const callerOnEvent = options.onEvent
const mothershipBaseURL = await getMothershipBaseURL({ userId: options.userId })
const lifecycleWorkspaceId = nonBlankString(options.workspaceId)
// Go's auth middleware re-validates every Sim -> Go request by reading
// workspaceId from the JSON body and forwarding it to Sim's validate route,
// where it is required for the per-member usage gate. Normalize the initial
// leg from the lifecycle option so callers that only set the option (not the
// raw payload) still send it on the first request.
if (lifecycleWorkspaceId && !nonBlankString(payload.workspaceId)) {
payload = { ...payload, workspaceId: lifecycleWorkspaceId }
}
// Enterprise BYOK eligibility hint: set once on the initial mothership request
// so Go only attempts a BYOK lookup for entitled workspaces. This is only a
// gate — Go re-confirms entitlement authoritatively before using any key.
payload = await withByokEligibilityHint(payload, route, lifecycleWorkspaceId)
for (;;) {
context.streamComplete = false
const isResume = route === '/api/tools/resume'
if (isResume && isAborted(options, context)) {
cancelPendingTools(context)
context.awaitingAsyncContinuation = undefined
break
}
const loopOptions = {
...options,
onEvent: async (event: StreamEvent) => {
if (
event.type === MothershipStreamV1EventType.run &&
event.payload.kind === MothershipStreamV1RunKind.checkpoint_pause &&
options.runId
) {
try {
await updateRunStatus(options.runId, 'paused_waiting_for_tool')
} catch (error) {
logger.warn('Failed to mark run as paused_waiting_for_tool', {
runId: options.runId,
error: toError(error).message,
})
}
}
await callerOnEvent?.(event)
},
}
const streamSpan = context.trace.startSpan(
isResume ? 'Sim → Go (Resume)' : 'Sim → Go Stream',
isResume ? 'lifecycle.resume' : 'sim.stream',
{
route,
isResume,
...(isResume ? { attempt: resumeAttempt } : {}),
}
)
context.trace.setActiveSpan(streamSpan)
logger.info('Starting stream loop', {
route,
isResume,
resumeAttempt,
pendingToolPromises: context.pendingToolPromises.size,
toolCallCount: context.toolCalls.size,
hasCheckpoint: !!context.awaitingAsyncContinuation,
})
// Snapshot recorded errors before this attempt. If the attempt fails with
// a retryable resume error, we roll back to this baseline before retrying
// so a subsequent successful retry doesn't inherit the failed attempt's
// errors (e.g. "backend stream ended before a terminal event") and get
// mis-finalized as `error`.
const errorsBeforeAttempt = context.errors.length
// A resume leg that is not the last allowed attempt will be retried below
// on a retryable stream error. Tell Go so it treats a mid-flight provider
// error as non-terminal for the UI and suppresses the user-facing error tag
// that a recovered retry should not show. Billing is still flushed for
// every leg; /api/billing/update-cost records cumulative cost as a
// monotonic top-up, so the partial retry leg and the recovered terminal leg
// reconcile to the maximum cumulative total. Recomputed per attempt because
// the same payload is reused across retries.
const willRetryOnStreamError = isResume && resumeAttempt < MAX_RESUME_ATTEMPTS - 1
const legPayload = willRetryOnStreamError
? { ...payload, willRetryOnStreamError: true }
: payload
try {
await runStreamLoop(
`${mothershipBaseURL}${route}`,
{
method: 'POST',
headers: mothershipRequestHeaders(),
body: JSON.stringify(legPayload),
},
context,
execContext,
loopOptions
)
const streamStatus = isAborted(options, context)
? RequestTraceV1SpanStatus.cancelled
: context.errors.length > 0
? RequestTraceV1SpanStatus.error
: RequestTraceV1SpanStatus.ok
context.trace.endSpan(streamSpan, streamStatus)
context.trace.setActiveSpan(undefined)
resumeAttempt = 0
} catch (streamError) {
context.trace.endSpan(streamSpan, RequestTraceV1SpanStatus.error)
context.trace.setActiveSpan(undefined)
if (streamError instanceof BillingLimitError) {
await handleBillingLimitResponse(streamError.userId, context, execContext, options)
break
}
if (
isResume &&
isRetryableStreamError(streamError) &&
resumeAttempt < MAX_RESUME_ATTEMPTS - 1
) {
// Discard errors recorded during this failed attempt; we're about to
// redo this leg and a clean retry must not finalize as `error`.
context.errors.length = errorsBeforeAttempt
resumeAttempt++
const backoff = RESUME_BACKOFF_MS[resumeAttempt - 1] ?? 1000
logger.warn('Resume stream failed, retrying', {
attempt: resumeAttempt + 1,
maxAttempts: MAX_RESUME_ATTEMPTS,
backoffMs: backoff,
error: toError(streamError).message,
})
await sleepWithAbort(backoff, options.abortSignal)
continue
}
throw streamError
}
logger.info('Stream loop completed', {
route,
isResume,
isAborted: isAborted(options, context),
hasCheckpoint: !!context.awaitingAsyncContinuation,
checkpointId: context.awaitingAsyncContinuation?.checkpointId,
pendingToolPromises: context.pendingToolPromises.size,
streamComplete: context.streamComplete,
toolCallCount: context.toolCalls.size,
})
if (isAborted(options, context)) {
cancelPendingTools(context)
context.awaitingAsyncContinuation = undefined
break
}
let continuation = context.awaitingAsyncContinuation
if (!continuation) break
// Per-subagent checkpoint model: fan out one concurrent resume chain per
// child instead of a single bundled resume. The driver returns null when the
// turn completed, or the orchestrator's follow-on continuation when it
// re-paused after the join. A per-subagent follow-on (orchestrator spawned
// more subagents) loops back through the driver; a normal follow-on falls
// through to the sequential resume path below.
if (isPerSubagentContinuation(continuation)) {
context.awaitingAsyncContinuation = undefined
let next: AsyncContinuation | null = continuation
while (next && isPerSubagentContinuation(next)) {
if (isAborted(options, context)) {
cancelPendingTools(context)
next = null
break
}
await waitForToolIds(context, next.pendingToolCallIds)
next = await driveSubagentChains(
next,
context,
execContext,
options,
mothershipBaseURL,
lifecycleWorkspaceId
)
}
if (!next) break
continuation = next
}
if (context.pendingToolPromises.size > 0) {
// Bounded by the slowest pending tool's watchdog plus grace. The
// per-tool watchdog already guarantees each promise settles; this gate
// is the structural backstop so that no tool failure mode — known or
// unknown — can park the checkpoint loop (and the chat's pending-stream
// lock) forever.
const waitBudgetMs =
Array.from(context.pendingToolPromises.keys()).reduce(
(max, toolCallId) =>
Math.max(max, toolWatchdogTimeoutMs(context.toolCalls.get(toolCallId)?.name)),
0
) + TOOL_WATCHDOG_RESUME_GRACE_MS
const waitSpan = context.trace.startSpan('Wait for Tools', 'lifecycle.wait_tools', {
checkpointId: continuation.checkpointId,
pendingCount: context.pendingToolPromises.size,
waitBudgetMs,
})
logger.info('Waiting for in-flight tool executions before resume', {
checkpointId: continuation.checkpointId,
pendingCount: context.pendingToolPromises.size,
waitBudgetMs,
})
const settledInTime = await Promise.race([
Promise.allSettled(context.pendingToolPromises.values()).then(() => true),
sleep(waitBudgetMs).then(() => false),
])
if (!settledInTime) {
const hungToolCallIds = Array.from(context.pendingToolPromises.keys())
logger.error('Pending tool executions exceeded the resume wait budget; force-failing', {
checkpointId: continuation.checkpointId,
waitBudgetMs,
hungToolCallIds,
})
for (const toolCallId of hungToolCallIds) {
await forceFailHungToolCall(
toolCallId,
context,
'Tool execution hung on the Sim executor and was abandoned so the conversation could continue.'
)
context.pendingToolPromises.delete(toolCallId)
}
}
waitSpan.attributes = { ...waitSpan.attributes, settledInTime }
context.trace.endSpan(waitSpan)
}
if (isAborted(options, context)) {
cancelPendingTools(context)
context.awaitingAsyncContinuation = undefined
break
}
const undispatchedToolIds = continuation.pendingToolCallIds.filter((toolCallId) => {
const tool = context.toolCalls.get(toolCallId)
return (
!!tool &&
!tool.result &&
!tool.error &&
!context.pendingToolPromises.has(toolCallId) &&
tool.status !== 'executing'
)
})
if (undispatchedToolIds.length > 0) {
logger.warn('Checkpointed tools were never dispatched; executing before resume', {
checkpointId: continuation.checkpointId,
toolCallIds: undispatchedToolIds,
})
await Promise.allSettled(
undispatchedToolIds.map((toolCallId) =>
executeToolAndReport(toolCallId, context, execContext, options)
)
)
}
if (isAborted(options, context)) {
cancelPendingTools(context)
context.awaitingAsyncContinuation = undefined
break
}
const results: Array<{
callId: string
name: string
data: unknown
success: boolean
}> = []
for (const toolCallId of continuation.pendingToolCallIds) {
if (isAborted(options, context)) {
cancelPendingTools(context)
context.awaitingAsyncContinuation = undefined
break
}
const tool = context.toolCalls.get(toolCallId)
if (!tool || !tool.result) {
logger.error('Missing tool result for pending tool call', {
toolCallId,
checkpointId: continuation.checkpointId,
hasToolEntry: !!tool,
toolName: tool?.name,
toolStatus: tool?.status,
hasPendingPromise: context.pendingToolPromises.has(toolCallId),
})
throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`)
}
results.push({
callId: toolCallId,
name: tool.name || '',
data: getToolCallTerminalData(tool),
success: requireToolCallStateResult(tool).success,
})
}
if (isAborted(options, context)) {
cancelPendingTools(context)
context.awaitingAsyncContinuation = undefined
break
}
logger.info('Resuming with tool results', {
checkpointId: continuation.checkpointId,
runId: continuation.runId,
toolCount: results.length,
pendingToolCallIds: continuation.pendingToolCallIds,
frameCount: continuation.frames?.length ?? 0,
})
context.awaitingAsyncContinuation = undefined
route = '/api/tools/resume'
payload = {
streamId: context.messageId,
checkpointId: continuation.checkpointId,
userId: options.userId,
...(lifecycleWorkspaceId ? { workspaceId: lifecycleWorkspaceId } : {}),
results,
}
if (isAborted(options, context)) {
cancelPendingTools(context)
context.awaitingAsyncContinuation = undefined
break
}
logger.info('Prepared resume request payload', {
route,
streamId: context.messageId,
checkpointId: continuation.checkpointId,
resultCount: results.length,
})
}
}
// ---------------------------------------------------------------------------
// Execution context builder
// ---------------------------------------------------------------------------
async function buildExecutionContext(
requestPayload: Record<string, unknown>,
params: {
userId: string
workflowId?: string
workspaceId?: string
chatId?: string
executionId?: string
runId?: string
abortSignal?: AbortSignal
}
): Promise<ExecutionContext> {
const { userId, workflowId, workspaceId, chatId, executionId, runId, abortSignal } = params
const userTimezone =
typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined
const requestMode = typeof requestPayload?.mode === 'string' ? requestPayload.mode : undefined
const userPermission =
typeof requestPayload?.userPermission === 'string' ? requestPayload.userPermission : undefined
let execContext: ExecutionContext
if (workflowId) {
execContext = await prepareExecutionContext(userId, workflowId, chatId)
} else {
const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId)
execContext = {
userId,
workflowId: '',
workspaceId,
chatId,
decryptedEnvVars,
}
}
if (userTimezone) execContext.userTimezone = userTimezone
execContext.copilotToolExecution = true
if (requestMode) execContext.requestMode = requestMode
if (userPermission) execContext.userPermission = userPermission
execContext.messageId =
typeof requestPayload?.messageId === 'string' ? requestPayload.messageId : undefined
execContext.executionId = executionId
execContext.runId = runId
execContext.abortSignal = abortSignal
return execContext
}
async function ensureHeadlessRunIdentity(input: {
requestPayload: Record<string, unknown>
userId: string
workflowId?: string
workspaceId?: string
chatId?: string
executionId?: string
runId?: string
messageId: string
}): Promise<{ executionId?: string; runId?: string }> {
if (!input.chatId || input.executionId || input.runId) {
return {
executionId: input.executionId,
runId: input.runId,
}
}
const executionId = generateId()
const runId = generateId()
try {
await createRunSegment({
id: runId,
executionId,
chatId: input.chatId,
userId: input.userId,
workflowId: input.workflowId,
workspaceId: input.workspaceId,
streamId: input.messageId,
model: typeof input.requestPayload?.model === 'string' ? input.requestPayload.model : null,
provider:
typeof input.requestPayload?.provider === 'string' ? input.requestPayload.provider : null,
requestContext: {
source: 'headless_lifecycle',
},
})
return { executionId, runId }
} catch (error) {
logger.warn('Failed to create headless run identity', {
chatId: input.chatId,
messageId: input.messageId,
error: toError(error).message,
})
return {}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Adds `enterpriseByokEligible: true` to the initial mothership payload when the
* workspace is on an enterprise plan. BYOK is mothership-only, so non-mothership
* routes (e.g. `/api/copilot`) are left untouched. Failures default to hosted.
*/
async function withByokEligibilityHint(
payload: Record<string, unknown>,
route: string,
workspaceId?: string
): Promise<Record<string, unknown>> {
// The eligibility hint is server-authoritative: always overwrite any
// client-supplied value with a server-derived boolean so a client can never
// assert its own eligibility. (Copilot's ValidateBYOK is the final authority,
// but the hint must never originate from the client.) BYOK is mothership-only;
// everything else gets an explicit false.
let eligible = false
if (workspaceId && route.startsWith('/api/mothership')) {
try {
eligible = await isWorkspaceOnEnterprisePlan(workspaceId)
} catch (error) {
logger.warn('Failed to resolve BYOK eligibility; defaulting to hosted', {
workspaceId,
error: toError(error).message,
})
}
}
return { ...payload, enterpriseByokEligible: eligible }
}
function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): boolean {
return !!(options.abortSignal?.aborted || context.wasAborted)
}
function cancelPendingTools(context: StreamingContext): void {
for (const [, toolCall] of context.toolCalls) {
if (toolCall.status === 'pending' || toolCall.status === 'executing') {
setTerminalToolCallState(toolCall, {
status: MothershipStreamV1ToolOutcome.cancelled,
error: 'Stopped by user',
})
}
}
}
function isRetryableStreamError(error: unknown): boolean {
if (error instanceof DOMException && error.name === 'AbortError') {
return false
}
if (error instanceof CopilotBackendError) {
return error.status !== undefined && error.status >= 500
}
if (error instanceof TypeError) {
return true
}
return false
}
function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise<void> {
if (!abortSignal) {
return sleep(ms)
}
if (abortSignal.aborted) {
return Promise.resolve()
}
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
abortSignal.removeEventListener('abort', onAbort)
resolve()
}, ms)
const onAbort = () => {
clearTimeout(timeoutId)
abortSignal.removeEventListener('abort', onAbort)
resolve()
}
abortSignal.addEventListener('abort', onAbort, { once: true })
})
}