Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 116 additions & 9 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { latestRootProviderFailure } from "@t3tools/shared/orchestrationV2ThreadError";
import {
latestExecutedRun,
latestRootProviderFailure,
usageLimitBlockedRun,
} from "@t3tools/shared/orchestrationV2ThreadError";
import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests";
import {
normalizeThreadPullRequestKey,
Expand Down Expand Up @@ -1099,7 +1103,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
]);
});

const startNextQueuedRun = (threadId: ThreadId) =>
const startNextQueuedRun = (threadId: ThreadId, options?: { readonly failedRunId?: RunId }) =>
Effect.gen(function* () {
// Every terminal run checks the queue. Only a deliverable queued run
// needs the transcript for provider handoff and legacy import context.
Expand All @@ -1114,10 +1118,51 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
return;
}

// The limit already stopped this thread. Starting the queue would send
// every waiting message and drop it from the queue as each one fails.
const sessionError =
projection.providerSessions
.filter((session) => session.providerInstanceId === projection.thread.providerInstanceId)
.toSorted(
(left, right) =>
DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
)[0]?.lastError ?? null;
if (usageLimitBlockedRun(projection.runs, projection.turnItems, sessionError) !== null) {
return;
}
const queuedRun = nextQueuedRun(projection);
if (queuedRun === undefined) {
return;
}
// A provider that just failed will likely fail the next message too.
// Hold the queue so the user decides when to resume it. Validation
// failures (setup, unsupported handoff) belong to that message alone,
// and a message queued for another provider is how users recover.
const failedRun = latestExecutedRun(projection.runs);
const failureClass =
failedRun?.id === options?.failedRunId
? latestRootProviderFailure(failedRun, projection.turnItems)?.class
: undefined;
if (
failureClass !== undefined &&
failureClass !== "validation_error" &&
failedRun?.providerInstanceId === queuedRun.providerInstanceId
) {
const now = yield* DateTime.now;
yield* writeSystemEvents(
projection.runs
.filter((run) => run.status === "queued")
.map((run) => ({
Comment thread
Yash-Singh1 marked this conversation as resolved.
type: "run.updated" as const,
threadId,
runId: run.id,
providerInstanceId: run.providerInstanceId,
occurredAt: now,
payload: { ...run, queueHeld: true },
})),
);
return;
}
const rootNodeId = queuedRun.rootNodeId;
const attemptId = queuedRun.activeAttemptId;
const providerThreadId = queuedRun.providerThreadId;
Expand Down Expand Up @@ -2377,7 +2422,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
["runs", "runtimeRequests", "turnItems"],
{ turnItemTypes: ["error"] },
);
const run = projection.runs.at(-1) ?? null;
const run = usageLimitBlockedRun(projection.runs, projection.turnItems, null);
const failure = latestRootProviderFailure(run, projection.turnItems);
const resetMs = Date.parse(command.limitRecovery.resetAt);
if (command.limitRecovery.snooze === true && resetMs <= DateTime.toEpochMillis(now)) {
Expand All @@ -2395,8 +2440,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
failure?.class !== "usage_limit" ||
failure.resetAt !== command.limitRecovery.resetAt ||
resetMs <= DateTime.toEpochMillis(run.completedAt ?? run.requestedAt) ||
projection.runtimeRequests.some((request) => request.status === "pending") ||
projection.runs.some((candidate) => candidate.status === "queued")
projection.runtimeRequests.some((request) => request.status === "pending")
) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
Expand Down Expand Up @@ -4015,8 +4059,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
) =>
Effect.gen(function* () {
let projection = yield* getProjectionWithPendingEvents(command.threadId, events);
if (command.manualContinuationOfRunId !== undefined) {
const source = projection.runs.find((run) => run.id === command.manualContinuationOfRunId);
const limited = latestRootProviderFailure(source ?? null, projection.turnItems);
if (
command.dispatchMode.type !== "start_immediately" ||
source === undefined ||
(source.status !== "interrupted" &&
!(source.status === "failed" && limited?.class === "usage_limit")) ||
latestExecutedRun(projection.runs)?.id !== source.id ||
projection.thread.archivedAt !== null ||
projection.thread.deletedAt !== null ||
projection.runtimeRequests.some((request) => request.status === "pending")
) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
cause: "This thread can no longer be resumed from that run.",
});
}
}
if (command.usageLimitContinuationOfRunId !== undefined) {
const run = projection.runs.at(-1) ?? null;
const run = usageLimitBlockedRun(projection.runs, projection.turnItems, null);
const failure = latestRootProviderFailure(run, projection.turnItems);
const recovery = projection.thread.limitRecovery;
const now = yield* DateTime.now;
Expand Down Expand Up @@ -7505,6 +7569,21 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
});

