From 6adce5a12a65d013c48ddf7f5282044d5fea90cf Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 30 Jul 2026 15:18:30 -0400 Subject: [PATCH] refactor: make escalation one atomic server-side arm, in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 4: four P2s, every one an interleaving of the same window — escalation as reclaim-then-repost is a compound, non-atomic operation whose continuation must revalidate the world (FIFO position lost, ref assigned too late, no run fence, competing bubble actions). Rounds 1-3 patched that window with a lock and rechecks; round 4 shows the window itself is the defect, so this removes it instead of guarding it again. Escalation is now POST /chat/steer/arm: the server flips preempt on the EXISTING queued item in one atomic store op (new IJobStore.armSteer; a decode-patch-encode LSET Lua on Redis, an in-place mutation in memory), fenced to the validated generation and refused once the queue closes. The handler mirrors the steer POST's preempt contract exactly: durable flag gated on the owner's recorded capability, volatile requestPreempt fire-and-forget because the durable flag is the truth resume/handover re-arm from. By construction this resolves all four findings: FIFO survives (the item never moves; the whole queue still drains in instruction order at the seal), there is no continuation to hold stale controls, the store op is fenced to the original run, and a competing Edit/Queue/Cancel either beats the arm (armed:false, chip untouched) or operates on the armed item, whose cancel already disarms. The client escalation entry becomes one mutation: armed:true relabels the chip in place (same steerId, same position), PREEMPT_UNSUPPORTED and lost races toast honestly, and the round 1-3 machinery — the escalating lock atom, the latest-ref, the post-reclaim rechecks and their two toast strings — is deleted rather than extended. Verified: 7 new handler tests on the real in-memory manager (including FIFO preservation and the stale-generation fence), 2 Redis integration tests against real Redis (in-place arm keeps order and every field; missing/stale/closed all refuse), client suites 396 green. --- api/server/controllers/agents/steer.js | 27 +- api/server/routes/agents/index.js | 13 + .../components/Chat/Input/InFlightSteers.tsx | 116 ++++----- .../Chat/Input/PendingSteerChips.tsx | 11 +- .../Input/__tests__/InFlightSteers.test.tsx | 240 ++++-------------- .../__tests__/PendingSteerChips.test.tsx | 19 +- client/src/data-provider/SSE/mutations.ts | 29 +++ client/src/locales/en/translation.json | 4 +- client/src/store/steer.ts | 8 - .../agents/steering/__tests__/request.spec.ts | 107 +++++++- packages/api/src/agents/steering/index.ts | 1 + packages/api/src/agents/steering/request.ts | 65 +++++ packages/api/src/stream/SteeringLifecycle.ts | 11 + .../RedisJobStore.stream_integration.spec.ts | 86 +++++++ .../implementations/InMemoryJobStore.ts | 16 ++ .../stream/implementations/RedisJobStore.ts | 38 +++ .../api/src/stream/interfaces/IJobStore.ts | 10 + 17 files changed, 506 insertions(+), 295 deletions(-) diff --git a/api/server/controllers/agents/steer.js b/api/server/controllers/agents/steer.js index baa86de010..2b6cb24a6c 100644 --- a/api/server/controllers/agents/steer.js +++ b/api/server/controllers/agents/steer.js @@ -1,4 +1,9 @@ -const { checkAccess, handleSteerRequest, handleSteerCancel } = require('@librechat/api'); +const { + checkAccess, + handleSteerRequest, + handleSteerCancel, + handleSteerArm, +} = require('@librechat/api'); const { logger, ResourceCapabilityMap } = require('@librechat/data-schemas'); const { Permissions, @@ -107,5 +112,25 @@ const SteerCancelController = async (req, res) => { } }; +/** + * POST /api/agents/chat/steer/arm + * + * Escalates a still-queued steer to an interrupt in place (the durable item + * keeps its FIFO position). `armed: false` is not an error — the steer + * already injected, was cancelled, or the deployment cannot seal mid-stream. + * No agent-access check: arming injects nothing model-bound, so ownership + * checks suffice, exactly like cancel. + */ +const SteerArmController = async (req, res) => { + try { + const { status, body } = await handleSteerArm(req.user ?? {}, req.body ?? {}); + return res.status(status).json(body); + } catch (error) { + logger.error('[SteerArmController] Failed to arm steer', error); + return res.status(500).json({ code: 'STEER_ARM_FAILED' }); + } +}; + module.exports = SteerController; module.exports.SteerCancelController = SteerCancelController; +module.exports.SteerArmController = SteerArmController; diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 217c94203b..5382b7c457 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -474,6 +474,19 @@ router.post( SteerController.SteerCancelController, ); +/** + * @route POST /chat/steer/arm + * @desc Escalate a still-queued steer to an interrupt in place (no new + * model-bound content, so no PII/moderation pass — just the shared limiters) + * @access Private + */ +router.post( + '/chat/steer/arm', + configMiddleware, + ...steerLimiters, + SteerController.SteerArmController, +); + router.use('/', v1); const chatRouter = express.Router(); diff --git a/client/src/components/Chat/Input/InFlightSteers.tsx b/client/src/components/Chat/Input/InFlightSteers.tsx index 7e84c7001f..b71db39971 100644 --- a/client/src/components/Chat/Input/InFlightSteers.tsx +++ b/client/src/components/Chat/Input/InFlightSteers.tsx @@ -1,5 +1,5 @@ import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from 'react'; -import { useSetAtom, useAtomValue } from 'jotai'; +import { useSetAtom } from 'jotai'; import { useToastContext } from '@librechat/client'; import { useRecoilValue, useRecoilCallback } from 'recoil'; import { X, Zap, ZapOff, Clock, Pencil, ChevronUp, ChevronDown } from 'lucide-react'; @@ -9,11 +9,12 @@ import type { PendingSteer } from '~/store/families'; import type { MenuEntry } from './SteerMenu'; import { RowMenu, useDefaultToggleEntry, useInterruptToggleEntry } from './SteerMenu'; import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog'; -import { steerOverlayHeightFamily, escalatingSteerFamily } from '~/store/steer'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import FileContainer from '~/components/Chat/Input/Files/FileContainer'; import { useSteerCancel, useSteerReclaim, useLocalize } from '~/hooks'; import ImagePreview from '~/components/Chat/Input/Files/ImagePreview'; +import { steerOverlayHeightFamily } from '~/store/steer'; +import { useArmSteerMutation } from '~/data-provider'; import { carriedSteerContext, cn } from '~/utils'; import store from '~/store'; @@ -130,27 +131,17 @@ const InFlightSteer = memo(function InFlightSteer({ [conversationId], ); - /** Fresh read at resubmit time: an interrupt armed while this escalation's - * reclaim was in flight (another bubble, a queued row, the composer chord) - * means resubmitting would break the one-interrupt invariant. */ - const hasUnresolvedInterrupt = useRecoilCallback( - ({ snapshot }) => - () => - snapshot - .getLoadable(store.pendingSteersByConvoId(conversationId)) - .getValue() - .some((item) => item.preempt === true && item.status !== 'failed'), + /** Relabels the chip in place once the server confirms the durable arm — + * same steerId, same position, only the `preempt` flag flips. */ + const markSteerPreempt = useRecoilCallback( + ({ set }) => + (steerId: string) => + set(store.pendingSteersByConvoId(conversationId), (prev) => + prev.map((item) => (item.steerId === steerId ? { ...item, preempt: true } : item)), + ), [conversationId], ); - const setEscalating = useSetAtom(escalatingSteerFamily(conversationId)); - - /** Latest controls for the escalation's async continuation: the `.then` - * closure otherwise holds the render's stale `steering`, blind to a run - * that paused (approval, answer mode) while the reclaim was in flight. */ - const steeringRef = useRef(steering); - useEffect(() => { - steeringRef.current = steering; - }); + const { mutateAsync: armSteer } = useArmSteerMutation(); /** * Takes the steer back off the server queue so its words can be re-homed. @@ -235,54 +226,40 @@ const InFlightSteer = memo(function InFlightSteer({ key: 'interrupt', label: localize('com_ui_interrupt_steer_now'), icon: