Repository navigation
fix(server): preserve queued steering across restarts #21610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
69e443f
e618ea0
0e2c50e
09867bc
dded4e2
91923d2
6a20ce3
26da5b0
ca9cb9d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { CommandId } from "@t3tools/contracts"; | ||
| import { CommandId, type MessageId, type ThreadId, type RunId } from "@t3tools/contracts"; | ||
| import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; | ||
| import * as Cause from "effect/Cause"; | ||
| import * as Context from "effect/Context"; | ||
| import * as DateTime from "effect/DateTime"; | ||
|
|
@@ -31,7 +32,7 @@ import * as RuntimeRequestService from "./RuntimeRequestService.ts"; | |
| import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationService.ts"; | ||
| import * as ThreadManagementService from "./ThreadManagementService.ts"; | ||
| import * as ServerSettings from "../serverSettings.ts"; | ||
| import { continueRestartedRun } from "./RestartContinuation.ts"; | ||
| import { continueRestartedRun, restartContinuationBlocked } from "./RestartContinuation.ts"; | ||
|
|
||
| const isProviderTurnControlError = Schema.is(ProviderTurnControlService.ProviderTurnControlError); | ||
|
|
||
|
|
@@ -102,6 +103,112 @@ export const layerExecutor: Layer.Layer< | |
| const threads = yield* ThreadManagementService.ThreadManagementService; | ||
| const projectionStore = yield* ProjectionStore.ProjectionStoreV2; | ||
| const settings = yield* ServerSettings.ServerSettingsService; | ||
| // Reuse message ids and receipts so retries cannot duplicate messages or follow-ups. | ||
| const followUpSteer = Effect.fn("EffectWorker.followUpSteer")(function* (input: { | ||
| readonly effectId: string; | ||
| readonly threadId: ThreadId; | ||
| readonly messageId: MessageId; | ||
| readonly restartContinuationOfRunId?: RunId; | ||
| readonly declined?: Extract< | ||
| EffectOutbox.OrchestrationEffectRequestV2, | ||
| { type: "provider-turn.steer" } | ||
| >; | ||
| }) { | ||
| const { threadId, declined, restartContinuationOfRunId } = input; | ||
| const projection = yield* threads.getThreadRecords(threadId, ["messages", "runs"], { | ||
| messageIds: [input.messageId], | ||
| }); | ||
| const message = projection.messages.find((item) => item.id === input.messageId); | ||
| const run = projection.runs.find((item) => item.id === message?.runId); | ||
| if (message === undefined || run === undefined) return false; | ||
| // Notifications must be queued messages; recovered steering waits behind active work. | ||
| const queued = | ||
| message.delegatedCompletion !== undefined || | ||
| message.notification !== undefined || | ||
| declined !== undefined || | ||
| restartContinuationOfRunId !== undefined; | ||
| yield* threads | ||
| .dispatch({ | ||
| type: "message.dispatch", | ||
| commandId: CommandId.make(`command:steer-follow-up:${input.effectId}`), | ||
| threadId, | ||
| messageId: message.id, | ||
| ...(declined ? { steerFollowUpOfRunId: run.id } : {}), | ||
| ...(declined?.steerFollowUpAfterRunOrdinal !== undefined | ||
| ? { steerFollowUpAfterRunOrdinal: declined.steerFollowUpAfterRunOrdinal } | ||
| : {}), | ||
| ...(restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId }), | ||
| text: message.text, | ||
| ...(message.context ? { context: message.context } : {}), | ||
| attachments: message.attachments, | ||
| ...(declined?.modelSelection !== undefined | ||
| ? { modelSelection: declined.modelSelection } | ||
| : message.delegatedCompletion === undefined | ||
| ? {} | ||
| : { modelSelection: run.modelSelection }), | ||
| dispatchMode: { type: queued ? "queue_after_active" : "start_immediately" }, | ||
| createdBy: message.createdBy, | ||
| creationSource: message.creationSource, | ||
| ...(message.delegatedCompletion === undefined | ||
| ? {} | ||
| : { delegatedCompletion: message.delegatedCompletion }), | ||
| ...(message.notification === undefined ? {} : { notification: message.notification }), | ||
| ...(message.scheduledTaskId === undefined | ||
| ? {} | ||
| : { scheduledTaskId: message.scheduledTaskId }), | ||
| ...(message.senderThreadId === undefined | ||
| ? {} | ||
| : { senderThreadId: message.senderThreadId }), | ||
| ...(message.linkedSender === undefined ? {} : { linkedSender: message.linkedSender }), | ||
| }) | ||
| .pipe( | ||
| // A disabled instance cannot retry this completed turn; its result stands. | ||
| Effect.catchTags({ | ||
| ThreadManagementProviderInstanceDisabledError: (disabledError) => | ||
| Effect.logInfo("orchestration-v2.effect-worker.steer-follow-up-disabled", { | ||
| threadId, | ||
| messageId: message.id, | ||
| projectId: disabledError.projectId, | ||
| instanceId: disabledError.instanceId, | ||
| }), | ||
| }), | ||
| ); | ||
| return true; | ||
| }); | ||
| const recoverSteering = Effect.fn("EffectWorker.recoverSteering")(function* ( | ||
| threadId: ThreadId, | ||
| request: Extract< | ||
| EffectOutbox.OrchestrationEffectRequestV2, | ||
| { type: "provider-turn.steer-follow-up" } | ||
| >, | ||
| ) { | ||
| const enabled = yield* settings.getSettings.pipe(Effect.orElseSucceed(() => null)); | ||
| if (enabled === null) return; | ||
|
Comment on lines
+185
to
+186
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Propagate settings-read failures so steering recovery can retry. If Keep settings-read failures in the error channel. Reserve successful early returns for settings that explicitly disable continuation.
|
||
| const projection = yield* threads.getThreadRecords( | ||
| threadId, | ||
| ["runs", "providerTurns", "runtimeRequests", "turnItems"], | ||
| { turnItemTypes: ["secret_request"] }, | ||
| ); | ||
| if ( | ||
| !resolveProjectSettings(enabled, projection.thread.projectId).settings | ||
| .continueThreadsAfterServerUpdate | ||
| ) | ||
| return; | ||
| const source = projection.runs.find((run) => run.id === request.sourceRunId); | ||
| if ( | ||
| source === undefined || | ||
| restartContinuationBlocked(projection, source, yield* DateTime.now, true) | ||
| ) | ||
| return; | ||
| for (const steer of request.steers) { | ||
| yield* followUpSteer({ | ||
| effectId: steer.effectId, | ||
| threadId, | ||
| messageId: steer.messageId, | ||
| restartContinuationOfRunId: source.id, | ||
| }); | ||
| } | ||
| }); | ||
| return OrchestrationEffectExecutorV2.of({ | ||
| execute: Effect.fn("OrchestrationEffectExecutorV2.execute")(function (effect, options) { | ||
| const willRetry = options?.willRetry ?? false; | ||
|
|
@@ -126,6 +233,17 @@ export const layerExecutor: Layer.Layer< | |
| ), | ||
| ); | ||
| } | ||
| case "provider-turn.steer-follow-up": | ||
| return recoverSteering(effect.threadId, effect.request).pipe( | ||
| Effect.mapError( | ||
| (cause) => | ||
| new OrchestrationEffectExecutionError({ | ||
| effectId: effect.id, | ||
| effectType: effect.request.type, | ||
| cause, | ||
| }), | ||
| ), | ||
| ); | ||
| case "provider-session.detach": | ||
| return providerSessions | ||
| .detach({ | ||
|
|
@@ -298,79 +416,13 @@ export const layerExecutor: Layer.Layer< | |
| ) { | ||
| return yield* error; | ||
| } | ||
| const projection = yield* threads.getThreadRecords( | ||
| effect.threadId, | ||
| ["messages", "runs"], | ||
| { messageIds: [effect.request.messageId] }, | ||
| ); | ||
| const messageId = effect.request.messageId; | ||
| const message = projection.messages.find((item) => item.id === messageId); | ||
| const run = projection.runs.find((item) => item.id === message?.runId); | ||
| if (message === undefined || run === undefined) return yield* error; | ||
| // Notifications must be queued messages. | ||
| const queued = | ||
| message.delegatedCompletion !== undefined || | ||
| message.notification !== undefined || | ||
| error.steerDeclined === true; | ||
| // Reuse message ids and receipts so retries cannot duplicate messages or follow-ups. | ||
| yield* threads | ||
| .dispatch({ | ||
| type: "message.dispatch", | ||
| commandId: CommandId.make(`command:steer-follow-up:${effect.id}`), | ||
| threadId: effect.threadId, | ||
| messageId: message.id, | ||
| ...(error.steerDeclined ? { steerFollowUpOfRunId: run.id } : {}), | ||
| ...(error.steerDeclined && | ||
| effect.request.steerFollowUpAfterRunOrdinal !== undefined | ||
| ? { | ||
| steerFollowUpAfterRunOrdinal: | ||
| effect.request.steerFollowUpAfterRunOrdinal, | ||
| } | ||
| : {}), | ||
| text: message.text, | ||
| ...(message.context ? { context: message.context } : {}), | ||
| attachments: message.attachments, | ||
| ...(error.steerDeclined && effect.request.modelSelection !== undefined | ||
| ? { modelSelection: effect.request.modelSelection } | ||
| : message.delegatedCompletion === undefined | ||
| ? {} | ||
| : { modelSelection: run.modelSelection }), | ||
| dispatchMode: { | ||
| type: queued ? "queue_after_active" : "start_immediately", | ||
| }, | ||
| createdBy: message.createdBy, | ||
| creationSource: message.creationSource, | ||
| ...(message.delegatedCompletion === undefined | ||
| ? {} | ||
| : { delegatedCompletion: message.delegatedCompletion }), | ||
| ...(message.notification === undefined | ||
| ? {} | ||
| : { notification: message.notification }), | ||
| ...(message.scheduledTaskId === undefined | ||
| ? {} | ||
| : { scheduledTaskId: message.scheduledTaskId }), | ||
| ...(message.senderThreadId === undefined | ||
| ? {} | ||
| : { senderThreadId: message.senderThreadId }), | ||
| ...(message.linkedSender === undefined | ||
| ? {} | ||
| : { linkedSender: message.linkedSender }), | ||
| }) | ||
| .pipe( | ||
| // A disabled instance cannot retry this completed turn; its result stands. | ||
| Effect.catchTags({ | ||
| ThreadManagementProviderInstanceDisabledError: (disabledError) => | ||
| Effect.logInfo( | ||
| "orchestration-v2.effect-worker.steer-follow-up-disabled", | ||
| { | ||
| threadId: effect.threadId, | ||
| messageId: message.id, | ||
| projectId: disabledError.projectId, | ||
| instanceId: disabledError.instanceId, | ||
| }, | ||
| ), | ||
| }), | ||
| ); | ||
| const followedUp = yield* followUpSteer({ | ||
| effectId: effect.id, | ||
| threadId: effect.threadId, | ||
| messageId: effect.request.messageId, | ||
| ...(error.steerDeclined ? { declined: effect.request } : {}), | ||
| }); | ||
| if (!followedUp) return yield* error; | ||
| }), | ||
| ), | ||
| Effect.mapError( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep recovered steering from releasing a stopped queue.
Recovery preserves the original user/web or user/mobile origin.
sentByUserFromClientexcludessteerFollowUpOfRunId, but notrestartContinuationOfRunId.If Stop interrupts recovered S1 between the S1 and S2 dispatches, S2 can pass the new sibling exception while S1 is still running. S2 then clears existing queue holds as a client send. Queued work can resume after Stop.
Exclude restart continuations from the client-send classification. Add a focused case that orders Stop between the recovered dispatches.