const emitEvent = emit(events, command);
const holdQueuedRuns = Effect.forEach(
projection.runs.filter(
(candidate) => candidate.status === "queued" && !candidate.queueHeld,
),
(queuedRun) =>
emitEvent({
type: "run.updated",
threadId: command.threadId,
runId: queuedRun.id,
providerInstanceId: queuedRun.providerInstanceId,
occurredAt: now,
payload: { ...queuedRun, queueHeld: true },
}),
{ discard: true },
);
const interruptRequestItem: OrchestrationV2TurnItem = {
id: idAllocator.derive.runSignalTurnItem({
runId: run.id,
Expand Down Expand Up @@ -7637,6 +7716,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
occurredAt: now,
payload: { ...run, status: "interrupted", completedAt: now },
});
if (command.holdQueue === true) yield* holdQueuedRuns;
yield* stopCompletionCohort();
return {
effectTypes: ["provider-turn.start", "provider-turn.restart"],
Expand Down Expand Up @@ -7723,6 +7803,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
occurredAt: now,
payload: interruptRequestItem,
});
if (command.holdQueue === true) yield* holdQueuedRuns;
yield* stopCompletionCohort();
yield* Ref.update(effects, (existing) => [
...existing,
Expand Down Expand Up @@ -8875,8 +8956,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
yield* dispatchQueuedMessagePromoteToSteer(command, events, effects);
break;
case "queue.resume": {
const projection = yield* mapDispatchError(command)(
projectionStore.getRuntimeRecoveryProjection(command.threadId),
const projection = yield* loadProjectionForCommand(
command,
["runs", "turnItems", "providerSessions"],
{ turnItemTypes: ["error"] },
);
if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) {
return yield* new OrchestratorDispatchError({
Expand All @@ -8885,6 +8968,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
cause: `Thread ${command.threadId} is not active.`,
});
}
const sessionError =
projection.providerSessions
.filter(
(session) => session.providerInstanceId === projection.thread.providerInstanceId,
)
.toSorted(
(left, right) =>
DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
)[0]?.lastError ?? null;
if (usageLimitBlockedRun(projection.runs, projection.turnItems, sessionError) !== null) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
cause: "Continue the limited thread before resuming its queue.",
Comment thread
Yash-Singh1 marked this conversation as resolved.
});
}
const now = yield* DateTime.now;
const queued = projection.runs.filter((run) => run.status === "queued");
for (const run of queued) {
Expand Down Expand Up @@ -9139,7 +9238,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
finalizeDelegatedCompletionDelivery(threadId, stored.event.payload.id),
);
}
yield* threadDispatch.withLock(threadId, startNextQueuedRun(threadId));
yield* threadDispatch.withLock(
threadId,
startNextQueuedRun(
threadId,
stored.event.type === "run.updated" && stored.event.payload.status === "failed"
? { failedRunId: stored.event.payload.id }
: undefined,
),
);
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Failed to react to terminal V2 run", {
Expand Down
90 changes: 90 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2037,7 +2037,97 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {
});
yield* applyRun("failed");
yield* assertSummary("Plan limit reached.", "usage_limit");
const queuedRunId = RunId.make("run:limit-shell:queued");
yield* store.apply({
id: EventId.make("event:limit-shell:queued"),
type: "run.created",
threadId,
runId: queuedRunId,
nodeId: NodeId.make("node:limit-shell:queued"),
driver,
providerInstanceId,
occurredAt: now,
payload: {
...original,
id: queuedRunId,
ordinal: original.ordinal + 1,
rootNodeId: NodeId.make("node:limit-shell:queued"),
userMessageId: MessageId.make("message:limit-shell:queued"),
status: "queued",
startedAt: null,
completedAt: null,
},
});
yield* assertSummary("Plan limit reached.", "usage_limit");
const queuedProjection = yield* store.getThreadProjection(threadId);
const queuedMemoryShell = threadShellFromProjection(queuedProjection);
const queuedSqlShell = (yield* store.getShellSnapshot()).threads.find(
(row) => row.id === threadId,
)!;
assert.equal(queuedMemoryShell.status, "failed");
assert.equal(queuedMemoryShell.latestRunId, original.id);
assert.equal(queuedSqlShell.status, "failed");
assert.equal(queuedSqlShell.latestRunId, original.id);
assert.equal(queuedProjection.runs.find((run) => run.id === queuedRunId)?.status, "queued");
const cancelledRunId = RunId.make("run:limit-shell:cancelled-queued");
yield* store.apply({
id: EventId.make("event:limit-shell:cancelled-queued"),
type: "run.created",
threadId,
runId: cancelledRunId,
nodeId: NodeId.make("node:limit-shell:cancelled-queued"),
driver,
providerInstanceId,
occurredAt: now,
payload: {
...original,
id: cancelledRunId,
ordinal: original.ordinal + 2,
rootNodeId: NodeId.make("node:limit-shell:cancelled-queued"),
userMessageId: MessageId.make("message:limit-shell:cancelled-queued"),
status: "cancelled",
startedAt: null,
completedAt: now,
},
});
yield* store.apply({
id: EventId.make("event:limit-shell:cancelled-queued-message"),
type: "turn-item.updated",
threadId,
runId: cancelledRunId,
nodeId: NodeId.make("node:limit-shell:cancelled-queued"),
driver,
occurredAt: now,
payload: {
createdBy: "user",
creationSource: "web",
id: TurnItemId.make("limit-shell:cancelled-queued-message"),
threadId,
runId: cancelledRunId,
nodeId: NodeId.make("node:limit-shell:cancelled-queued"),
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 3,
status: "completed",
title: null,
startedAt: now,
completedAt: now,
updatedAt: now,
type: "user_message",
messageId: MessageId.make("message:limit-shell:cancelled-queued"),
inputIntent: "turn_start",
text: "cancelled before the provider started",
attachments: [],
},
});
yield* assertSummary("Plan limit reached.", "usage_limit");
const sql = yield* SqlClient.SqlClient;
// The rest of this case treats the failed run as the latest run.
yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${queuedRunId}`;
yield* assertSummary("Plan limit reached.", "usage_limit");
yield* sql`DELETE FROM orchestration_v2_projection_runs WHERE run_id = ${cancelledRunId}`;
const [originalRow] = yield* sql<{
payload_json: string;
}>`SELECT payload_json FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId}`;
Expand Down
Loading
Loading