526 lines
20 KiB
TypeScript
526 lines
20 KiB
TypeScript
import type { Session, TaskRunStatus } from "@trigger.dev/database";
|
|
import { SessionTriggerConfig as SessionTriggerConfigZod } from "@trigger.dev/core/v3";
|
|
import type { z } from "zod";
|
|
import { prisma, $replica } from "~/db.server";
|
|
import { runStore } from "~/v3/runStore.server";
|
|
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
|
|
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
|
|
import { isFinalRunStatus } from "~/v3/taskStatus";
|
|
|
|
/**
|
|
* Schema for `Session.triggerConfig` (stored as JSONB). The wire-format
|
|
* source of truth lives in `@trigger.dev/core/v3` as `SessionTriggerConfig`;
|
|
* we re-export it here for the trigger machinery to validate on read.
|
|
*
|
|
* `basePayload` carries the customer's wire payload (for chat.agent:
|
|
* `{ chatId, ...clientData, idleTimeoutInSeconds? }`). Runtime fields
|
|
* specific to a particular trigger (e.g. `trigger: "trigger" | "preload"`,
|
|
* an `isContinuation` flag) come in via the `payloadOverrides` argument
|
|
* to `ensureRunForSession` and shallow-merge on top of `basePayload`.
|
|
*/
|
|
export const SessionTriggerConfigSchema = SessionTriggerConfigZod;
|
|
|
|
export type SessionTriggerConfig = z.infer<typeof SessionTriggerConfigSchema>;
|
|
|
|
export type EnsureRunReason = "initial" | "continuation" | "upgrade" | "manual";
|
|
|
|
/**
|
|
* Hard cap on how many times `ensureRunForSession` will recurse on the
|
|
* pathological "we lost the claim race AND the winner's run was already
|
|
* terminal" path. In practice progress through the run engine bounds
|
|
* this, but a misconfigured task that crashes before it can be dequeued
|
|
* could otherwise loop without limit. After this many attempts we
|
|
* surface `SessionRunManagerError` so the caller can 5xx instead of
|
|
* blowing the stack.
|
|
*/
|
|
const ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS = 3;
|
|
|
|
type EnsureRunForSessionParams = {
|
|
/**
|
|
* Session row to operate on. Caller is responsible for the env match —
|
|
* we don't re-check `runtimeEnvironmentId` against `environment.id`.
|
|
*
|
|
* `friendlyId` is used to pre-populate `payload.sessionId` on the new
|
|
* run so the agent's `chat.agent` boot path can attach to `session.in/.out`
|
|
* without a control-plane round-trip. `currentRunId` is also forwarded
|
|
* as `payload.previousRunId` (with `continuation: true`) when the prior
|
|
* run is dead, so the agent's boot gate triggers snapshot.read + replay
|
|
* instead of treating the run as a fresh chat.
|
|
*/
|
|
session: Pick<
|
|
Session,
|
|
"id" | "friendlyId" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion"
|
|
>;
|
|
environment: AuthenticatedEnvironment;
|
|
reason: EnsureRunReason;
|
|
/**
|
|
* Shallow-merged on top of `triggerConfig.basePayload`. Runtime fields
|
|
* only — caller-controlled data that varies per trigger (`trigger:
|
|
* "preload"` vs `"trigger"`, etc).
|
|
*/
|
|
payloadOverrides?: Record<string, unknown>;
|
|
/**
|
|
* @internal Recursion-guard counter for the lost-claim-race retry path.
|
|
* Public callers should leave this unset; the function recurses with
|
|
* an incremented value on the pathological "winner's run was already
|
|
* terminal" branch and throws once it exceeds
|
|
* {@link ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS}.
|
|
*/
|
|
_attempt?: number;
|
|
};
|
|
|
|
export type EnsureRunResult = {
|
|
runId: string;
|
|
/** True if this call triggered a fresh run; false if it reused an alive existing one. */
|
|
triggered: boolean;
|
|
};
|
|
|
|
/**
|
|
* Idempotently make sure the session has a live run.
|
|
*
|
|
* Algorithm:
|
|
* 1. If `currentRunId` is set, probe its status. Alive → return as-is.
|
|
* 2. Trigger a new run upfront (cheap to cancel if we lose the race).
|
|
* 3. Atomic claim via `updateMany` keyed on `currentRunVersion`.
|
|
* - Won: return new runId, record SessionRun audit row.
|
|
* - Lost: cancel our triggered run, re-read session, reuse winner's
|
|
* run if alive. If pathological (winner's run already terminal),
|
|
* recurse.
|
|
*
|
|
* No DB lock is held across the trigger call. Wasted-trigger window is
|
|
* the rare multi-tab race on a dead run; cancel cost is negligible and
|
|
* the run-engine handles it gracefully.
|
|
*/
|
|
export async function ensureRunForSession(
|
|
params: EnsureRunForSessionParams
|
|
): Promise<EnsureRunResult> {
|
|
const { session, environment, reason, payloadOverrides, _attempt = 1 } = params;
|
|
|
|
if (_attempt > ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS) {
|
|
throw new SessionRunManagerError(
|
|
`ensureRunForSession exceeded ${ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS} attempts for session ${session.id} — every triggered run reached a terminal state before claim could resolve`
|
|
);
|
|
}
|
|
|
|
// 1. Probe currentRunId.
|
|
let priorDeadRunFriendlyId: string | undefined;
|
|
if (session.currentRunId) {
|
|
let probe = await getRunStatusAndFriendlyId(session.currentRunId);
|
|
if (!probe) {
|
|
// Replica miss on a row we just observed via `currentRunId` — the
|
|
// run was likely triggered moments ago and hasn't replicated yet.
|
|
// Re-probe the writer BEFORE deciding liveness: treating a lagging
|
|
// replica as "row vanished" double-triggers the session (a fast
|
|
// first append after session create races the replica apply delay
|
|
// and spawns a second live run consuming the same `.in`).
|
|
probe = await runStore.findRun(
|
|
{ id: session.currentRunId },
|
|
{ select: { status: true, friendlyId: true } },
|
|
prisma
|
|
);
|
|
}
|
|
if (probe && !isFinalRunStatus(probe.status)) {
|
|
return { runId: session.currentRunId, triggered: false };
|
|
}
|
|
// Either the row vanished on the writer too (probe null) or its status
|
|
// is final. Either way the prior run isn't going to consume new
|
|
// appends — but the session may still hold conversation state on
|
|
// `session.out` and an S3 snapshot keyed on `session.friendlyId`.
|
|
// Forward the prior run's public-form id (friendlyId — same shape as
|
|
// `ctx.run.id`) to the agent as `previousRunId` so its boot gate flips
|
|
// `couldHavePriorState` and replays the persisted state instead of
|
|
// treating this as a fresh chat. See `chat.agent`'s boot orchestration
|
|
// in `packages/trigger-sdk/src/v3/ai.ts`.
|
|
priorDeadRunFriendlyId = probe?.friendlyId ?? session.currentRunId;
|
|
}
|
|
|
|
// 2. Validate config + trigger upfront. Continuation overrides
|
|
// (`continuation`, `previousRunId`) are derived from session state above
|
|
// and merged AFTER caller-supplied overrides — caller can't accidentally
|
|
// unset them on a session that has had a prior run, but can still
|
|
// override `trigger`/`metadata` etc. `sessionId` is always set so the
|
|
// agent doesn't need a control-plane round-trip to look up the session
|
|
// friendlyId from `payload.chatId`.
|
|
// Continuation overrides strip the basePayload's first-run-only fields
|
|
// so a continuation run doesn't inherit a stale boot payload. The Session
|
|
// row's `triggerConfig.basePayload` is captured at create-time and used
|
|
// verbatim for every Run we trigger; if the customer included `message`
|
|
// / `messages` / `trigger: "submit-message"` to make the FIRST run boot
|
|
// straight into a first turn (via `chat.createStartSessionAction`), those
|
|
// values stick around and get replayed on every continuation. With
|
|
// `continuation: true` and `message`/`messages` cleared, the SDK boot
|
|
// path enters its continuation-wait branch and waits for the next
|
|
// session.in record before running a turn.
|
|
const continuationOverrides: Record<string, unknown> = {
|
|
sessionId: session.friendlyId,
|
|
...(priorDeadRunFriendlyId !== undefined
|
|
? {
|
|
continuation: true,
|
|
previousRunId: priorDeadRunFriendlyId,
|
|
// Clear sticky boot-payload fields so the new run waits for the
|
|
// next session.in record instead of re-processing whatever was
|
|
// in the original `createStartSessionAction({ basePayload })`.
|
|
message: undefined,
|
|
messages: undefined,
|
|
trigger: undefined,
|
|
}
|
|
: {}),
|
|
};
|
|
const mergedPayloadOverrides: Record<string, unknown> = {
|
|
...(payloadOverrides ?? {}),
|
|
...continuationOverrides,
|
|
};
|
|
|
|
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
|
|
const triggered = await triggerSessionRun({
|
|
session,
|
|
config,
|
|
environment,
|
|
payloadOverrides: mergedPayloadOverrides,
|
|
});
|
|
|
|
// 3. Try to claim the slot atomically.
|
|
const claim = await prisma.session.updateMany({
|
|
where: {
|
|
id: session.id,
|
|
currentRunVersion: session.currentRunVersion,
|
|
},
|
|
data: {
|
|
currentRunId: triggered.id,
|
|
currentRunVersion: { increment: 1 },
|
|
},
|
|
});
|
|
|
|
if (claim.count === 1) {
|
|
// Won. Audit the SessionRun. Best-effort — failure here doesn't
|
|
// invalidate the live run, just leaves a missing audit row.
|
|
prisma.sessionRun
|
|
.create({
|
|
data: { sessionId: session.id, runId: triggered.id, reason },
|
|
})
|
|
.catch((error) => {
|
|
logger.warn("Failed to record SessionRun audit row", {
|
|
sessionId: session.id,
|
|
runId: triggered.id,
|
|
reason,
|
|
error,
|
|
});
|
|
});
|
|
|
|
return { runId: triggered.id, triggered: true };
|
|
}
|
|
|
|
// 4. Lost the race. Cancel our triggered run; reuse the winner's.
|
|
cancelLostRaceRun(triggered.id, environment).catch((error) => {
|
|
logger.warn("Failed to cancel lost-race session run", {
|
|
sessionId: session.id,
|
|
runId: triggered.id,
|
|
error,
|
|
});
|
|
});
|
|
|
|
// Read-after-write: the winner just wrote `currentRunId` /
|
|
// `currentRunVersion` on the writer. Reading from `$replica` could
|
|
// return pre-race state and cause us to recurse with the same stale
|
|
// version, losing the next claim, until we exhaust max attempts.
|
|
const fresh = await prisma.session.findFirst({
|
|
where: { id: session.id },
|
|
select: {
|
|
id: true,
|
|
friendlyId: true,
|
|
taskIdentifier: true,
|
|
triggerConfig: true,
|
|
currentRunId: true,
|
|
currentRunVersion: true,
|
|
},
|
|
});
|
|
|
|
if (!fresh) {
|
|
// Session vanished mid-flight. Surface as an error — caller decides
|
|
// whether to 404 or retry.
|
|
throw new SessionRunManagerError(`Session ${session.id} not found after lost claim race`);
|
|
}
|
|
|
|
if (fresh.currentRunId) {
|
|
// Same read-after-write reason as the `fresh` reload above: the winner
|
|
// just wrote `currentRunId` on the writer, so probe the writer too —
|
|
// the replica may not have the run row yet, and a missed probe forces
|
|
// another trigger+recurse until `ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS`.
|
|
const probe = await runStore.findRun(
|
|
{ id: fresh.currentRunId },
|
|
{ select: { status: true, friendlyId: true } },
|
|
prisma
|
|
);
|
|
if (probe && !isFinalRunStatus(probe.status)) {
|
|
return { runId: fresh.currentRunId, triggered: false };
|
|
}
|
|
}
|
|
|
|
// Pathological: winner's run already terminal. Recurse with the fresh
|
|
// version. Bounded by `ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS` so a task
|
|
// that always crashes before being dequeued surfaces as an error
|
|
// instead of a stack overflow.
|
|
return ensureRunForSession({
|
|
session: fresh,
|
|
environment,
|
|
reason,
|
|
payloadOverrides,
|
|
_attempt: _attempt + 1,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trigger a single run for a session. Builds `TriggerTaskRequestBody`
|
|
* by shallow-merging `payloadOverrides` over `config.basePayload` and
|
|
* threading `config`'s machine/queue/tags through the trigger options.
|
|
*/
|
|
async function triggerSessionRun(params: {
|
|
session: Pick<Session, "id" | "taskIdentifier">;
|
|
config: SessionTriggerConfig;
|
|
environment: AuthenticatedEnvironment;
|
|
payloadOverrides?: Record<string, unknown>;
|
|
}): Promise<{ id: string; friendlyId: string }> {
|
|
const { session, config, environment, payloadOverrides } = params;
|
|
|
|
const payload = {
|
|
...config.basePayload,
|
|
...(config.idleTimeoutInSeconds !== undefined
|
|
? { idleTimeoutInSeconds: config.idleTimeoutInSeconds }
|
|
: {}),
|
|
...(payloadOverrides ?? {}),
|
|
};
|
|
|
|
const body = {
|
|
payload,
|
|
context: {},
|
|
options: {
|
|
...(config.machine ? { machine: config.machine as never } : {}),
|
|
...(config.queue ? { queue: { name: config.queue } } : {}),
|
|
...(config.tags ? { tags: config.tags } : {}),
|
|
...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}),
|
|
...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}),
|
|
...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}),
|
|
...(config.region ? { region: config.region } : {}),
|
|
},
|
|
};
|
|
|
|
const service = new TriggerTaskService();
|
|
const result = await service.call(session.taskIdentifier, environment, body, {
|
|
triggerSource: "session",
|
|
triggerAction: "trigger",
|
|
});
|
|
|
|
if (!result) {
|
|
throw new SessionRunManagerError(
|
|
`TriggerTaskService returned no result for taskIdentifier=${session.taskIdentifier}`
|
|
);
|
|
}
|
|
|
|
return { id: result.run.id, friendlyId: result.run.friendlyId };
|
|
}
|
|
|
|
type SwapSessionRunParams = {
|
|
/**
|
|
* Session row to swap. `friendlyId` is forwarded as `payload.sessionId`
|
|
* on the new run so the agent attaches to `session.in/.out` without a
|
|
* control-plane round-trip (same convention as
|
|
* {@link EnsureRunForSessionParams}).
|
|
*/
|
|
session: Pick<
|
|
Session,
|
|
"id" | "friendlyId" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion"
|
|
>;
|
|
/**
|
|
* The run requesting the swap. Optimistic claim requires
|
|
* `Session.currentRunId === callingRunId` so the swap can't clobber
|
|
* a run triggered out-of-band (e.g. a parallel `.in/append` probe
|
|
* that already replaced the dead run).
|
|
*
|
|
* Also forwarded as `payload.previousRunId` on the new run alongside
|
|
* `continuation: true` — every swap is a continuation by construction
|
|
* (`chat.requestUpgrade` / `chat.endRun` deliberately hand off prior
|
|
* conversation state to a new run), so the agent's boot gate flips
|
|
* `couldHavePriorState` and replays the snapshot + session.out tail.
|
|
*/
|
|
callingRunId: string;
|
|
environment: AuthenticatedEnvironment;
|
|
reason: EnsureRunReason;
|
|
payloadOverrides?: Record<string, unknown>;
|
|
};
|
|
|
|
export type SwapSessionRunResult = {
|
|
/** runId of the newly-triggered run that has taken over the session. */
|
|
runId: string;
|
|
/**
|
|
* False when the swap was preempted (currentRunId is no longer the
|
|
* calling run). The caller should treat this as "someone else
|
|
* already moved on" — exit cleanly without expecting to drive the
|
|
* next run.
|
|
*/
|
|
swapped: boolean;
|
|
};
|
|
|
|
/**
|
|
* Force-swap the session to a freshly-triggered run, regardless of
|
|
* whether the current run is alive. Called by `end-and-continue` when
|
|
* the running agent wants a clean handoff (typically version upgrade).
|
|
*
|
|
* Differs from `ensureRunForSession`: never reuses the current run.
|
|
* The optimistic claim is keyed on `currentRunId === callingRunId`, so
|
|
* a parallel append-time probe that already swapped to a different
|
|
* run wins the race and `swapped: false` is surfaced.
|
|
*/
|
|
export async function swapSessionRun(params: SwapSessionRunParams): Promise<SwapSessionRunResult> {
|
|
const { session, callingRunId, environment, reason, payloadOverrides } = params;
|
|
|
|
// `callingRunId` is the internal cuid (`Session.currentRunId` stores
|
|
// cuid; the route handler resolves the wire's friendlyId before passing
|
|
// it here). The agent's `previousRunId` is customer-visible and must
|
|
// match the public `run_*` form exposed via `ctx.run.id` — resolve
|
|
// before forwarding.
|
|
const callingRunFriendlyId = await resolveRunFriendlyId(callingRunId);
|
|
|
|
// Continuation overrides — unconditionally set on swap. Unlike
|
|
// `ensureRunForSession`, there's no dead-run-detection branch here:
|
|
// every swap is a deliberate handoff from `callingRunId` (which owned
|
|
// prior conversation state) to a fresh run. Merged AFTER caller-supplied
|
|
// overrides so a caller can't accidentally unset them.
|
|
//
|
|
// Sticky boot-payload fields (`message` / `messages` / `trigger`) are
|
|
// cleared here for the same reason as in `ensureRunForSession`: the
|
|
// Session's basePayload is captured at create-time and replays on every
|
|
// continuation if not stripped. See the comment in `ensureRunForSession`.
|
|
const mergedPayloadOverrides: Record<string, unknown> = {
|
|
...(payloadOverrides ?? {}),
|
|
sessionId: session.friendlyId,
|
|
continuation: true,
|
|
previousRunId: callingRunFriendlyId,
|
|
message: undefined,
|
|
messages: undefined,
|
|
trigger: undefined,
|
|
};
|
|
|
|
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
|
|
const triggered = await triggerSessionRun({
|
|
session,
|
|
config,
|
|
environment,
|
|
payloadOverrides: mergedPayloadOverrides,
|
|
});
|
|
|
|
const claim = await prisma.session.updateMany({
|
|
where: {
|
|
id: session.id,
|
|
currentRunId: callingRunId,
|
|
currentRunVersion: session.currentRunVersion,
|
|
},
|
|
data: {
|
|
currentRunId: triggered.id,
|
|
currentRunVersion: { increment: 1 },
|
|
},
|
|
});
|
|
|
|
if (claim.count === 1) {
|
|
prisma.sessionRun
|
|
.create({
|
|
data: { sessionId: session.id, runId: triggered.id, reason },
|
|
})
|
|
.catch((error) => {
|
|
logger.warn("Failed to record SessionRun audit row", {
|
|
sessionId: session.id,
|
|
runId: triggered.id,
|
|
reason,
|
|
error,
|
|
});
|
|
});
|
|
return { runId: triggered.id, swapped: true };
|
|
}
|
|
|
|
// Lost the race — someone else already swapped to a new run. Cancel
|
|
// ours, surface the existing winner.
|
|
cancelLostRaceRun(triggered.id, environment).catch((error) => {
|
|
logger.warn("Failed to cancel preempted swap run", {
|
|
sessionId: session.id,
|
|
runId: triggered.id,
|
|
error,
|
|
});
|
|
});
|
|
|
|
// Read-after-write: the winner's swap was just committed on the
|
|
// writer. A replica read could return the pre-swap `currentRunId`
|
|
// (often `callingRunId` itself), which would tell the caller it is
|
|
// still the canonical run when in fact a different run has taken
|
|
// over.
|
|
const fresh = await prisma.session.findFirst({
|
|
where: { id: session.id },
|
|
select: { currentRunId: true },
|
|
});
|
|
|
|
// Mirror `ensureRunForSession`'s "session vanished" branch: if we
|
|
// can't find the row (or it has no current run) on the writer right
|
|
// after losing the race, surface as an error rather than handing back
|
|
// `callingRunId` with `swapped: false` — that would tell the caller
|
|
// it's still the canonical run when in fact we don't know who is.
|
|
if (!fresh?.currentRunId) {
|
|
throw new SessionRunManagerError(
|
|
`Session ${session.id} has no currentRunId after preempted swap`
|
|
);
|
|
}
|
|
|
|
return {
|
|
runId: fresh.currentRunId,
|
|
swapped: false,
|
|
};
|
|
}
|
|
|
|
async function getRunStatusAndFriendlyId(
|
|
runId: string
|
|
): Promise<{ status: TaskRunStatus; friendlyId: string } | null> {
|
|
// Use the read replica — this is a hot-path probe and stale-by-ms is
|
|
// fine. The append handler re-checks if it ends up reusing the runId.
|
|
// `friendlyId` is fetched alongside `status` so the dead-run-detection
|
|
// branch in `ensureRunForSession` can forward the public-form id as
|
|
// `payload.previousRunId` without a second read. `Session.currentRunId`
|
|
// stores the internal cuid; the agent's wire / customer hooks expose
|
|
// the friendlyId via `ctx.run.id`, so consistency matters.
|
|
const row = await runStore.findRun(
|
|
{ id: runId },
|
|
{ select: { status: true, friendlyId: true } },
|
|
$replica
|
|
);
|
|
return row ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resolve a TaskRun cuid to its friendlyId. Used by `swapSessionRun` to
|
|
* forward the calling run's public-form id as `payload.previousRunId` on
|
|
* the new run. Falls back to the cuid on lookup miss so the swap doesn't
|
|
* fail just because the read replica hasn't caught up — the agent only
|
|
* uses `previousRunId` for customer-visible bookkeeping (e.g.
|
|
* `runs.retrieve(previousRunId)`), so a stale-but-non-null value is
|
|
* acceptable degraded behavior.
|
|
*/
|
|
async function resolveRunFriendlyId(runId: string): Promise<string> {
|
|
const row = await runStore.findRun({ id: runId }, { select: { friendlyId: true } }, $replica);
|
|
return row?.friendlyId ?? runId;
|
|
}
|
|
|
|
async function cancelLostRaceRun(
|
|
runId: string,
|
|
environment: AuthenticatedEnvironment
|
|
): Promise<void> {
|
|
const service = new CancelTaskRunService();
|
|
// Read-after-write: the run was just triggered on the writer, so go
|
|
// through `prisma`. A `$replica` miss here would silently no-op the
|
|
// cancel and leak an orphan run that no session is going to claim.
|
|
const run = await runStore.findRun({ id: runId }, prisma);
|
|
if (!run) return;
|
|
await service.call(run, { reason: "Lost session-run claim race" });
|
|
}
|
|
|
|
export class SessionRunManagerError extends Error {
|
|
readonly name = "SessionRunManagerError";
|
|
}
|