项目文件夹

文件
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 行
39 KiB
TypeScript

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 })
})
}