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
1059 lines
39 KiB
TypeScript
1059 lines
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 })
|
||
})
|
||
}
|