mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-21 15:45:22 +00:00
* feat: Scheduled Chats — agent-centric scheduled runs creating real conversations
Squash of the full review-hardened branch (PR 14540, supersedes 14373) onto
latest dev, preserving the exact verified tree. History prior to this commit
lived on the pre-squash branch; every invariant below survived 25 Codex review
rounds plus two external audit rounds (R26) with regression tests that fail
without their fixes.
Feature:
- Schedules CRUD + side-panel UI (cadence dialog, run cards, Run Now), roles/
permissions (SCHEDULES:USE), interface.schedules availability, per-user limits
and capacity slots, timezone-aware cadence with DST-conservative floors and
misfire grace.
- Engine: single-process claim/fire loop with leases, loopback POST dispatch
(signed schedule-fire JWT claims, per-occurrence idempotency key inside the
route's clientRequestId charset), overlap/balance/capacity/duplicate skip
policies with auto-disable streaks (too_many_failures, insufficient_balance),
reconciliation from retained terminal-job evidence, erasure sweep.
- Scheduled runs create real conversations through the resumable agents chat
path: HITL pauses surface on the card (requires_action), resumes re-apply the
fire boundary's admission policy (revision fence, enabled, global kill switch,
SCHEDULES:USE, availability) before continuing a billed generation.
Correctness invariants (the audit surface):
- Settlement discipline: every persistence-producing write happens-before a
run's terminal outcome write; Stop/complete/pause race through single-winner
terminal CAS claims (dev's TerminalJobClaim substrate) with retained,
completedAt-less evidence for scheduled fires plus an owner-intended outcome
stamp (scheduleOutcome) the reconciler prefers over re-derived success —
round-tripped through the Redis hash mapper.
- Swallowed generation failures (client error content parts) classify to
error/skipped_balance instead of success on both initial and resumed paths;
stale stamps are refreshed evidence-first when persistence plus the Mongo
outcome write both fail.
- Abort honesty: delivery judged by generation ownership and the CAS's actual
from-status; republication escalates to the transport's acknowledged variant;
the Stop route settles only genuinely paused runs.
- Account deletion: one-way barrier (deletionRequestedAt) with auth-cache
tombstone-before-stamp, boundary rechecks across all auth strategies, quiesce
of scheduled + interactive work with durable per-stream abort fences
(positive-evidence acknowledgement only), owner-side finalization markers for
post-terminal billed writes, deferred-deletion sweep (explicitly ensured
partial index) that completes cascades autonomously. Remote OpenAI-compatible/
Responses requests are documented as outside the quiesce and tracked in issue
14594.
- Store compatibility: finalization markers optional on the legacy IJobStore
contract with coherent degradation (registration fails -> synchronous title
fallback; count reads 0).
* fix: fence terminal response persistence from deletion; retain stale-pause evidence
Two blockers from the third external review round.
Terminal persistence visible to account deletion:
- The finalization marker was registered only when post-terminal TITLE work was
possible, but every persistence-owning terminal CAS opens the same window: the
claim drops the job out of the active set BEFORE the response save (and the
background user-message/convo saves), so a deletion quiesce landing there saw
neither an active job nor a marker and could cascade while the admitted request
could still recreate messages. Both controllers now register the marker before
every persistence-owning claim — the fresh-turn path and the HITL resume — and
release it only after their pending saves (and any post-terminal title) have
landed, on success and failure paths alike. The TTL bounds a crash.
- settleAbortFence no longer clears a complete/error fence while
`terminalPersistencePending` is set: terminal at the CAS is not settled while
the owner is still persisting. The job facade now surfaces the flag.
- The marker trio is REQUIRED by the runtime store contract (assertJobStoreV2
refuses a store without it at configure time, keeping the failure loud and
deterministic) while remaining optional on the legacy public IJobStore type for
source compatibility. The silent degrade path from the previous round is gone —
it was not deletion-safe.
Stale-pause recovery retains scheduled evidence:
- Three crash/timeout recovery paths — ApprovalLifecycle.failStalePausePersistence
and the InMemory/Redis stale-pause cleanups — unconditionally stamped
`completedAt`, putting a scheduled fire's failed-pause error terminal on the
short completed TTL. A Mongo outage longer than that TTL erased the evidence and
the reconciler recovered the run as `interrupted` instead of `error`. All three
now follow the controller-observed path from the previous round: scheduled jobs
omit `completedAt` (retained-evidence TTL) and stamp the error outcome.
The PR description now explicitly narrows the deletion guarantee for the remote
OpenAI-compatible/Responses paths (tracked in issue 14594).
* fix: deletion-fence marker protocol — generation-scoped, atomic, fail-closed, all terminal paths
One consolidated pass over the finalization-marker mechanism, per the fourth
external review round. The invariant it establishes: NO persistence-owning
terminal CAS runs without a durable, generation-qualified marker covering the
window it opens, and every consumer treats a pending terminal as unsettled.
- Generation-scoped markers. Entries were keyed (userId, streamId), so a
Stop-superseded generation finishing late could clear the marker its
replacement registered on the same conversation. Marker fields are now
qualified by the generation's createdAt; clears must present the same
identity, and an unqualified legacy clear cannot drop a qualified entry.
- Atomic Redis registration. HSET-then-EXPIRE loses the fresh marker when the
user's existing hash expires between the two commands (or the process dies
there); registration is now a single Lua script carrying both.
- Fail closed everywhere. Registration failure (after one retry) now REFUSES
the terminal CAS instead of proceeding uncovered: the completion claim throws
into the error path, the error path skips completeJob and leaves the job
ACTIVE — deletion-visible by itself, recovered by the stale-running reaper —
and abortJob returns a new retryable `fence_unavailable` failure the Stop
route answers with 503 and the deletion quiesce treats as an unacknowledged
stop (fence kept). The previous round's log-and-proceed is gone.
- Every terminal path enrolled. abortJob now owns its window (register before
the abort CAS, clear in its finally — the Stop route's checkpoint prune and
partial save run inside beforePublish, between CAS and publication); the
interactive and background generation-error paths register before their
completeJob; the resume controller's error finalization registers before its
completeJob. A lost or thrown claim releases the marker after pending saves
flush instead of holding the user's deletion behind the TTL.
- Every pending terminal unsettled. settleAbortFence defers on
terminalPersistencePending for ALL statuses — including `aborted`, whose
route-side persistence the previous guard missed.
Also: a direct Redis regression for scheduled stale-pause retention (the P2
test gap), and the PR description no longer claims to carry every commit.
* fix: lease-token lifecycle fences — same-generation isolation, admission fence, undelivered-Stop retention, legacy abort enrollment
Fifth external review round; four P1s, handled as the requested consolidated
lifecycle-fence pass.
- Lease tokens. Marker fields were (streamId, createdAt), shared by every
contender on the same generation — completion and Stop, or two racing Stops —
so a losing contender's cleanup erased the winner's still-live marker. Every
registrant now carries a unique lease token in the field and may only ever
clear its own lease; unqualified legacy clears cannot touch qualified entries.
- Admission fence. Authentication can pass before the deletion barrier goes up,
and the durable createJob is several async steps later — a deletion quiesce in
that window saw neither an active job nor a marker and could cascade before
the admitted request created its job. The controller now registers an
admission lease and THEN rereads the deletion barrier: the ordering guarantees
either this request observes the barrier (403, lease released, slot/claim
cleanup) or the quiesce observes the lease and defers. Held until createJob is
durable; released on every refusal and initialization-error path. Fail closed
when the lease itself cannot be registered (503 retryable).
- Undelivered-Stop retention. abortJob released its lease in a finally even when
delivery AND publication had provably failed — the job reads terminal
(invisible to active-set scans), a user Stop writes no durable abort fence,
and the remote owner keeps generating and will persist its abort-catch writes
whenever the signal finally lands. The lease is now retained in exactly that
case, and each resignal attempt heartbeats a fresh lease so the fence outlives
the TTL for as long as delivery is still being driven. The abort-winning
turn's own loser-side pending saves are additionally fenced in the controller
catch (best-effort — those writes are already in flight).
- Legacy abort enrollment. abortMiddleware (assistants abort route fallback for
non-assistants endpoints) awaited abortJob and then spent usage and saved the
stopped response AFTER the abort's lease was released. Both writes now run
inside `beforePublish`, between the abort CAS and publication, covered by the
same lease as every other abort.
Barrier tests, each verified to fail without its fix: same-generation lease
isolation (store), racing two-Stop loser cleanup (manager, stale-read forced
CAS race), undelivered-Stop lease retention, resignal heartbeat, admission
refusal with lease-before-reread ordering plus release-on-durable-create, and
legacy-abort persistence inside beforePublish.
* fix: heartbeat-backed owner-lifecycle leases close the settlement handoff races
Sixth external review round: the remaining P1 interleavings were one structural
problem — lease handoffs that were not atomic — resolved as the requested
consolidated lifecycle-lease pass.
- Quiesce reads leases BEFORE the active-job scan. The admission-lease -> durable
-job handoff is only atomic against a reader in the OPPOSITE order of the
writer: writers hold the lease strictly until the job is active-set visible,
so leases-first shows every interleaving either the lease or the job.
Jobs-first allowed a request to create its job after the scan and release its
lease before the count — hiding both, cascading, and letting the new
generation persist into a deleted account.
- The abort acknowledgement is fenced by the owner-lifecycle lease. Redis ACKed
the moment the owner's AbortController tripped; the stopping side released its
lease on that ACK while the owner's asynchronous abort-catch persistence was
still ahead. The transport now awaits a manager-installed pre-ACK hook that
registers a DETERMINISTIC owner lease (exactly one owner exists per
generation, and determinism is what lets the signal-time registrant and the
owner's catch-side release agree across processes) before the acknowledgement
is persisted or published; an owned same-replica abort bridges to the same
lease before tripping its local controller. Both generation-owner catches
(fresh turn, resume) release it once their writes land.
- A failed replacement handoff no longer orphans the predecessor. The atomic
replacement removes it from active storage, and an unconfirmed handoff
terminalizes the replacement too — leaving nothing a quiesce could discover
while the predecessor's provider may still be generating. Its owner lease is
now retained at the point the receipt fails delivery; the owner replica renews
it through the pre-ACK fence when the signal finally lands.
- Leases HEARTBEAT while held. The five-minute store TTL only bounds a crashed
holder; live persistence — a stalled save, a long deferred title — must never
outlive its own fence. holdUserFinalization registers and renews every minute
until released; the controllers' completion/error/admission leases all hold.
The undelivered-Stop retention moved to a deterministic `stop` lease that
every resignal attempt renews and the first successful one clears (no more
opaque leases accumulating to TTL), and a THROWN abort transition releases the
contender lease instead of leaking it.
- The user-document abort-fence mutations now invalidate the auth user-doc
cache, matching every other user-doc write.
Barrier tests, each verified to fail without its fix: quiesce lease-scan
ordering (plus the observed-lease defer), pre-ACK fence ordering at the
transport, owned-abort owner-lease bridging, replacement-handoff predecessor
retention, held-lease heartbeat past the TTL, and failed-then-successful
resignal reaping the retained stop lease.
* fix: one manager-owned owner-lease span across every abort delivery path
Seventh external review round; four lifecycle-fence gaps, closed by making the
owner-lifecycle lease a single manager-owned, heartbeat-held span.
- Fail-closed acknowledgements. The pre-ACK hook registered a one-shot lease and
the transport ACKed even when it failed; a same-replica owned abort likewise
swallowed registration failure. The hook now acquires a HELD owner lease
(heartbeat until the owner's catch releases it via releaseOwnerLease) and a
rejection SUPPRESSES the acknowledgement — the stopping side stays retryable
behind its retention lease, and every resignal re-drives the handler. The hook
also stopped gating on `job.createdAt === generationId`: during a replacement
handoff the store holds the replacement while the abort targets the
predecessor, and that gate silently skipped exactly the generation being
acknowledged (the store job is owner identity, never a generation gate). A
local owned abort acquires the same held lease before tripping its provider;
post-CAS the trip cannot be withheld, so acquisition failure downgrades
delivery and the retention handoff keeps the user fenced.
- Committed-but-lost-reply disambiguation. A thrown abort transition released
the contender lease as if nothing had happened, but a Lua CAS can commit and
lose its reply — an aborted job invisible to active-set scans whose provider
was never signalled, with no fence left. The throw path now re-reads the exact
generation: only a job still live under the caller's identity proves no
commit; committed or ambiguous outcomes hand the fence to the deterministic
stop lease (kept on the contender lease if even that fails) before rethrowing.
- Replacement handoff covered end to end. A LOCAL replacement abort acquires the
predecessor's held owner lease before the trip (failure reports the receipt
undelivered, engaging retention). Failed-handoff retention is no longer a
swallowed one-shot: it heartbeats with the durable acknowledgement proof as
its renewal predicate — acquisition failures keep retrying for as long as the
fence is needed, and the retainer stands down (without clearing the shared
field) once the remote owner ACKs and thereby holds its own lease.
- A LOCAL resignal delivery hands off to the owner lease BEFORE clearing the
retained stop lease, and keeps the stop lease when that handoff fails.
Barrier tests: hook rejection suppressing the ACK, commit-then-lost-reply
retention with its provably-uncommitted counterpart, local-resignal owner
handoff ordering, pre-ACK owner lease held past the store TTL until release
(and provably stopped after), and failed-handoff retention retrying on its
heartbeat — verified fail-before/pass-after by stashing the fixes.
* fix: finish scheduled chat lifecycle hardening
* fix: close scheduled chat review follow-ups
* fix: generation-fence abort recovery evidence
* test: wait for settled approval tool output
* test: preserve scheduled init reconciliation option
* refactor: rebuild scheduled chats on durable agent triggers
* test: reset MCP cache mock between cases
* test: isolate scheduler startup in server specs
* fix: harden scheduled run lifecycle
* fix: normalize schedule capacity conflicts
* test: type schedule collision fixture
* fix: re-fence scheduled resume and expiry
* fix: fence scheduled resume handoffs
* fix: release superseded manual schedule leases
* fix: release failed run-now claims
* fix: release superseded engine claims
* fix: repair schedule dialog interaction and rework its form
The agent picker was unusable: ControlCombobox portals its popover to the
body by default, which lands it outside the dialog's Radix focus trap. Clicks
passed through it, it could not be tabbed into, and the trap fighting Ariakit
for focus locked the page up on selection. The prop is documented for exactly
this case — pass `portal={false}` and give the dialog `overflow-visible`, as
ProjectButton already does. The time and day dropdowns defaulted the same way.
Alongside that:
- Extract the agent builder's instructions editor (special-variable menu plus
expand-to-fullscreen) into a controlled `VariableEditor` and use it for the
schedule prompt. Insertions now route through `onChange`, so react-hook-form
sees them — the schedule PATCH is built from `dirtyFields`, and a `setValue`
that skipped dirty tracking would have dropped an inserted variable silently.
- Replace the hand-rolled frequency buttons with the shared `Radio`. They marked
the selection with `bg-surface-hover` on an outline button whose hover is the
same token, so the selected option was indistinguishable from a hovered one;
`Radio` is also a real radiogroup rather than four `aria-pressed` toggles.
- Wrap the fields in a real `<form>` and associate the footer button by id, so
Enter submits. Group the frequency, day and time controls in fieldsets.
- Add placeholders for name and prompt, match the textarea fill to the other
fields, and label the hourly case as minutes past the hour.
- Widen the dialog to `md:max-w-3xl` and pair name with agent so the form fits
without scrolling on desktop.
- Move scheduled chats below skills and above prompts in the side nav.
The new dialog spec fails when the portal fix is reverted.
* test: cover scheduled and subagent deletion drains
* refactor: own the form-control appearance in the client primitives
Addresses the codex finding on ScheduleDialog: a feature-local `FIELD_CLASS`
restated the `Input` primitive's border, radius, height and background so it
could be pasted onto the schedule dropdowns, leaving those controls with no
connection to the primitive they were imitating.
Move that appearance into `packages/client/src/components/Field.ts` as the
single source `Input` and `Textarea` now compose, and give `Dropdown` and
`ControlCombobox` a `variant="field"` that applies it. The schedule dialog
passes the variant and carries no class strings of its own.
This also repairs a break the dev merge would otherwise have introduced: the
newer `Dropdown` splits `className` (wrapper) from `triggerClassName`, so the
old pasted classes would have landed on the wrapper and left the triggers
unstyled.
The semantic-token guard now watches the shared module and asserts each
primitive still composes it, which covers more than the two files it read
before.
* fix: keep an explicit schedules disable from becoming an opt-in
`use` is two things at once for a dual-purpose runtime interface field: a
permission bit, which DB overrides strip, and the runtime disable signal that
`getLimits` reads. Stripping it from `{ use: false, maxPerUser: 2 }` leaves an
object, and `getLimits` treats any object without `use: false` as enabled — so
an admin override written to stop scheduled billing for a role or user started
it instead.
Collapse an explicit disable to the boolean form before the strip, on both
paths that accept it: the `interface.schedules` field patch, which admitted the
object wholesale because bare runtime paths deliberately bypass the permission
gate, and the overrides merge, which reached the composite-field branch and
kept `maxPerUser`. Objects that only narrow limits are untouched, so a
principal can still be given a smaller cap.
Both regressions fail without the normalizer.
* fix(schedules): Wave A — null-balance CAS, atomic paused-card clear, clustered erasure sweep
Slice 1 (thread r3804518381): route the existing-null balance initialization
through a { user, tokenCredits: null } compare-and-set instead of a blind $set,
so a concurrent initializer/charge landing between the preflight read and the
write is never handed back its spent starting balance. On a CAS miss the
preflight re-reads the winner. The absent-record $setOnInsert path and the
credited-record refill-config sync are unchanged. Adds the initializeNullBalance
adapter (no upsert) and regression coverage for winner/miss/sync cases.
Slice 2 (thread r3804518388): updateScheduleById now drops a `requires_action`
lastRun projection atomically with the configRevision bump. Any pause present at
edit time was projected under the pre-edit revision and can never be replaced by
its own revision-fenced terminal outcome — a disabling edit would strand the
card on "Needs approval" forever. Implemented as classic-operator CAS branches
(DocumentDB rules out a conditional pipeline $unset), fenced on the card STILL
being the pause so a terminal outcome or newer occurrence that races in is
preserved. Terminal history survives untouched.
Slice 4 (thread r3803826204): expose initializeScheduleErasureSweep from the
schedule runtime facade and start it in every clustered (experimental) worker
after Mongo is up. It re-drives eraseScheduleIfDrained for soft-deleted rows so
a hidden prompt cannot outlive its drain when the delete/erase-on-settle
attempts miss. It arms nothing else and never infers owner death from a
process-local missing job (isTopologySafeToArm gates that).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): Wave B.1 — reversible account-deletion schedule suspension
Thread r3804518383. Account-deletion quiesce marked every schedule `deleting`,
disabled it, and cleared nextRunAt destructively. When a later cascade step (or
the drain itself) failed, the controller cancelled the user-deletion fence —
restoring the user — but the schedules stayed `deleting` and were erased by the
sweep, silently losing all of a live user's scheduled prompts.
Replace the destructive marking with a REVERSIBLE, token-fenced suspension:
- suspendUserSchedulesForDeletion(userId, token) snapshots each schedule's prior
enabled/nextRunAt under a per-attempt token, then fences firing (disable, clear
nextRunAt, rotate claimToken). It never sets `deleting`, so a suspended row is
not erasure-eligible. Snapshotting reads then bulkWrites (a classic update
cannot copy field values under DocumentDB), fenced per row so an already-
suspended/soft-deleted/edited row is left alone; idempotent per token.
- restoreUserSchedulesFromDeletion(userId, token) reverses it, re-enabling and
re-arming only rows still carrying the exact attempt token and not independently
deleted — so an owner-deleted or newer-attempt-suspended schedule is never
resurrected.
- deleteUserController generates the attempt token, passes it to quiesce, and on
any failure that cancels the user-deletion fence restores the suspended rows. A
successful deletion hard-deletes them (and their snapshots) via the existing
cascade and never restores.
Adds a `deletionSuspension` embedded field (excluded from the wire schedule),
data-method regression tests (suspend/restore/fence/idempotency/no-resurrect),
and controller tests (restore on drain-false and post-quiesce cascade failure,
no restore on success).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): Wave B.2 — wire the interactive Stop persistence protocol
Thread r3804255932. The durable Stop primitives (requestRunAbort 'stop',
getScheduleRunAbortState, markRunAbortPersisted) already existed but production
only used requestRunAbort(..., 'deletion'). An interactive Stop flipped the job
to `aborted` and then persisted its partial message + checkpoint inside
`beforePublish`, without ever stamping the schedule Stop or acknowledging it —
so reconciliation, the generation owner, or a concurrent schedule/account
deletion could terminalize the run and release its capacity (and erase data)
mid-write.
Wire the request -> persist -> acknowledge -> settle barrier through the
schedule runtime (the route never touches raw Mongo):
- Expose beginScheduledStop / acknowledgeScheduledStopPersistence on the service.
- The abort route stamps the Stop BEFORE signalling abortJob (a serialized
'in_progress' loser returns 409 STOP_IN_PROGRESS without a second abort),
acknowledges only after beforePublish persistence succeeds, and on a
persistence failure leaves the barrier unresolved so the run stays preserved
(client retries; stale-owner timeout is the bounded recovery). A failed abort
releases the stamp it placed so a replacement/retry is never blocked through
its predecessor.
- recordScheduleOutcome (the owner settlement path) now waits, bounded, for the
Stop acknowledgement before terminalizing; a resolved/non-stop/stale marker
proceeds immediately. The paused Stop settles only after its own ack.
Adds service-layer barrier tests and route-level ordering/persistence-failure/
in-progress tests; the data-layer serialization is already covered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): Wave C.1 — reconcile durable trigger delivery with the reservation
Threads r3803826192 (manual limiter) and r3804255924 (PII/moderation), plus the
independently-found long-Retry-After race. fireSchedule reserves a `started` run
and a global capacity slot BEFORE the durable trigger delivery reaches the chat
route, where an interactive limiter (manual Run Now), PII, or moderation can
reject it before any generation job exists — dead-lettering the delivery while
the run sat `started` until the 30-minute orphan sweep mislabeled it interrupted.
And a valid delivery deferred by Retry-After (up to 24h) could be orphan-settled
and have its capacity released, then fire anyway.
Translate durable delivery state into the schedule outcome:
- Store the deterministic trigger deliveryKey on the ScheduleRun reservation
(computed from the envelope BEFORE enqueue, so an ambiguous commit still has it).
- Add a getTriggerDelivery engine dep (wired to the merged trigger service's
getDelivery) that reads the durable delivery by key.
- Schedule reconciliation, for a jobless `started` run: staging/pending/leased →
admission is live, never orphan; dead → record `error` from the durable
lastError and release capacity promptly (no 30-minute wait), through the
ordinary outcome/auto-disable path; succeeded or no record → the existing
legacy orphan policy (interrupted only past the cutoff); a delivery lookup
failure defers rather than orphaning a possibly-live delivery. Limiter/PII/
moderation middleware writes no schedule state.
Adds reconcile state-mapping tests (dead/pending/leased/staging/succeeded/none/
lookup-failure) and a fire test that the reservation's deliveryKey equals the
enqueued delivery's idempotency key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(stream): Wave C.2 — durable retry/ack for terminal host lifecycle actions
Thread r3804518375. Approval expiry won the `requires_action → aborted` CAS and
then invoked the host hook best-effort: `runApprovalExpiredHandler` swallowed a
failure, and because later sweeps enumerate only `requires_action` jobs, the now-
aborted job was never offered again. In the clustered entrypoint (no schedule
reconciler) the ScheduleRun stayed `requires_action` and its retained job
persisted indefinitely.
Make the host lifecycle work durable rather than schedule-specific:
- Add a generic `terminalHostActionPending` marker, set ATOMICALLY in the same
terminal transition (ApprovalLifecycle.expireWithIdentity), only when a host
adapter is installed.
- Retain and index such jobs: both stores keep them out of terminal reaping and
expose getTerminalHostActionJobs(); Redis adds a set + extended (24h-bounded)
TTL, in-memory a bounded 24h retention so a permanently-failing hook cannot leak.
- The manager clears the marker only after the adapter acknowledges success,
fenced by generation identity (clearTerminalHostAction), so a replacement
generation can neither clear nor execute its predecessor's action.
- cleanup()/expireStaleApprovals() enumerates unacknowledged terminal host actions
across restarts and replicas and retries the idempotent hook; the relay only
re-invokes while the marker is unacknowledged, so a successful ack prevents
duplicate work. Store-won expiry marks it too, so a loser-replica relay still
crosses the hook.
- Terminal SSE notification continues regardless of host-hook outcome.
Covers in-memory behavior (retry after failure, restart/other-replica retry, ack
prevents duplicates, identity fence, terminal notification on failure, no marker
accumulation for non-scheduled jobs) and updates the Redis cluster-membership
contract test for the new index.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* style(schedules): satisfy import sorting in fire.ts and fire.spec.ts
CI "Static checks" failed on IMPORT_SORT for the two files Wave C.1 added imports
to (the AgentTriggerEnvelope type import and getAgentTriggerIdempotencyKey).
Applied scripts/sort-imports.mts to exactly those files — imports-only reordering,
no behavior change. Other files reported by a repo-wide check are pre-existing on
dev and deliberately left untouched so this PR is not widened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): repair the deletion CLI and order restore before the fence release
Addresses three findings from the fresh Codex review.
P1 — config/delete-user.js called methods.disableUserSchedulesForDeletion, which
Wave B removed in favor of suspendUserSchedulesForDeletion. The file is
@ts-nocheck and its spec mocked the removed name, so neither typecheck nor tests
caught it; the real CLI would throw a TypeError before deleting anything and then
only unwind the fence. The CLI now uses the tokenized protocol: it mints a
suspension token, suspends with it, and restores that exact attempt's rows in its
finally block when the deletion does not commit. Its spec mocks the real methods,
so the breakage can no longer hide.
P2 — both the HTTP controller and the CLI released the user-deletion fence BEFORE
restoring schedules. That fence is what refuses new schedule writes/claims, so the
gap let an owner PATCH edit a still-suspended row and have its enabled/next-run
state overwritten by the older snapshot, and let a second deletion attempt
re-suspend under a new token — making the first restore a no-op and stranding the
disabled snapshot permanently. Restore now runs first, while writes are still
fenced.
Hardening for the same defect class across a crash: suspendUserSchedulesForDeletion
now ADOPTS an existing suspension's snapshot when re-suspending a row abandoned by
an earlier attempt, instead of re-capturing the row's current (already-suspended)
state. Without this, an attempt that died before restoring would have its
successor snapshot "disabled, no next run" and permanently strand the schedule.
Tests: CLI restore-before-fence ordering and no-restore-on-success; the same
ordering assertion on both controller post-quiesce failure paths; a data-method
regression that a second attempt adopts the abandoned snapshot and restores the
original enabled/next-run state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): converge dead deliveries in topology-safe maintenance
Codex finding: the `dead` delivery mapping added in Wave C.1 lives only inside
startScheduleEngine's reconciler, but the clustered entrypoint arms no engine — it
runs erasure-only maintenance. A delivery queued before a restart into clustered
mode and then rejected before generation creation (interactive limiter, PII,
moderation) dead-letters while its ScheduleRun stays `started`, holding a global
capacity slot indefinitely for an ordinary non-deleting schedule.
Add a dead-delivery convergence pass to the erasure sweep, so every topology that
runs schedule maintenance settles it. The pass is POSITIVE-EVIDENCE-ONLY and is
therefore safe where absence-based reconciliation is not: a `dead` delivery is
durable shared state proving no generation owns the reservation. It settles only
when the job is confirmed absent or identity-mismatched (an identity-matched job
still owns the run), defers on an unknown job lookup, on an in-flight abort, and
on an in-flight resume hand-off, ignores legacy reservations with no deliveryKey,
and applies a short grace so an accepted delivery still creating its generation is
never settled mid-handoff. Auto-disable policy is deliberately left to the armed
engine; this path records the failure and frees the slot.
Deliberately does NOT touch api/server/experimental.js — the clustered entrypoint
already starts this sweep, so the convergence arrives through the existing
initializer and the shared-file footprint stays as-is.
Tests: settles a dead delivery as error under an explicitly UNSAFE topology,
leaves live deliveries alone, never settles under an identity-matched running
generation (delivery is not even consulted), defers an in-flight abort, and
ignores a reservation with no deliveryKey.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(stream): refresh host-action retention on each retry attempt
Codex finding: unacknowledged terminal host-action evidence was capped at the
24h pause TTL, so a host dependency (Mongo) unreachable for longer than that let
the Redis key — and its pending marker — expire with no generation-fenced
acknowledgement, stranding the ScheduleRun where no reconciler is armed.
Measure retention from the LAST retry rather than from the terminal transition:
enumerating a pending host action IS the retry attempt, so both stores refresh
its retention as they hand it to the hook (Redis re-EXPIREs the job key; the
in-memory store stamps terminalHostActionRefreshedAt and bounds from it). Evidence
therefore survives as long as some replica is still actively retrying, while a
deployment that stops sweeping entirely still lets it age out — so this does not
reintroduce the unbounded leak the cap existed to prevent.
Test: after a failed hook, a later cleanup pass keeps the marker pending and moves
its retention basis forward.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): defer settlement when the Stop barrier times out
Codex finding: waitForStopPersistence returned after its 5s poll budget even when
the Stop was still fresh and unacknowledged, and recordScheduleOutcome then went
straight on to terminalize the run — releasing its capacity, deletion, and erasure
barriers while beforePublish may still have been writing. Slow checkpoint cleanup
is indistinguishable from a dead route on that signal, so the timeout was being
treated as if the barrier had been satisfied.
The poll budget now means "undecided", not "clear". On timeout with a fresh,
unacknowledged Stop the barrier DEFERS: recordScheduleOutcome returns false
without recording, leaving the run active/preserved. Settlement then happens
either when the route acknowledges, or once the existing stale-owner cutoff
(ABORT_OWNER_PRESUMED_ALIVE_MS) authorizes a later attempt — which the loop
already treats as clear-to-settle. Callers with durable retry (the approval-expiry
host action, reconciliation) re-drive it, so a deferral converges rather than
stranding the run.
Test: a fresh Stop that never acknowledges within the budget reports not-settled
and records no outcome.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): require definite delivery failure, extract its message, converge deferred Stops
Third Codex round. Three findings in code from this closeout, plus one pre-existing
P1 that is a one-line operator-safety fix.
P1 — dead-delivery settlement demanded too little evidence. `dead` does not prove a
request was rejected: the trigger host marks response timeouts and invalid success
responses `certainty: 'ambiguous'`, and the engine dead-letters those once retries
are exhausted. The erasure sweep treated every dead letter as positive evidence, so
an ambiguous one sitting over a generation a peer had accepted could terminalize the
run and release its capacity mid-flight. It now settles only on a DEFINITE rejection,
unless job absence is deployment-authoritative (safe topology), where the
confirmed-absent job is itself the evidence.
P1 — `lastError` is an `AgentTriggerDeliveryFailure` object, not a string. A
duplicated local interface declared it `string` (against CLAUDE.md's no-duplicate-
types rule), so both the sweep and the engine reconciler passed the object into the
String-typed run/schedule `error` fields; Mongoose would reject the cast, the per-row
catch would swallow it, and the run would keep its global capacity slot. The dep type
now reuses the canonical `AgentTriggerDeliveryFailure` and both call sites pass
`.message`. Re-typing immediately surfaced a stale test that had asserted a string.
P1 (pre-existing) — the base-config global stop is honored in `getLimits` via
`isRuntimeDisabled` rather than a literal `=== false`. The stop has two shapes, and
deepMerge turns base `{ use: false }` plus a principal override of `true` into
`{ use: true }`, so the literal check reported the feature enabled and Run Now
dispatched straight through fireSchedule, bypassing the operator's emergency stop.
Now the same predicate the engine gate already uses.
P2 — a Stop whose settlement DEFERRED past the poll budget had no convergence path
where no reconciler is armed. `acknowledgeScheduledStopPersistence` now optionally
re-drives the terminal outcome once the barrier clears; `recordRunOutcome` is
match-guarded and idempotent, so an owner that already settled makes it a no-op. The
abort route passes it for a running generation; a paused job still settles explicitly.
Tests: ambiguous dead letters refused under unsafe topology but settled when absence
is authoritative, definite rejections settled either way, the failure message carried
through, and the abort route's re-drive present for running / absent for paused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): converge terminal runs in clustered workers, retry suspension restore
Fourth Codex round on 74f7af8. Both findings are in code from this closeout.
P2 - a clustered worker never settled a run whose generation finished but whose
outcome write failed. `recordScheduleOutcome` retries three times and then returns
false; the owner honors that by PRESERVING the terminal job as the only surviving
evidence, and the armed engine's reconciler replays exactly that. The clustered
entrypoint arms no engine, and its sweep only covered deleting schedules
(settleAbandonedRuns) and dead deliveries - an identity-matched job was skipped
outright. So an ordinary live schedule's run stayed `started`, held its GLOBAL
capacity slot, and kept a preserved job that carries no `completedAt` and is
therefore invisible to the store's finished-job sweep: both leaked until store
expiry.
The live-schedule pass now also converges from a retained terminal job, mirroring
the reconciler branch it stands in for: honor the owner's stamped outcome over the
generic status (so a balance refusal still walks its streak rather than resetting
it), clear the reserved conversationId when the generation never emitted its
created event, and delete the retained job only AFTER the outcome write is durable.
This stays positive-evidence-only and safe in every topology. Presence, not
absence, is the evidence: an identity-matched job is authoritative wherever it is
observed - a shared store shows the real generation, a process-local store can
only be showing this process's own - which is why it needs no
canInferOwnerDeathFromMissingJob fence, unlike the absence-based paths. The
in-flight abort/resume fences still defer, so an `aborted` job cannot settle a run
whose owner may still be persisting. Both cases now share one pass over one window
rather than two, and `retainedOutcome` moved to types.ts so the sweep reuses the
engine's mapping instead of duplicating it (and stays independent of the engine).
P2 - a failed restore stranded a live account's schedules. Cancelling an account
deletion restores the suspended rows while the deletion fence is still armed and
then releases that fence; nothing re-drives the restore afterwards, so one
transient write failure left the user with silently disabled, next-run-less
schedules. The restore is now retried at the single choke point both the HTTP
controller and the CLI share. Retrying is safe because each attempt re-reads only
the rows STILL carrying the token: a partially-applied unordered write converges
on exactly the stragglers, and a fully-applied one finds nothing.
The fence is still released when every attempt fails, deliberately: retaining it
would refuse the live account's schedule writes AND make beginAgentTriggerUserDeletion
report `in_progress` forever, blocking the retry that is the convergence path (a
later attempt adopts this snapshot, so its cancel restores these exact rows). Both
callers now log the user id and suspension token so the state stays recoverable by
hand if that never happens.
Tests: retained terminal job settled and its evidence released, stamped outcome
preferred over the generic status, stamped failure reason carried, reserved
conversationId cleared for a never-created conversation, identity-mismatched
terminal job ignored, abort-in-flight deferred; restore retried past a transient
write failure and converging on a partially applied restore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): converge unprojected pauses and release replayed bookkeeping jobs
Two of the four findings I flagged as pre-existing to my closeout commits. Both are
in code this PR introduces, so merging would have shipped them; both are the same
capacity/evidence-leak class the last four rounds have been closing. The remaining
two (slotless rows escaping the per-user cap, and the non-rotating `started`
reconciliation bucket) are genuinely latent and deliberately left alone.
An UNPROJECTED PAUSE held a global capacity slot forever. The pause projection is
what moves a run row off `started`; `recordScheduleOutcome` already retries it, but
the request controller discarded the result, so three failed attempts were dropped
silently. The armed engine's reconciler replays that state, but the clustered sweep
did not: a paused job is not terminal, so the retained-job path returned without
settling, and the dead-delivery path never inspects an identity-matched job at all.
The row stayed `started` with no cutoff that would ever clear it.
The call site now surfaces the failure, and the sweep converges it, mirroring the
reconciler's pause branch: project `requires_action` (which frees the slot) but do
NOT release the job's evidence — unlike a terminal job it is still live, awaiting an
approval. The resume hand-off fence still defers, so re-projecting cannot release a
slot a continuation just claimed.
The BOOKKEEPING REPLAY pass leaked its retained job. A run reaches that pass only
because its owner crashed before bookkeeping — which is also before it could release
the job it retained for exactly this recovery. The active-run pass clears its own;
this one never did, and a preserved job is deliberately kept WITHOUT `completedAt`
so the store's finished-job sweep cannot reap it early, so nothing else ever would.
It now clears after `finalizeBookkeeping` succeeds — identity-guarded, a no-op when
no job is retained, and skipped entirely when the replay itself failed, since the
retained job is the only surviving evidence in that case.
Tests: pause projected and the slot freed while the live job's evidence is kept,
paused job deferred during a resume hand-off, retained job released once replayed
bookkeeping is durable, and retained job kept when the replay fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): fence the clustered pause replay against a concurrent resume claim
Codex round 5, on code I added in f735341. The finding is correct and the exposure
is one I introduced.
The pause replay lives in a sweep that runs in EVERY clustered replica, so several
sweepers can observe the same unprojected pause. Its guards were all derived from an
in-memory row SNAPSHOT: hasResumeHandoffInFlight reads the snapshot's
resumeClaimedAt, and recordRunOutcome's pause branch matched any row currently in
`started`/`requires_action` with no fence of its own. The race: sweeper A projects
the pause and frees the slot; the owner's approval then claims a fresh one
(markRunResumeClaimed takes the row to `started` WITH resumeClaimedAt in one write);
sweeper B, still holding the pre-projection snapshot, passes its hand-off check and
replays — `$unset: { capacitySlot, resumeClaimedAt }` under a continuation that is
already running. The run reverts to `requires_action` while its generation proceeds
outside global capacity.
The engine's reconciler makes the same call and has the same snapshot-derived guard,
but v1 arms exactly one engine, so it has no concurrent racer; the sweep is the first
thing to run this transition in parallel. Left the engine alone rather than widening
the change: its `requires_action` re-affirmation is deliberate and single-writer.
Fixed where the race is, in the write itself. `recordRunOutcome` takes an optional
`requireNoResumeClaim`, which adds `resumeClaimedAt: { $exists: false }` to the pause
filter, and the sweep sets it. Because the stamp is written in the SAME update that
moves the row to `started`, its absence is atomic proof no resume owns the row. The
flag is deliberately NOT set by the generation owner: its own re-pause legitimately
clears the stamp as the hand-off's completion signal.
The fence cannot block the recovery it exists to enable: markRunResumeClaimed only
matches `requires_action`, so a genuinely stuck `started` row can carry no resume
claim — it is in fact blocking its own approval until this replay frees it.
Tests: a replay from a stale snapshot leaves a resume-claimed row's status, slot, and
claim stamp intact, while a stuck row with no claim still recovers; and the sweep is
asserted to send the fence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
* fix(schedules): fence stale resume claims by age, not existence
Codex round 6, on the fence I added in ee00c60. Correct again, and the defect is
the mirror image of the one it fixed.
The fence was an EXISTENCE check (`resumeClaimedAt: { $exists: false }`) while its
caller's guard is a FRESHNESS check (hasResumeHandoffInFlight, bounded by
RESUME_HANDOFF_STALE_MS). They agree while a claim is fresh and disagree once it is
abandoned: a worker that dies after markRunResumeClaimed takes the row to `started`
and stamps resumeClaimedAt — but before the continuation resumes or
releaseRunResumeClaim rolls it back — leaves the stamp set forever. Past the bound
the sweep correctly stops deferring and tries to recover the row, but the write
rejected it purely because the field still existed. The row stayed `started` holding
its global capacity slot, and its approval was unresumable for good, since
markRunResumeClaimed only matches `requires_action`. That is exactly the stuck state
this replay exists to clear, so the fence had reintroduced it for the crashed-resume
case.
`requireNoResumeClaim: boolean` becomes `resumeClaimStaleBefore: Date`, and the
filter matches a row with no claim OR a claim older than that cutoff. The sweep
passes the SAME bound its in-flight check uses, so the two can no longer disagree.
A genuinely racing claim is by construction fresh — it is created after the sweeper's
snapshot — so the race from round 5 stays closed.
Tests: a row whose resume claim was abandoned by a dead worker now recovers (status,
slot and stamp all cleared), alongside the existing two — a fresh claim still repels
a stale-snapshot replay, and an unclaimed stuck row still recovers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq
---------
Co-authored-by: Claude <noreply@anthropic.com>
1435 lines
56 KiB
JavaScript
1435 lines
56 KiB
JavaScript
const { randomUUID } = require('crypto');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { Constants, EModelEndpoint, ViolationTypes } = require('librechat-data-provider');
|
|
const {
|
|
GenerationJobManager,
|
|
isPendingActionStale,
|
|
mapToolApprovalResolutions,
|
|
resolveAskUserQuestionResume,
|
|
buildResolvedAskUserQuestion,
|
|
appendResolvedAskUserQuestion,
|
|
attachAskUserQuestionAnswers,
|
|
findAskUserQuestionContentIndex,
|
|
findUndecidedToolCalls,
|
|
findDisallowedDecisions,
|
|
findIncompleteDecisions,
|
|
computeAgentRequestFingerprint,
|
|
captureAgentCheckpointGeneration,
|
|
deleteAgentCheckpoint,
|
|
buildAbortedResponseMetadata,
|
|
sanitizeMessageForTransmit,
|
|
filterMalformedContentParts,
|
|
decrementPendingRequest,
|
|
checkAndIncrementPendingRequest,
|
|
isSteerPreemptSupported,
|
|
toPendingSteer,
|
|
} = require('@librechat/api');
|
|
const { disposeClient } = require('~/server/cleanup');
|
|
const {
|
|
getMCPRequestContext,
|
|
cleanupMCPRequestContextForReq,
|
|
} = require('~/server/services/MCPRequestContext');
|
|
const { saveMessage, getConvo, getMessages } = require('~/models');
|
|
const {
|
|
recordScheduleOutcome,
|
|
claimScheduleResume,
|
|
releaseScheduleResumeClaim,
|
|
finalizeScheduleResumeClaim,
|
|
releaseScheduleResumeFence,
|
|
isScheduleLive,
|
|
} = require('~/server/services/Schedules');
|
|
const {
|
|
GENERATION_PROTOCOL_HEADER,
|
|
negotiateNewGenerationProtocol,
|
|
negotiateExistingGenerationProtocol,
|
|
} = require('./protocol');
|
|
|
|
function sendGenerationJson(res, status, body, generationProtocolVersion) {
|
|
if (typeof res.set === 'function') {
|
|
res.set(GENERATION_PROTOCOL_HEADER, String(generationProtocolVersion));
|
|
} else if (typeof res.setHeader === 'function') {
|
|
res.setHeader(GENERATION_PROTOCOL_HEADER, String(generationProtocolVersion));
|
|
}
|
|
return res.status(status).json({ ...body, generationProtocolVersion });
|
|
}
|
|
|
|
/**
|
|
* How long a resume waits on best-effort steering bookkeeping before answering
|
|
* anyway. The approval is already consumed by that point, so a stalled Redis
|
|
* must not strand the client behind a chip label and an arm.
|
|
*/
|
|
const STEER_RESUME_SETUP_TIMEOUT_MS = 1000;
|
|
|
|
/**
|
|
* New jobs are physically isolated by an immutable saver namespace, so a
|
|
* terminal owner deletes the whole namespace and catches writes that landed
|
|
* after an earlier read. Pre-isolation jobs share the root namespace and must
|
|
* retain captured-id cleanup to avoid pruning a replacement.
|
|
*/
|
|
function deleteResumedGenerationCheckpoint({
|
|
conversationId,
|
|
checkpointerCfg,
|
|
job,
|
|
checkpointGeneration,
|
|
}) {
|
|
const checkpointNamespace =
|
|
typeof job?.metadata?.checkpointNamespace === 'string' ? job.metadata.checkpointNamespace : '';
|
|
if (checkpointNamespace !== '') {
|
|
return deleteAgentCheckpoint(conversationId, checkpointerCfg, undefined, {
|
|
checkpointNamespace,
|
|
});
|
|
}
|
|
return deleteAgentCheckpoint(conversationId, checkpointerCfg, checkpointGeneration);
|
|
}
|
|
|
|
/** Error-path checkpoint cleanup runs after the HTTP ACK. A storage failure
|
|
* must be observable, but must not escape the controller catch and bypass the
|
|
* remaining request-context/concurrency/client cleanup in `finally`. */
|
|
async function deleteFailedResumeCheckpoint(args, context) {
|
|
try {
|
|
await deleteResumedGenerationCheckpoint(args);
|
|
} catch (error) {
|
|
logger.error(`[ResumeAgentController] Failed to prune checkpoint after ${context}`, error);
|
|
}
|
|
}
|
|
|
|
/** De-duplicate a merged attachment list by a stable artifact identity. */
|
|
function mergeAttachments(existing, incoming) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const attachment of [...(existing ?? []), ...(incoming ?? [])]) {
|
|
if (!attachment) {
|
|
continue;
|
|
}
|
|
const key =
|
|
attachment.file_id ??
|
|
attachment.filepath ??
|
|
attachment.filename ??
|
|
JSON.stringify(attachment);
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
out.push(attachment);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Resolve the current segment's tool artifacts and merge them with any already
|
|
* persisted on the response row. A resumed turn can span multiple pause segments;
|
|
* each rebuilt client has its own `artifactPromises`, and the final finalize would
|
|
* otherwise OVERWRITE the row's attachments with only the last segment's. Reading
|
|
* the persisted row and merging keeps every segment's artifacts on the saved message.
|
|
*/
|
|
async function resolveAccumulatedAttachments({ client, conversationId, responseMessageId }) {
|
|
const promises = Array.isArray(client?.artifactPromises) ? client.artifactPromises : [];
|
|
const resolved = promises.length > 0 ? (await Promise.all(promises)).filter(Boolean) : [];
|
|
let existing = [];
|
|
if (responseMessageId) {
|
|
try {
|
|
const [row] = await getMessages(
|
|
{ conversationId, messageId: responseMessageId },
|
|
'attachments',
|
|
);
|
|
existing = Array.isArray(row?.attachments) ? row.attachments : [];
|
|
} catch (err) {
|
|
logger.warn(
|
|
'[ResumeAgentController] Failed to read prior attachments for merge',
|
|
err?.message ?? err,
|
|
);
|
|
}
|
|
}
|
|
return mergeAttachments(existing, resolved);
|
|
}
|
|
|
|
/** Resolve the segment's content for an unfinished save (mirrors finalize's source). */
|
|
async function resolveSegmentContent(client, streamId, expectedCreatedAt) {
|
|
const liveContent = Array.isArray(client?.contentParts) ? client.contentParts : [];
|
|
const rawContent =
|
|
liveContent.length > 0
|
|
? liveContent
|
|
: ((await GenerationJobManager.getResumeState(streamId, expectedCreatedAt))
|
|
?.aggregatedContent ?? []);
|
|
return filterMalformedContentParts(rawContent);
|
|
}
|
|
|
|
/**
|
|
* A resumed segment that streamed content / produced artifacts and then paused AGAIN
|
|
* must persist that progress before returning. The next resume rebuilds a fresh client
|
|
* (empty `contentParts`/`artifactPromises`), so without this an approval that later
|
|
* expires or is reaped would leave only the EARLIER pause's content on the saved row —
|
|
* the user loses everything streamed during this segment. Saved as a partial (`$set`,
|
|
* still `unfinished`) so a subsequent successful resume overwrites it on finalize.
|
|
*/
|
|
async function persistRePauseProgress({ req, client, job, streamId, conversationId }) {
|
|
const userId = req.user.id;
|
|
const meta = job.metadata ?? {};
|
|
const responseMessageId = meta.responseMessageId ?? client.responseMessageId;
|
|
if (!responseMessageId) {
|
|
return;
|
|
}
|
|
const content = await resolveSegmentContent(client, streamId, job.createdAt);
|
|
const attachments = await resolveAccumulatedAttachments({
|
|
client,
|
|
conversationId,
|
|
responseMessageId,
|
|
});
|
|
if (content.length === 0 && attachments.length === 0) {
|
|
return;
|
|
}
|
|
const savedResponseMessage = await saveMessage(
|
|
{
|
|
userId,
|
|
isTemporary: meta.isTemporary ?? req.body?.isTemporary,
|
|
interfaceConfig: req?.config?.interfaceConfig,
|
|
},
|
|
{
|
|
messageId: responseMessageId,
|
|
conversationId,
|
|
...(content.length > 0 && { content }),
|
|
...(attachments.length > 0 && { attachments }),
|
|
unfinished: true,
|
|
user: userId,
|
|
},
|
|
{ context: 'api/server/controllers/agents/resume.js - re-pause progress persist' },
|
|
);
|
|
if (!savedResponseMessage) {
|
|
throw new Error('Re-pause response progress could not be persisted');
|
|
}
|
|
}
|
|
|
|
/** Untenanted jobs (pre-multi-tenancy) remain accessible if the userId check passes. */
|
|
function hasTenantMismatch(job, user) {
|
|
return job.metadata?.tenantId != null && job.metadata.tenantId !== user.tenantId;
|
|
}
|
|
|
|
/**
|
|
* Build the SDK resume value from the wire decision payload, validating against the
|
|
* pending action. Returns `{ resumeValue }` on success or `{ error }` with an HTTP
|
|
* status for the route to surface.
|
|
*/
|
|
function resolveResumeValue(pendingAction, body) {
|
|
const payload = pendingAction.payload;
|
|
if (payload?.type === 'tool_approval') {
|
|
const resolutions = Array.isArray(body.decisions) ? body.decisions : [];
|
|
const undecided = findUndecidedToolCalls(payload, resolutions);
|
|
if (undecided.length > 0) {
|
|
return { status: 400, error: 'Every paused tool call must be decided', undecided };
|
|
}
|
|
// Enforce the policy's per-tool allowed_decisions — a crafted POST must not
|
|
// approve a tool the policy restricted to (e.g.) reject/respond.
|
|
const disallowed = findDisallowedDecisions(payload, resolutions);
|
|
if (disallowed.length > 0) {
|
|
return { status: 403, error: 'Decision not permitted for one or more tools', disallowed };
|
|
}
|
|
// `edit`/`respond` must carry their payload — otherwise toSdkDecision's defensive
|
|
// defaults ({} / '') would resume with an empty input/result the user didn't approve.
|
|
const incomplete = findIncompleteDecisions(resolutions);
|
|
if (incomplete.length > 0) {
|
|
return {
|
|
status: 400,
|
|
error: 'edit requires editedArguments and respond requires responseText',
|
|
incomplete,
|
|
};
|
|
}
|
|
return { resumeValue: mapToolApprovalResolutions(resolutions) };
|
|
}
|
|
if (payload?.type === 'ask_user_question') {
|
|
return resolveAskUserQuestionResume(payload, body);
|
|
}
|
|
return { status: 400, error: 'Unsupported pending action type' };
|
|
}
|
|
|
|
/**
|
|
* Finalize a resumed turn that ran to completion: persist the (now complete)
|
|
* response message, emit the terminal event over the existing SSE, complete the
|
|
* job, and prune the checkpoint. Mirrors the abort route's save shape but for a
|
|
* successful finish. Best-effort title generation for a first-turn pause.
|
|
*/
|
|
async function finalizeResumedTurn({
|
|
req,
|
|
client,
|
|
job,
|
|
streamId,
|
|
conversationId,
|
|
addTitle,
|
|
checkpointGeneration,
|
|
}) {
|
|
const userId = req.user.id;
|
|
const checkpointerCfg = req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
|
|
const meta = job.metadata ?? {};
|
|
const userMessage = meta.userMessage;
|
|
// The response hangs off the user message; the *user* message's own parent decides
|
|
// whether this is the first turn of the conversation (title eligibility).
|
|
const parentMessageId = userMessage?.messageId ?? Constants.NO_PARENT;
|
|
const isFirstTurn = (userMessage?.parentMessageId ?? Constants.NO_PARENT) === Constants.NO_PARENT;
|
|
const responseMessageId = meta.responseMessageId ?? `${userMessage?.messageId ?? 'resumed'}_`;
|
|
// Sourced from the paused job (persisted at creation), not the resume body — a
|
|
// temporary chat must stay temporary on resume so its messages aren't persisted.
|
|
const isTemporary = meta.isTemporary ?? req.body?.isTemporary;
|
|
|
|
// Read the raw job data BEFORE completeJob deletes it — its tracked token/context
|
|
// usage backs the response message's cost rollup (parity with normal completion).
|
|
const jobData = await GenerationJobManager.getJobStore().getJob(streamId);
|
|
|
|
// Job-replacement guard (mirrors the normal request path): jobs are keyed by streamId
|
|
// (== conversationId), so a new/concurrent request reusing this conversation overwrites
|
|
// the record with a fresh createdAt. If that happened while we were resuming, finalizing
|
|
// now would emit `done` to / complete / delete the NEWER turn's job. Skip all terminal
|
|
// side effects when the job we paused is no longer the live one; the caller's `finally`
|
|
// still disposes the client + releases the slot.
|
|
if (!jobData || jobData.createdAt !== job.createdAt) {
|
|
logger.warn(
|
|
`[ResumeAgentController] Skipping resumed finalization — job ${streamId} was replaced`,
|
|
);
|
|
return;
|
|
}
|
|
// Prefer the resumed run's live content: it's complete (seeded with the pre-pause
|
|
// content) and avoids a Redis re-read that can race appendChunk writes still in
|
|
// flight. Fall back to the aggregated store content only when the live array is empty.
|
|
const liveContent = Array.isArray(client?.contentParts) ? client.contentParts : [];
|
|
const rawContent =
|
|
liveContent.length > 0
|
|
? liveContent
|
|
: ((await GenerationJobManager.getResumeState(streamId, job.createdAt))?.aggregatedContent ??
|
|
[]);
|
|
// Parity with the normal agents path (AgentClient strips these before saving):
|
|
// drop empty/malformed tool_call parts so a resumed turn can't persist an invalid
|
|
// part that breaks reload/rendering.
|
|
const content = filterMalformedContentParts(rawContent);
|
|
|
|
/**
|
|
* A resumed segment can end on an empty preempt boundary just as a fresh
|
|
* one can — the boundary hook is re-registered by `buildSteerWiring` on
|
|
* resume. Persisting that as complete would contradict the honest contract
|
|
* the normal request path now keeps.
|
|
*/
|
|
const preemptStats = client?.run?.getPreemptStats?.();
|
|
const preemptIncomplete =
|
|
(preemptStats?.emptyBoundaries ?? 0) > 0 ||
|
|
client?.run?.getHaltReason?.() === 'preempt_incomplete';
|
|
|
|
const responseMessage = {
|
|
messageId: responseMessageId,
|
|
parentMessageId,
|
|
conversationId,
|
|
content,
|
|
sender: meta.sender ?? client?.sender ?? 'AI',
|
|
endpoint: meta.endpoint,
|
|
iconURL: meta.iconURL,
|
|
model: meta.model,
|
|
unfinished: preemptIncomplete,
|
|
error: false,
|
|
isCreatedByUser: false,
|
|
user: userId,
|
|
};
|
|
if (meta.agent_id ?? req.body?.agent_id) {
|
|
responseMessage.agent_id = meta.agent_id ?? req.body.agent_id;
|
|
}
|
|
// Persist tool artifacts (code files, images, UI resources) the resumed continuation
|
|
// produced — BaseClient.sendMessage awaits these before saving, but the lean resume
|
|
// path bypasses it, so do it here or they vanish on reload / for late subscribers.
|
|
// MERGE with any already on the row (earlier pause segments) rather than overwrite —
|
|
// the final segment's client only holds its own segment's artifacts.
|
|
const attachments = await resolveAccumulatedAttachments({
|
|
client,
|
|
conversationId,
|
|
responseMessageId,
|
|
});
|
|
if (attachments.length > 0) {
|
|
responseMessage.attachments = attachments;
|
|
}
|
|
|
|
// Response metadata: the resume client only sees POST-resume usage, while the job's
|
|
// tracked tokenUsage is cumulative across the pause. Take the cumulative usage (+
|
|
// summary marker) from the job, and contextUsage / thoughtSignatures from the client
|
|
// (which the abort-only helper drops). Cumulative usage wins so cost isn't underreported.
|
|
const clientMeta = client?.buildResponseMetadata?.() ?? null;
|
|
const cumulativeMeta = jobData ? buildAbortedResponseMetadata(jobData) : null;
|
|
const responseMetadata = {
|
|
...(clientMeta ?? {}),
|
|
...(cumulativeMeta?.usage ? { usage: cumulativeMeta.usage } : {}),
|
|
...(cumulativeMeta?.summaryUsedTokens != null
|
|
? { summaryUsedTokens: cumulativeMeta.summaryUsedTokens }
|
|
: {}),
|
|
};
|
|
if (Object.keys(responseMetadata).length > 0) {
|
|
responseMessage.metadata = responseMetadata;
|
|
}
|
|
// Carry the resumed run's context-window calibration (BaseClient.sendMessage persists
|
|
// this on the response). Without it, the NEXT turn can't seed its pruner from this
|
|
// run and falls back to uncalibrated token accounting.
|
|
if (client?.contextMeta != null) {
|
|
responseMessage.contextMeta = client.contextMeta;
|
|
}
|
|
|
|
// Win terminal ownership BEFORE the outcome-defining response write. Stop
|
|
// and completion both write the same Mongo row; a later liveness read cannot
|
|
// fence that external write, while this CAS gives exactly one side authority.
|
|
// The durable pending marker keeps status/subscribers on the readiness path
|
|
// until the winner has persisted and published its FINAL.
|
|
const terminalClaim = await GenerationJobManager.claimTerminalJob(
|
|
streamId,
|
|
'complete',
|
|
undefined,
|
|
job.createdAt,
|
|
{ persistencePending: true },
|
|
);
|
|
if (!terminalClaim) {
|
|
logger.warn(
|
|
`[ResumeAgentController] Skipping resumed FINAL — another terminal/pause transition won for ${streamId}`,
|
|
);
|
|
return;
|
|
}
|
|
let terminalPublicationStarted = false;
|
|
try {
|
|
const savedResponseMessage = await saveMessage(
|
|
{ userId, isTemporary, interfaceConfig: req?.config?.interfaceConfig },
|
|
responseMessage,
|
|
{ context: 'api/server/controllers/agents/resume.js - resumed response end' },
|
|
);
|
|
if (!savedResponseMessage) {
|
|
throw new Error('Resumed response could not be persisted before terminal publication');
|
|
}
|
|
|
|
const convo = await getConvo(userId, conversationId);
|
|
const conversation = { ...(convo ?? {}), conversationId };
|
|
|
|
// First-turn pause: the title was deferred when the turn paused. Generate it BEFORE
|
|
// completing the stream so the `title` event still reaches the live client (emitChunk
|
|
// no-ops once completeJob tears down the runtime) and the final event carries the real
|
|
// title instead of "New Chat". Best-effort — a failure must not fail the resumed turn.
|
|
if (
|
|
addTitle &&
|
|
isFirstTurn &&
|
|
!isTemporary &&
|
|
userMessage?.text &&
|
|
(!convo || !convo.title || convo.title === 'New Chat')
|
|
) {
|
|
try {
|
|
await addTitle(req, {
|
|
text: userMessage.text,
|
|
conversationId,
|
|
client,
|
|
onTitleGenerated: ({ conversationId: titleConvoId, title }) => {
|
|
conversation.title = title;
|
|
return GenerationJobManager.emitChunk(
|
|
streamId,
|
|
{
|
|
event: 'title',
|
|
data: { conversationId: titleConvoId, title },
|
|
},
|
|
{ expectedCreatedAt: job.createdAt },
|
|
);
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.error('[ResumeAgentController] Title generation failed after resume', err);
|
|
}
|
|
}
|
|
conversation.title = conversation.title || 'New Chat';
|
|
|
|
if (meta.scheduleId) {
|
|
await recordScheduleOutcome({
|
|
scheduleId: meta.scheduleId,
|
|
scheduledFor: meta.scheduledFor,
|
|
streamId,
|
|
jobCreatedAt: job.createdAt,
|
|
status: preemptIncomplete ? 'interrupted' : 'success',
|
|
conversationId,
|
|
...(preemptIncomplete && {
|
|
error: 'Scheduled run was interrupted before completion',
|
|
}),
|
|
});
|
|
}
|
|
|
|
const pendingSteers = terminalClaim.drainedSteers.map(toPendingSteer);
|
|
const finalEvent = {
|
|
final: true,
|
|
conversation,
|
|
title: conversation.title,
|
|
requestMessage: userMessage
|
|
? sanitizeMessageForTransmit({
|
|
...userMessage,
|
|
conversationId,
|
|
isCreatedByUser: true,
|
|
// job.metadata.userMessage is persisted without files; carry the restored
|
|
// uploads (seeded onto req.body.files before reconstruction) so the final SSE
|
|
// doesn't blank the user bubble's attachments — matching the normal path.
|
|
...(Array.isArray(req.body?.files) && req.body.files.length > 0
|
|
? { files: req.body.files }
|
|
: {}),
|
|
})
|
|
: null,
|
|
responseMessage: { ...responseMessage },
|
|
...(pendingSteers.length > 0 && { pendingSteers }),
|
|
};
|
|
|
|
terminalPublicationStarted = true;
|
|
await GenerationJobManager.publishTerminalClaim(terminalClaim, finalEvent);
|
|
} catch (error) {
|
|
if (!terminalPublicationStarted) {
|
|
try {
|
|
await GenerationJobManager.publishTerminalClaim(terminalClaim, null);
|
|
} catch (publishError) {
|
|
logger.error(
|
|
'[ResumeAgentController] Failed to publish terminal persistence reconciliation',
|
|
publishError,
|
|
);
|
|
}
|
|
}
|
|
throw error;
|
|
} finally {
|
|
try {
|
|
// Cleanup must run even if persistence/publication fails. The claim
|
|
// carries the exact generation/runtime identity, so this cannot tear
|
|
// down a later run.
|
|
await GenerationJobManager.finishTerminalJob(terminalClaim);
|
|
} finally {
|
|
await deleteResumedGenerationCheckpoint({
|
|
conversationId,
|
|
checkpointerCfg,
|
|
job,
|
|
checkpointGeneration,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resume a generation that paused for human-in-the-loop review.
|
|
*
|
|
* The original run lives in a detached background task that exits when the run
|
|
* pauses, so this REBUILDS the run from the durable checkpoint (same `thread_id`)
|
|
* and continues it with the user's decision. The continuation streams over the
|
|
* client's existing SSE (events flow through the same `streamId`).
|
|
*
|
|
* Flow: authorize → map decisions → atomically claim the resume (single-winner) →
|
|
* ACK → reconstruct the client → `resumeCompletion` → finalize (or re-pause).
|
|
*
|
|
* Shares chat.js's middleware (auth, agent access, `buildEndpointOption`) so the
|
|
* agent/endpoint are reconstructed from the request exactly like a normal turn.
|
|
*
|
|
* @param {express.Request} req
|
|
* @param {express.Response} res
|
|
* @param {express.NextFunction} next
|
|
* @param {Function} initializeClient
|
|
* @param {Function} addTitle
|
|
*/
|
|
const ResumeAgentController = async (req, res, next, initializeClient, addTitle) => {
|
|
const userId = req.user.id;
|
|
let generationProtocolVersion = negotiateNewGenerationProtocol(req, GenerationJobManager);
|
|
const { conversationId, actionId, generationCreatedAt } = req.body;
|
|
const streamId = conversationId;
|
|
|
|
if (!streamId || streamId === 'new') {
|
|
return sendGenerationJson(
|
|
res,
|
|
400,
|
|
{ error: 'conversationId is required to resume' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
if (
|
|
generationCreatedAt != null &&
|
|
(!Number.isSafeInteger(generationCreatedAt) || generationCreatedAt < 0)
|
|
) {
|
|
return sendGenerationJson(
|
|
res,
|
|
400,
|
|
{ code: 'INVALID_GENERATION_IDENTITY' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
|
|
const job = await GenerationJobManager.getJob(streamId);
|
|
if (!job) {
|
|
return sendGenerationJson(
|
|
res,
|
|
404,
|
|
{ error: 'No paused generation for this conversation' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
// Every persisted generation is owner-scoped. A missing/corrupt owner is
|
|
// not a legacy wildcard: fail closed before reading or resolving its action.
|
|
if (job.metadata?.userId !== userId) {
|
|
return sendGenerationJson(res, 403, { error: 'Unauthorized' }, generationProtocolVersion);
|
|
}
|
|
if (hasTenantMismatch(job, req.user)) {
|
|
return sendGenerationJson(res, 403, { error: 'Unauthorized' }, generationProtocolVersion);
|
|
}
|
|
generationProtocolVersion = negotiateExistingGenerationProtocol(req, job);
|
|
if (generationCreatedAt != null && job.createdAt !== generationCreatedAt) {
|
|
return sendGenerationJson(res, 409, { code: 'RUN_REPLACED' }, generationProtocolVersion);
|
|
}
|
|
|
|
// The resume must rebuild the SAME agent/endpoint that paused. Require an EXACT
|
|
// agent_id match when the paused job had one — a request that omits agent_id (or
|
|
// claims an ephemeral / non-agents endpoint) must not rebuild the claimed checkpoint
|
|
// on a different graph. The conversation's agent is stable, so a correct client always
|
|
// sends the right one.
|
|
const originalAgentId = job.metadata?.agent_id;
|
|
if (originalAgentId && req.body.agent_id !== originalAgentId) {
|
|
return sendGenerationJson(
|
|
res,
|
|
403,
|
|
{ error: 'Cannot resume with a different agent' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
// Require an EXACT endpoint match (like agent_id): a request that OMITS endpoint must
|
|
// not fall through — the shared chat middleware treats a missing/non-agents endpoint
|
|
// as the ephemeral agent, so omitting it could rebuild the claimed checkpoint on a
|
|
// different graph. A correct client always echoes the paused endpoint.
|
|
const originalEndpoint = job.metadata?.endpoint;
|
|
if (originalEndpoint && req.body.endpoint !== originalEndpoint) {
|
|
return sendGenerationJson(
|
|
res,
|
|
403,
|
|
{ error: 'Cannot resume on a different endpoint' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
|
|
const scheduleId = job.metadata?.scheduleId;
|
|
const scheduledFor = job.metadata?.scheduledFor;
|
|
if (
|
|
scheduleId &&
|
|
!(await isScheduleLive(scheduleId, job.metadata?.scheduleConfigRevision, {
|
|
automatic: job.metadata?.scheduleManual !== true,
|
|
policy: true,
|
|
}))
|
|
) {
|
|
let stopped = false;
|
|
try {
|
|
const abortResult = await GenerationJobManager.abortJob(streamId, {
|
|
expectedCreatedAt: job.createdAt,
|
|
awaitProviderDrain: true,
|
|
});
|
|
stopped = abortResult != null && abortResult.failureReason == null;
|
|
} catch (error) {
|
|
logger.warn('[ResumeAgentController] Failed to stop inactive scheduled run', error);
|
|
}
|
|
if (!stopped) {
|
|
res.set('Retry-After', '1');
|
|
return sendGenerationJson(
|
|
res,
|
|
503,
|
|
{
|
|
code: 'SCHEDULE_STOP_UNCONFIRMED',
|
|
error: 'The inactive scheduled run could not be confirmed stopped. Please retry.',
|
|
},
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
await recordScheduleOutcome({
|
|
scheduleId,
|
|
scheduledFor,
|
|
streamId,
|
|
jobCreatedAt: job.createdAt,
|
|
status: 'interrupted',
|
|
conversationId,
|
|
error: 'Schedule was disabled, changed, or deleted before approval',
|
|
});
|
|
const checkpointNamespace = job.metadata?.checkpointNamespace;
|
|
if (typeof checkpointNamespace === 'string' && checkpointNamespace !== '') {
|
|
await deleteAgentCheckpoint(
|
|
conversationId,
|
|
req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer,
|
|
undefined,
|
|
{ checkpointNamespace },
|
|
).catch((error) => {
|
|
logger.warn('[ResumeAgentController] Failed to prune inactive schedule checkpoint', error);
|
|
});
|
|
}
|
|
return sendGenerationJson(
|
|
res,
|
|
409,
|
|
{ code: 'SCHEDULE_NO_LONGER_ACTIVE', error: 'This schedule can no longer be resumed' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
|
|
const pendingAction = job.metadata?.pendingAction;
|
|
if (job.status !== 'requires_action') {
|
|
return sendGenerationJson(
|
|
res,
|
|
409,
|
|
{ error: 'No live pending action to resume' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
if (isPendingActionStale({ pendingAction })) {
|
|
// The action expired between the pending-action SSE and this submit. Drive the expiry
|
|
// NOW (expire CAS + terminal SSE) instead of waiting for the periodic sweeper —
|
|
// otherwise the job sits `requires_action` with a dead action and any attached SSE
|
|
// client never gets a terminal event, so the stream appears to hang even though the
|
|
// UI already reported the action as expired.
|
|
try {
|
|
await GenerationJobManager.expireApproval(streamId, pendingAction?.actionId, job.createdAt);
|
|
} catch (err) {
|
|
logger.warn(
|
|
'[ResumeAgentController] Failed to expire stale action on submit',
|
|
err?.message ?? err,
|
|
);
|
|
}
|
|
return sendGenerationJson(
|
|
res,
|
|
409,
|
|
{ error: 'No live pending action to resume' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
// Require the actionId the UI sends: without it, a stale/malformed client could
|
|
// resolve whatever action is currently pending (e.g. answer a different question).
|
|
if (!actionId) {
|
|
return sendGenerationJson(
|
|
res,
|
|
400,
|
|
{ error: 'actionId is required to resume' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
if (pendingAction.actionId !== actionId) {
|
|
return sendGenerationJson(
|
|
res,
|
|
409,
|
|
{ error: 'This decision targets a stale action' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
|
|
// Pin the graph identity: the resume must rebuild the SAME agent/graph + tool set the
|
|
// run paused on. The agent_id + endpoint guards above cover saved agents; the
|
|
// fingerprint additionally catches an ephemeral-agent config swap (its agent_id is
|
|
// undefined, so the id guard can't tell two ephemeral configs apart). Enforced only
|
|
// when the paused action carries a fingerprint (in-flight pauses from before this
|
|
// change won't), and recomputed from the resume body's graph-determining fields.
|
|
const pinnedFingerprint = pendingAction.requestFingerprint;
|
|
if (pinnedFingerprint && pinnedFingerprint !== computeAgentRequestFingerprint(req.body ?? {})) {
|
|
return sendGenerationJson(
|
|
res,
|
|
403,
|
|
{ error: 'Cannot resume with a different agent configuration' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
|
|
const mapped = resolveResumeValue(pendingAction, req.body);
|
|
if (mapped.error) {
|
|
return sendGenerationJson(
|
|
res,
|
|
mapped.status,
|
|
{
|
|
error: mapped.error,
|
|
...(mapped.undecided && { undecided: mapped.undecided }),
|
|
...(mapped.disallowed && { disallowed: mapped.disallowed }),
|
|
...(mapped.incomplete && { incomplete: mapped.incomplete }),
|
|
},
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
let resolvedAskContentIndex;
|
|
let resolvedAskContentMissing = false;
|
|
if (pendingAction.payload.type === 'ask_user_question' && !pendingAction.payload.tool_call_id) {
|
|
const answerSnapshot = await GenerationJobManager.getResumeState(streamId, job.createdAt);
|
|
if (answerSnapshot == null) {
|
|
return sendGenerationJson(res, 409, { code: 'RUN_REPLACED' }, generationProtocolVersion);
|
|
}
|
|
const askRequest = Array.isArray(pendingAction.payload.questions)
|
|
? { questions: pendingAction.payload.questions }
|
|
: pendingAction.payload.question;
|
|
const answerContent = answerSnapshot.aggregatedContent ?? [];
|
|
if (answerContent.length > 0) {
|
|
resolvedAskContentIndex = findAskUserQuestionContentIndex(
|
|
answerContent,
|
|
undefined,
|
|
askRequest,
|
|
);
|
|
if (resolvedAskContentIndex < 0) {
|
|
resolvedAskContentIndex = undefined;
|
|
resolvedAskContentMissing = true;
|
|
}
|
|
} else {
|
|
resolvedAskContentMissing = true;
|
|
}
|
|
}
|
|
const resolvedAskUserQuestion = buildResolvedAskUserQuestion(
|
|
pendingAction,
|
|
req.body,
|
|
resolvedAskContentIndex,
|
|
resolvedAskContentMissing,
|
|
);
|
|
const resolvedAskUserQuestions = appendResolvedAskUserQuestion(
|
|
job.metadata?.resolvedAskUserQuestions,
|
|
resolvedAskUserQuestion,
|
|
);
|
|
|
|
// A legacy job has no saver-level generation namespace, so snapshot its exact
|
|
// durable ids before the atomic resume claim. New jobs can skip this indexed
|
|
// read: terminal cleanup deletes their whole immutable namespace, including
|
|
// writes that land while the continuation is running.
|
|
//
|
|
// Start the indexed read alongside the independent concurrency check so the
|
|
// generation guard adds minimal time to the resume ACK path.
|
|
const checkpointerCfg = req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
|
|
const checkpointNamespace =
|
|
typeof job.metadata?.checkpointNamespace === 'string' ? job.metadata.checkpointNamespace : '';
|
|
const checkpointGenerationPromise =
|
|
checkpointNamespace !== ''
|
|
? Promise.resolve(undefined)
|
|
: captureAgentCheckpointGeneration(conversationId, checkpointerCfg).catch((err) => {
|
|
logger.warn('[ResumeAgentController] Failed to capture checkpoint generation', err);
|
|
return {
|
|
threadId: conversationId,
|
|
checkpointIds: [],
|
|
};
|
|
});
|
|
|
|
// Count the resume against the concurrency limit. The original turn released its slot
|
|
// when it paused, so resuming must re-acquire one — otherwise pausing several turns
|
|
// and resuming them at once would bypass LIMIT_CONCURRENT_MESSAGES.
|
|
const { allowed } = await checkAndIncrementPendingRequest(userId);
|
|
if (!allowed) {
|
|
return sendGenerationJson(
|
|
res,
|
|
429,
|
|
{ error: 'Too many concurrent requests' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
|
|
// Finish the legacy checkpoint snapshot before claiming scheduled capacity.
|
|
// It is independent of the approval claim, and holding a deployment-wide slot
|
|
// while an indexed saver read stalls would unnecessarily block other schedules
|
|
// and lengthen the Mongo-claim -> approval-CAS hand-off window below.
|
|
const checkpointGeneration = await checkpointGenerationPromise;
|
|
|
|
// A pause frees its scheduled-run capacity slot. Before consuming the approval,
|
|
// atomically promote the run row back to `started` and claim a fresh global slot.
|
|
// The database's partial unique indexes arbitrate both deployment capacity and a
|
|
// concurrent active occurrence of the same schedule.
|
|
let scheduleCapacitySlot;
|
|
let scheduleResumeClaimToken;
|
|
let scheduleResumeLeaseBy;
|
|
const scheduleResumeOptions = {
|
|
expectedConfigRevision: job.metadata?.scheduleConfigRevision,
|
|
automatic: job.metadata?.scheduleManual !== true,
|
|
};
|
|
if (scheduleId) {
|
|
let scheduleClaim;
|
|
try {
|
|
scheduleClaim = await claimScheduleResume(scheduleId, scheduledFor, scheduleResumeOptions);
|
|
} catch (err) {
|
|
await decrementPendingRequest(userId);
|
|
logger.error('[ResumeAgentController] Failed to claim scheduled resume capacity', err);
|
|
return sendGenerationJson(
|
|
res,
|
|
500,
|
|
{ error: 'Failed to reserve scheduled-run capacity' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
if ('conflict' in scheduleClaim) {
|
|
await decrementPendingRequest(userId);
|
|
if (scheduleClaim.conflict === 'capacity' || scheduleClaim.conflict === 'overlap') {
|
|
res.set('Retry-After', '1');
|
|
return sendGenerationJson(
|
|
res,
|
|
429,
|
|
{
|
|
code:
|
|
scheduleClaim.conflict === 'capacity'
|
|
? 'SCHEDULE_CAPACITY'
|
|
: 'SCHEDULE_OCCURRENCE_ACTIVE',
|
|
error:
|
|
scheduleClaim.conflict === 'capacity'
|
|
? 'Scheduled-run capacity is currently full. Please retry.'
|
|
: 'Another occurrence of this schedule is still running. Please retry.',
|
|
},
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
return sendGenerationJson(
|
|
res,
|
|
409,
|
|
{
|
|
code:
|
|
scheduleClaim.conflict === 'inactive'
|
|
? 'SCHEDULE_NO_LONGER_ACTIVE'
|
|
: 'SCHEDULE_RUN_NOT_PAUSED',
|
|
error: 'This scheduled run can no longer be resumed',
|
|
},
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
scheduleCapacitySlot = scheduleClaim.capacitySlot;
|
|
scheduleResumeClaimToken = scheduleClaim.claimToken;
|
|
scheduleResumeLeaseBy = scheduleClaim.leaseBy;
|
|
}
|
|
|
|
const releaseScheduleFence = async () => {
|
|
if (scheduleId == null || scheduleResumeLeaseBy == null) {
|
|
return;
|
|
}
|
|
try {
|
|
await releaseScheduleResumeFence(scheduleId, scheduleResumeLeaseBy);
|
|
} catch (releaseError) {
|
|
logger.warn('[ResumeAgentController] Failed to release scheduled resume fence', releaseError);
|
|
}
|
|
};
|
|
|
|
/** Release only when the exact generation demonstrably remains paused. If the
|
|
* approval CAS reply is ambiguous and the job cannot be read, retaining the slot
|
|
* until reconciliation is the safe direction: releasing it could exceed the cap
|
|
* while a committed continuation is already running. */
|
|
const rollbackUnconsumedScheduleClaim = async (currentJob) => {
|
|
if (
|
|
scheduleId == null ||
|
|
scheduleCapacitySlot == null ||
|
|
currentJob?.createdAt !== job.createdAt ||
|
|
currentJob?.status !== 'requires_action'
|
|
) {
|
|
return;
|
|
}
|
|
try {
|
|
await releaseScheduleResumeClaim(scheduleId, scheduledFor, scheduleCapacitySlot);
|
|
} catch (rollbackError) {
|
|
logger.warn(
|
|
'[ResumeAgentController] Failed to release unconsumed scheduled resume capacity',
|
|
rollbackError,
|
|
);
|
|
}
|
|
};
|
|
|
|
// Atomically claim the resume. The single winner drives the run; a racing second
|
|
// submit (double-click, two tabs) gets false and must not re-drive — that would
|
|
// re-execute tools and double-bill.
|
|
//
|
|
// The claim runs AFTER the slot increment above but BEFORE the run's own try/finally
|
|
// that releases it, so a store/Redis error here (unlike the clean `!claimed` branch)
|
|
// would leak the concurrency slot until the counter TTL expires — spuriously 429'ing
|
|
// the user when they retry the still-paused approval. Release the slot on that path too.
|
|
let claimed;
|
|
const providerExecutionId = randomUUID();
|
|
try {
|
|
/** The CAS that reopens steering must also publish THIS owner's seal
|
|
* capability. A separate write after status=`running` leaves a window in
|
|
* which steer/arm requests read the previous replica's capability. */
|
|
claimed = await GenerationJobManager.approvals.resolve(
|
|
streamId,
|
|
pendingAction.actionId,
|
|
{
|
|
preemptCapable: isSteerPreemptSupported(),
|
|
providerExecutionId,
|
|
providerDrained: true,
|
|
...(resolvedAskUserQuestion && { resolvedAskUserQuestions }),
|
|
},
|
|
job.createdAt,
|
|
);
|
|
} catch (err) {
|
|
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
|
|
await rollbackUnconsumedScheduleClaim(currentJob);
|
|
await releaseScheduleFence();
|
|
await decrementPendingRequest(userId);
|
|
logger.error('[ResumeAgentController] Failed to claim resume', err);
|
|
return sendGenerationJson(res, 500, { error: 'Failed to resume' }, generationProtocolVersion);
|
|
}
|
|
if (!claimed) {
|
|
await decrementPendingRequest(userId);
|
|
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
|
|
await rollbackUnconsumedScheduleClaim(currentJob);
|
|
await releaseScheduleFence();
|
|
if (currentJob != null && currentJob.createdAt !== job.createdAt) {
|
|
return sendGenerationJson(res, 409, { code: 'RUN_REPLACED' }, generationProtocolVersion);
|
|
}
|
|
return sendGenerationJson(
|
|
res,
|
|
409,
|
|
{ error: 'This action was already resolved or has expired' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
|
|
// Linearize the consumed approval against the schedule's live config. The schedule
|
|
// document fence was acquired only after all async policy reads, and this atomic
|
|
// consume checks its token/revision/enabled state immediately after the approval CAS.
|
|
// An edit/disable that won first makes this fail; one that lands afterward is ordered
|
|
// after the continuation has started. Never begin provider execution on a stale claim.
|
|
if (scheduleId) {
|
|
let scheduleClaimCurrent = false;
|
|
try {
|
|
scheduleClaimCurrent = await finalizeScheduleResumeClaim(
|
|
scheduleId,
|
|
scheduleResumeClaimToken,
|
|
scheduleResumeLeaseBy,
|
|
scheduleResumeOptions,
|
|
);
|
|
} catch (error) {
|
|
logger.error('[ResumeAgentController] Failed to finalize scheduled resume fence', error);
|
|
await releaseScheduleFence();
|
|
}
|
|
if (!scheduleClaimCurrent) {
|
|
await decrementPendingRequest(userId);
|
|
let stopped = false;
|
|
try {
|
|
const abortResult = await GenerationJobManager.abortJob(streamId, {
|
|
expectedCreatedAt: job.createdAt,
|
|
awaitProviderDrain: true,
|
|
});
|
|
stopped = abortResult != null && abortResult.failureReason == null;
|
|
} catch (error) {
|
|
logger.warn('[ResumeAgentController] Failed to stop stale scheduled resume', error);
|
|
}
|
|
if (!stopped) {
|
|
res.set('Retry-After', '1');
|
|
return sendGenerationJson(
|
|
res,
|
|
503,
|
|
{
|
|
code: 'SCHEDULE_STOP_UNCONFIRMED',
|
|
error: 'The stale scheduled resume could not be confirmed stopped.',
|
|
},
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
await recordScheduleOutcome({
|
|
scheduleId,
|
|
scheduledFor,
|
|
streamId,
|
|
jobCreatedAt: job.createdAt,
|
|
status: 'interrupted',
|
|
conversationId,
|
|
error: 'Schedule was disabled, changed, or deleted before approval',
|
|
});
|
|
if (checkpointNamespace !== '') {
|
|
await deleteAgentCheckpoint(conversationId, checkpointerCfg, undefined, {
|
|
checkpointNamespace,
|
|
}).catch((error) => {
|
|
logger.warn('[ResumeAgentController] Failed to prune stale schedule checkpoint', error);
|
|
});
|
|
}
|
|
return sendGenerationJson(
|
|
res,
|
|
409,
|
|
{ code: 'SCHEDULE_NO_LONGER_ACTIVE', error: 'This schedule can no longer be resumed' },
|
|
generationProtocolVersion,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An interrupt steer enqueued just before the pause survives durably with
|
|
* its `preempt` flag, but the ARM lived only in the previous owner's
|
|
* runtime. Rebuild it from the queue so the resumed segment honours an
|
|
* interrupt the user already had acknowledged.
|
|
*/
|
|
const preemptRearm = GenerationJobManager.rearmQueuedPreempts(streamId, job.createdAt).catch(
|
|
(error) => {
|
|
logger.error('[ResumeAgentController] Failed to re-arm queued preempts', error);
|
|
},
|
|
);
|
|
|
|
/**
|
|
* BOUNDED, and the bound is the point. `.catch` only fires on rejection,
|
|
* but ioredis queues commands while a connection is down instead of
|
|
* rejecting, so either of these can simply never settle. That would block
|
|
* here — after `approvals.resolve` has already consumed the action and
|
|
* flipped the job to `running`, and before both `res.json` and the resume
|
|
* lifecycle's own try/finally. The client times out, its retry gets a 409
|
|
* because the action is spent, and neither the continuation nor the
|
|
* failed-resume cleanup ever runs.
|
|
*
|
|
* Re-arming is steering bookkeeping that the next tool boundary would
|
|
* honour anyway, so it finishes in the background rather than holding a
|
|
* resume the user is waiting on. Capability is not in this best-effort path:
|
|
* it was committed atomically by the resume claim above.
|
|
*/
|
|
let steeringSetupTimer;
|
|
await Promise.race([
|
|
preemptRearm,
|
|
new Promise((resolve) => {
|
|
steeringSetupTimer = setTimeout(() => {
|
|
logger.warn(
|
|
`[ResumeAgentController] Steering setup for ${streamId} still pending after ` +
|
|
`${STEER_RESUME_SETUP_TIMEOUT_MS}ms; continuing the resume without it`,
|
|
);
|
|
resolve();
|
|
}, STEER_RESUME_SETUP_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
clearTimeout(steeringSetupTimer);
|
|
|
|
// Seed the run-scoped MCP request-context store BEFORE the ACK: once `res.json`
|
|
// finishes the response, a later `getMCPRequestContext(req, res)` (from tool loading)
|
|
// sees `res` as ended and returns undefined, leaving the resumed run without its MCP
|
|
// connection store — approved MCP / OAuth-overlay tools would then run without their
|
|
// request-scoped connections. Pre-seeding with a null `res` + `cleanupOnResponse:false`
|
|
// mirrors the normal stream path (request.js); torn down in the `finally` below.
|
|
req._resumableStreamId = streamId;
|
|
getMCPRequestContext(req, undefined, { cleanupOnResponse: false });
|
|
|
|
// ACK immediately; the continuation streams over the client's existing SSE.
|
|
sendGenerationJson(
|
|
res,
|
|
200,
|
|
{ streamId, conversationId, status: 'resuming' },
|
|
generationProtocolVersion,
|
|
);
|
|
|
|
// Seed the original thread parent BEFORE initializeClient: initializeAgent scopes
|
|
// thread files / code artifacts off `req.body.parentMessageId`, and the resume body
|
|
// doesn't carry it. This is the user message's parent (the thread position);
|
|
// `client.parentMessageId` below is a different value — the response's parent, i.e.
|
|
// the user message id.
|
|
req.body.parentMessageId = job.metadata.userMessage?.parentMessageId ?? Constants.NO_PARENT;
|
|
|
|
// Rebuild the same persistence/retention mode as the paused turn. The resume body is
|
|
// not authoritative here: image/code tools inspect `req.body.isTemporary` during
|
|
// initializeClient, and a missing or crafted value must not make a temporary chat's
|
|
// artifacts durable (or make a durable chat's artifacts ephemeral).
|
|
req.body.isTemporary = job.metadata.isTemporary === true;
|
|
|
|
// Restore the paused user message's OWN uploaded files. initializeAgent rebuilds
|
|
// code/file sessions by walking the conversation from `parentMessageId`, but
|
|
// execute-code files are excluded from that lookup, so files uploaded on the paused
|
|
// turn would be dropped — an approved code/read-file tool would resume without them.
|
|
//
|
|
// SECURITY: ALWAYS source files from the paused job, never from the `/resume` body.
|
|
// `files` is not pinned by the resume fingerprint or replayed via resumeContext, so
|
|
// honoring a client-supplied `files` array would let a crafted/buggy client resume an
|
|
// approved code/read-file tool against a DIFFERENT file set than the one the user
|
|
// approved. A resume reconstructs the SAME paused turn, so there is no legitimate
|
|
// reason for the client to supply its own files. Prefer the files persisted on the JOB
|
|
// at onStart (race-free), fall back to the DB row for older jobs, and CLEAR otherwise
|
|
// so a client-supplied set can never leak through.
|
|
const metaFiles = job.metadata.userMessage?.files;
|
|
if (Array.isArray(metaFiles) && metaFiles.length > 0) {
|
|
req.body.files = metaFiles;
|
|
} else {
|
|
let restoredFiles = false;
|
|
const pausedUserMessageId = job.metadata.userMessage?.messageId;
|
|
if (pausedUserMessageId) {
|
|
try {
|
|
const [row] = await getMessages(
|
|
{ conversationId, messageId: pausedUserMessageId },
|
|
'files',
|
|
);
|
|
if (Array.isArray(row?.files) && row.files.length > 0) {
|
|
req.body.files = row.files;
|
|
restoredFiles = true;
|
|
}
|
|
} catch (err) {
|
|
logger.warn(
|
|
'[ResumeAgentController] Failed to restore paused user message files',
|
|
err?.message ?? err,
|
|
);
|
|
}
|
|
}
|
|
if (!restoredFiles) {
|
|
// No paused files (or the lookup failed): drop any client-supplied files so a
|
|
// crafted resume body can't inject a file set the paused turn never had.
|
|
req.body.files = [];
|
|
}
|
|
}
|
|
|
|
// Restore the conversation's createdAt so temporal prompt vars ({{current_datetime}},
|
|
// {{iso_datetime}}, ...) resolve against the SAME anchor the paused graph used rather
|
|
// than the resume wall-clock. initializeAgent reads `req.conversationCreatedAt`; the
|
|
// normal path sets it from the convo timestamp (resolveConversationCreatedAt), so mirror
|
|
// that here. (The original `timezone` is replayed onto req.body via RESUME_CONTEXT_KEYS.)
|
|
try {
|
|
const resumedConvo = await getConvo(userId, conversationId);
|
|
const createdAt = resumedConvo?.createdAt ? new Date(resumedConvo.createdAt) : null;
|
|
if (createdAt && !Number.isNaN(createdAt.getTime())) {
|
|
req.conversationCreatedAt = createdAt.toISOString();
|
|
}
|
|
} catch (err) {
|
|
logger.warn(
|
|
'[ResumeAgentController] Failed to restore conversation timestamp anchor',
|
|
err?.message ?? err,
|
|
);
|
|
}
|
|
|
|
let client = null;
|
|
/** Re-pause progress failures use the action/epoch-scoped terminal CAS. The
|
|
* generic resume catch must not subsequently call completeJob, because the
|
|
* failed pause may have lost ownership to a newer action or generation. */
|
|
let pausePersistenceFailed = false;
|
|
let pausePersistenceFailureFinalized = false;
|
|
try {
|
|
if (
|
|
!(await GenerationJobManager.beginProviderExecution(
|
|
streamId,
|
|
job.createdAt,
|
|
providerExecutionId,
|
|
))
|
|
) {
|
|
throw Object.assign(new Error('Generation stopped before provider resume'), {
|
|
code: 'RUN_REPLACED',
|
|
});
|
|
}
|
|
const result = await initializeClient({
|
|
req,
|
|
res,
|
|
endpointOption: req.body.endpointOption,
|
|
signal: job.abortController.signal,
|
|
jobCreatedAt: job.createdAt,
|
|
checkpointNamespace,
|
|
});
|
|
client = result.client;
|
|
|
|
// Bind the rebuilt client to the in-flight turn's identity (no new user message).
|
|
client.conversationId = streamId;
|
|
// The resume operates on the SAME job (it moved it running again), so its identity is
|
|
// the paused job's createdAt — used by the re-pause CAS pre-check + checkpoint prune to
|
|
// avoid acting on a job a newer request has since replaced.
|
|
client.jobCreatedAt = job.createdAt;
|
|
client.checkpointNamespace = checkpointNamespace;
|
|
client.responseMessageId = job.metadata.responseMessageId;
|
|
client.parentMessageId = job.metadata.userMessage?.messageId ?? Constants.NO_PARENT;
|
|
// Read the pre-pause content BEFORE swapping the store's content reference: the
|
|
// in-memory store's setContentParts REPLACES the stored array, so reading the
|
|
// resume state afterward would see the new (empty) client array and lose the seed.
|
|
const resumeState = await GenerationJobManager.getResumeState(streamId, job.createdAt);
|
|
let seedContent = resumeState?.aggregatedContent ?? [];
|
|
// Stamp retained answers onto their paused ask_user_question tool-call parts
|
|
// (args = the pendingAction's authoritative question, output = the user's answer):
|
|
// the streamed arg chunks carry no tool name so the aggregator dropped them, and
|
|
// no completion event ever fires for this tool — without this the saved part is
|
|
// an empty "cancelled-looking" tool call. See attachAskUserQuestionAnswers.
|
|
if (resolvedAskUserQuestions) {
|
|
seedContent = attachAskUserQuestionAnswers(seedContent, resolvedAskUserQuestions);
|
|
}
|
|
if (client.contentParts) {
|
|
GenerationJobManager.setContentParts(streamId, client.contentParts, job.createdAt);
|
|
}
|
|
|
|
await client.resumeCompletion({
|
|
resumeValue: mapped.resumeValue,
|
|
seedContent,
|
|
runSteps: resumeState?.runSteps ?? [],
|
|
abortController: job.abortController,
|
|
// Carry the user's MCP auth so approved MCP tools run with their credentials.
|
|
userMCPAuthMap: result.userMCPAuthMap,
|
|
// Replay deferred tools discovered before the pause (captured at pause). The rebuilt
|
|
// graph passes `messages: []`, so without these the model would lose their schemas.
|
|
discoveredToolNames: job.metadata?.discoveredTools,
|
|
activityPhaseSnapshot: job.metadata?.activityPhaseSnapshot,
|
|
});
|
|
|
|
// The model may pause AGAIN (another tool, or a follow-up question). The pending
|
|
// action is already persisted + emitted; leave the job `requires_action`.
|
|
if (client.pendingApproval) {
|
|
logger.debug(`[ResumeAgentController] Re-paused for approval: ${streamId}`);
|
|
const pauseActionId = client.pendingApproval.actionId;
|
|
const pauseCreatedAt = client.jobCreatedAt ?? job.createdAt;
|
|
const ownsPausePersistence = await GenerationJobManager.approvals.ownsPausePersistence(
|
|
streamId,
|
|
pauseActionId,
|
|
pauseCreatedAt,
|
|
);
|
|
if (ownsPausePersistence) {
|
|
try {
|
|
// Persist this segment's content + artifacts before the fresh client (next
|
|
// resume) drops them, so an expiring re-pause doesn't lose them; finalize later
|
|
// overwrites content and merges attachments onto the saved message. A failed
|
|
// required write must reject into the error-finalization path rather than expose
|
|
// the next action while its preceding segment is absent from durable history.
|
|
await persistRePauseProgress({ req, client, job, streamId, conversationId });
|
|
} catch (pausePersistenceError) {
|
|
pausePersistenceFailed = true;
|
|
try {
|
|
pausePersistenceFailureFinalized =
|
|
(await GenerationJobManager.failPausePersistence(
|
|
streamId,
|
|
pauseActionId,
|
|
pausePersistenceError?.message ?? 'Re-pause persistence failed',
|
|
pauseCreatedAt,
|
|
)) === true;
|
|
if (!pausePersistenceFailureFinalized) {
|
|
logger.warn(
|
|
`[ResumeAgentController] Skipping stale re-pause persistence failure — ${streamId} no longer owns its barrier`,
|
|
);
|
|
}
|
|
} catch (failError) {
|
|
logger.error(
|
|
`[ResumeAgentController] Failed to terminalize re-pause persistence error for ${streamId}`,
|
|
failError,
|
|
);
|
|
}
|
|
throw pausePersistenceError;
|
|
}
|
|
const released = await GenerationJobManager.approvals.finishPausePersistence(
|
|
streamId,
|
|
pauseActionId,
|
|
pauseCreatedAt,
|
|
);
|
|
if (!released) {
|
|
logger.warn(
|
|
`[ResumeAgentController] Re-pause persistence barrier changed before release: ${streamId}`,
|
|
);
|
|
}
|
|
if (scheduleId) {
|
|
await recordScheduleOutcome({
|
|
scheduleId,
|
|
scheduledFor,
|
|
streamId,
|
|
jobCreatedAt: job.createdAt,
|
|
status: 'requires_action',
|
|
conversationId,
|
|
});
|
|
}
|
|
} else {
|
|
logger.debug(
|
|
`[ResumeAgentController] Skipping stale re-pause persistence — ${streamId} no longer owns its barrier`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If the user aborted mid-resume, the abort route already emitted the terminal
|
|
// event and finalized the job — don't double-save / double-finalize here. This
|
|
// continuation is nevertheless the scheduled-run owner, so it must settle the
|
|
// run row after observing its own abort; the generic Stop route deliberately
|
|
// delegates a running generation's settlement to that generation owner.
|
|
if (job.abortController.signal.aborted) {
|
|
logger.debug(
|
|
`[ResumeAgentController] Aborted during resume; abort route finalizes: ${streamId}`,
|
|
);
|
|
if (scheduleId) {
|
|
await recordScheduleOutcome({
|
|
scheduleId,
|
|
scheduledFor,
|
|
streamId,
|
|
jobCreatedAt: job.createdAt,
|
|
status: 'interrupted',
|
|
conversationId,
|
|
error: 'Scheduled run was stopped',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
await finalizeResumedTurn({
|
|
req,
|
|
client,
|
|
job,
|
|
streamId,
|
|
conversationId,
|
|
addTitle,
|
|
checkpointGeneration,
|
|
});
|
|
} catch (err) {
|
|
logger.error('[ResumeAgentController] Resume failed', err);
|
|
if (pausePersistenceFailed) {
|
|
// failPausePersistence already performed the exact requires_action ->
|
|
// error transition. Only its CAS winner owns this generation's checkpoint
|
|
// cleanup; a stale/mismatched failure must leave the live scope intact.
|
|
if (pausePersistenceFailureFinalized) {
|
|
await deleteFailedResumeCheckpoint(
|
|
{
|
|
conversationId,
|
|
checkpointerCfg,
|
|
job,
|
|
checkpointGeneration,
|
|
},
|
|
're-pause persistence failure',
|
|
);
|
|
}
|
|
if (scheduleId && pausePersistenceFailureFinalized) {
|
|
await recordScheduleOutcome({
|
|
scheduleId,
|
|
scheduledFor,
|
|
streamId,
|
|
jobCreatedAt: job.createdAt,
|
|
status: 'error',
|
|
conversationId,
|
|
error: err?.message ?? 'Re-pause persistence failed',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
// Job-replacement guard (mirrors finalizeResumedTurn's success-path guard): if a
|
|
// newer request reused this conversationId while the resume was failing, do NOT emit
|
|
// the error to / complete / prune the NEWER turn's job. The finally still releases
|
|
// the slot + disposes. Proceed with finalization if the replacement check itself fails.
|
|
let stillLive = true;
|
|
try {
|
|
const liveJob = await GenerationJobManager.getJobStore().getJob(streamId);
|
|
stillLive = !!liveJob && liveJob.createdAt === job.createdAt;
|
|
} catch (readErr) {
|
|
logger.warn('[ResumeAgentController] Replacement check failed; finalizing anyway', readErr);
|
|
}
|
|
if (!stillLive) {
|
|
logger.warn(
|
|
`[ResumeAgentController] Skipping failed-resume finalization — job ${streamId} was replaced`,
|
|
);
|
|
} else {
|
|
// completeJob atomically claims running -> error and parks steers before
|
|
// publishing. If abort or a re-pause won, it returns false; only the
|
|
// terminal-CAS winner may delete this generation's checkpoint scope.
|
|
let errorFinalized = false;
|
|
try {
|
|
errorFinalized =
|
|
(await GenerationJobManager.completeJob(
|
|
streamId,
|
|
err?.message ?? 'Resume failed',
|
|
job.createdAt,
|
|
)) === true;
|
|
} catch (completeErr) {
|
|
logger.error('[ResumeAgentController] Failed to finalize failed resume', completeErr);
|
|
}
|
|
if (errorFinalized) {
|
|
await deleteFailedResumeCheckpoint(
|
|
{
|
|
conversationId,
|
|
checkpointerCfg,
|
|
job,
|
|
checkpointGeneration,
|
|
},
|
|
'failed resume finalization',
|
|
);
|
|
}
|
|
if (scheduleId && errorFinalized) {
|
|
const balanceRefusal = err?.message?.includes(ViolationTypes.TOKEN_BALANCE);
|
|
await recordScheduleOutcome({
|
|
scheduleId,
|
|
scheduledFor,
|
|
streamId,
|
|
jobCreatedAt: job.createdAt,
|
|
status: balanceRefusal ? 'skipped_balance' : 'error',
|
|
conversationId,
|
|
...(!balanceRefusal && { error: err?.message ?? 'Resume failed' }),
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
try {
|
|
// Tear down the MCP request-context store seeded before the ACK (parity with
|
|
// request.js's finishResumableRequest). No-op if it was never seeded.
|
|
await cleanupMCPRequestContextForReq(req);
|
|
// Release the concurrency slot taken above — UNLESS handleRunInterrupt already
|
|
// released it on a re-pause (so a fast /resume isn't 429'd). On a normal finish or
|
|
// error it didn't, so release here. A re-pause re-acquires its own slot next resume.
|
|
if (!client?.pendingRequestReleased) {
|
|
await decrementPendingRequest(userId);
|
|
}
|
|
if (client) {
|
|
disposeClient(client);
|
|
}
|
|
} finally {
|
|
await GenerationJobManager.markProviderExecutionDrained?.(
|
|
streamId,
|
|
job.createdAt,
|
|
providerExecutionId,
|
|
).catch((drainError) => {
|
|
logger.warn('[ResumeAgentController] Failed to record provider drain', drainError);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
module.exports = ResumeAgentController;
|