mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-22 08:05:24 +00:00
* refactor: promote generation protocol v2 automatically * fix: remove unused protocol import
5151 lines
226 KiB
TypeScript
5151 lines
226 KiB
TypeScript
import { logger } from '@librechat/data-schemas';
|
|
import { createContentAggregator } from '@librechat/agents';
|
|
import { ContentTypes, getRunStepDurationMs } from 'librechat-data-provider';
|
|
import type { StandardGraph } from '@librechat/agents';
|
|
import type { Agents } from 'librechat-data-provider';
|
|
import type { Redis, Cluster } from 'ioredis';
|
|
import type {
|
|
SerializableJobData,
|
|
CreatedJobData,
|
|
ReplacedGeneration,
|
|
SteerQueueItem,
|
|
UsageMetadata,
|
|
IJobStoreV2,
|
|
JobStatus,
|
|
JobMetadataPatch,
|
|
JobStatusTransition,
|
|
IdempotencyClaimValue,
|
|
IdempotencyClaimResult,
|
|
SteerArmOutcome,
|
|
SteerArmResult,
|
|
SteerEnqueueReceiptResult,
|
|
SteerEnqueueVersionedResult,
|
|
SteerReceipt,
|
|
SteerReceiptInput,
|
|
ParkedSteerClaim,
|
|
} from '~/stream/interfaces/IJobStore';
|
|
import type { ResolvedAskUserQuestion } from '~/agents/hitl/resume';
|
|
import type { RecoveredSteerPayload } from '~/stream/SteerRecovery';
|
|
import {
|
|
JobCreationSupersededError,
|
|
JobStatusTransitionDeadlineError,
|
|
JobPredecessorMismatchError,
|
|
STEER_ENQUEUE_NOT_RUNNING,
|
|
STEER_QUEUE_MAX_DEPTH,
|
|
PAUSE_PERSISTENCE_TIMEOUT_ERROR,
|
|
PAUSE_PERSISTENCE_TIMEOUT_MS,
|
|
PROVIDER_DRAIN_TIMEOUT_MS,
|
|
isPendingActionStale,
|
|
toWireRunSteps,
|
|
} from '~/stream/interfaces/IJobStore';
|
|
import {
|
|
MAX_COALESCED_BYTES,
|
|
MAX_COALESCED_EVENTS,
|
|
resolveCoalesceWindowMs,
|
|
} from '~/stream/internal/coalescing';
|
|
import { instrumentIORedisClient, RedisUseCases } from '~/cache/redisTelemetry';
|
|
import { RecoveredSteerPayloadMismatchError } from '~/stream/SteerRecovery';
|
|
|
|
const CLIENT_REQUEST_ID_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/;
|
|
|
|
type ReasoningLabelOverlay = {
|
|
stepId: string;
|
|
revision: number;
|
|
label: string;
|
|
status: 'streaming' | 'complete';
|
|
};
|
|
|
|
type ReasoningAttemptOverlay = {
|
|
stepId: string;
|
|
attempts: number;
|
|
submittedChars?: number;
|
|
};
|
|
|
|
type ReasoningContentPart = Agents.MessageContentComplex & {
|
|
reasoning_label?: string;
|
|
reasoning_label_step_id?: string;
|
|
reasoning_label_attempts?: number;
|
|
reasoning_label_submitted_chars?: number;
|
|
reasoning_label_revision?: number;
|
|
reasoning_label_status?: 'streaming' | 'complete';
|
|
};
|
|
|
|
function assertCreateIdempotencyArguments(
|
|
claimKey?: string,
|
|
claimToken?: string,
|
|
clientRequestId?: string,
|
|
): void {
|
|
const supplied = [claimKey, claimToken, clientRequestId].filter((value) => value != null).length;
|
|
if (
|
|
(supplied !== 0 && supplied !== 3) ||
|
|
(supplied === 3 &&
|
|
(claimKey!.length === 0 ||
|
|
claimKey!.length > 1024 ||
|
|
claimToken!.length === 0 ||
|
|
claimToken!.length > 128 ||
|
|
!CLIENT_REQUEST_ID_PATTERN.test(clientRequestId!)))
|
|
) {
|
|
throw new Error('Invalid generation job idempotency arguments');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomic compare-and-set on the job hash — the single-winner decision for a
|
|
* status transition. All supplied keys share the stream hash tag, so updating
|
|
* the job and terminal stream cleanup are atomic on both single-node Redis and
|
|
* Redis Cluster (cross-slot membership sets self-heal during cleanup).
|
|
*
|
|
* Guards on the current `status` and, when supplied, on the flat
|
|
* `pendingActionId` and `createdAt` fields — so a stale decision targeting a
|
|
* different action or replacement epoch loses. On success: removes `clear`
|
|
* fields, writes `status`+patch pairs, refreshes the job-hash TTL, and performs
|
|
* terminal cleanup of same-slot stream state. Returns 1 if it fired, 0 otherwise;
|
|
* the abort-only mode returns the JSON-encoded drained items on success.
|
|
*
|
|
* KEYS: [job, eventSequence, chunks, runSteps, steers, claimedSteers,
|
|
* parkedSteers, generationEpoch, steerReceipts, steerReceiptOrder]
|
|
* ARGV: [
|
|
* from,
|
|
* expectActionId | "",
|
|
* expectCreatedAt | "",
|
|
* notAfterMs | "",
|
|
* ttl,
|
|
* terminal ("0" | "1"),
|
|
* chunksAfterComplete,
|
|
* runStepsAfterComplete,
|
|
* parkedSteersTtl,
|
|
* generationEpochGraceTtl,
|
|
* steerReceiptTtl (0 to leave unchanged),
|
|
* returnDrainedSteers,
|
|
* hdelCount,
|
|
* ...hdelFields,
|
|
* ...hsetPairs
|
|
* ]
|
|
*/
|
|
const JOB_CAS_LUA =
|
|
'if redis.call("HGET", KEYS[1], "status") ~= ARGV[1] then return 0 end ' +
|
|
'if ARGV[2] ~= "" and redis.call("HGET", KEYS[1], "pendingActionId") ~= ARGV[2] then return 0 end ' +
|
|
'if ARGV[3] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[3] then return 0 end ' +
|
|
'if ARGV[4] ~= "" then local now = redis.call("TIME") ' +
|
|
'local nowMs = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000) ' +
|
|
'if nowMs >= tonumber(ARGV[4]) then return -1 end end ' +
|
|
'local currentCreatedAt = redis.call("HGET", KEYS[1], "createdAt") ' +
|
|
'local ttl = tonumber(ARGV[5]) ' +
|
|
'local terminal = ARGV[6] == "1" ' +
|
|
'local chunksTtl = tonumber(ARGV[7]) ' +
|
|
'local runStepsTtl = tonumber(ARGV[8]) ' +
|
|
'local parkedTtl = tonumber(ARGV[9]) ' +
|
|
'local generationEpochGraceTtl = tonumber(ARGV[10]) ' +
|
|
'local receiptTtl = tonumber(ARGV[11]) ' +
|
|
'local ownerUserId = redis.call("HGET", KEYS[1], "userId") ' +
|
|
'local ownerTenantId = redis.call("HGET", KEYS[1], "tenantId") ' +
|
|
'local generationProtocol = redis.call("HGET", KEYS[1], "generationProtocolVersion") == "2" and 2 or 1 ' +
|
|
'local parkedProtocol = generationProtocol ' +
|
|
'local function isDenseArray(value) if type(value) ~= "table" then return false end ' +
|
|
'local count = 0 for key, _ in pairs(value) do ' +
|
|
'if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then return false end count = count + 1 end ' +
|
|
'return count == #value end ' +
|
|
// A terminal transition is destructive. Validate every recovery source
|
|
// before changing status, receipts, queues, or parked state.
|
|
'local validatedPrior = {} ' +
|
|
'if terminal then ' +
|
|
'local claimedRows = {} if generationProtocol == 2 then claimedRows = redis.call("LRANGE", KEYS[6], 0, -1) end ' +
|
|
'local sources = { claimedRows, redis.call("LRANGE", KEYS[5], 0, -1) } ' +
|
|
'for s = 1, #sources do for i = 1, #sources[s] do local ok, item = pcall(cjson.decode, sources[s][i]) ' +
|
|
'if not ok or type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" then return 0 end end end ' +
|
|
'local parkedRaw = redis.call("GET", KEYS[7]) ' +
|
|
'if parkedRaw then local ok, parked = pcall(cjson.decode, parkedRaw) ' +
|
|
'if not ok or type(parked) ~= "table" or type(parked.userId) ~= "string" or parked.userId == "" ' +
|
|
'or not isDenseArray(parked.steers) or #parked.steers == 0 or parked.userId ~= ownerUserId ' +
|
|
'or (parked.tenantId and parked.tenantId ~= ownerTenantId) then return 0 end ' +
|
|
'if parked.generationProtocolVersion and parked.generationProtocolVersion ~= 1 ' +
|
|
'and parked.generationProtocolVersion ~= 2 then return 0 end ' +
|
|
'if parked.generationProtocolVersion == 2 then parkedProtocol = 2 end ' +
|
|
'for i = 1, #parked.steers do local item = parked.steers[i] ' +
|
|
'if type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" ' +
|
|
'or (item.clientSteerId and (type(item.clientSteerId) ~= "string" or item.clientSteerId == "")) ' +
|
|
'or (item.text and type(item.text) ~= "string") ' +
|
|
'or (item.createdAt and (type(item.createdAt) ~= "number" or item.createdAt < 0)) ' +
|
|
'or (item.recoveringCreatedAt and (type(item.recoveringCreatedAt) ~= "number" or item.recoveringCreatedAt < 0)) then return 0 end ' +
|
|
'validatedPrior[#validatedPrior + 1] = item end end end ' +
|
|
'local hdelCount = tonumber(ARGV[13]) ' +
|
|
'local idx = 14 ' +
|
|
'for i = 1, hdelCount do redis.call("HDEL", KEYS[1], ARGV[idx]) idx = idx + 1 end ' +
|
|
'local hset = {} ' +
|
|
'for i = idx, #ARGV do hset[#hset + 1] = ARGV[i] end ' +
|
|
'if #hset > 0 then redis.call("HSET", KEYS[1], unpack(hset)) end ' +
|
|
'if terminal then redis.call("HSET", KEYS[1], "steersClosed", "1") end ' +
|
|
// A same-status pause-barrier release does not carry pendingAction again.
|
|
// Preserve an explicit approval window that is longer than the default
|
|
// requires_action TTL instead of shortening the live job and its content.
|
|
'if not terminal and redis.call("HGET", KEYS[1], "status") == "requires_action" then ' +
|
|
'local currentTtl = redis.call("TTL", KEYS[1]) ' +
|
|
'if currentTtl > ttl then ttl = currentTtl end end ' +
|
|
'redis.call("EXPIRE", KEYS[1], ttl) ' +
|
|
'local effectiveReceiptTtl = receiptTtl ' +
|
|
'if not terminal and ttl > effectiveReceiptTtl then effectiveReceiptTtl = ttl end ' +
|
|
'if terminal and parkedTtl > effectiveReceiptTtl then effectiveReceiptTtl = parkedTtl end ' +
|
|
'if not terminal and redis.call("HGET", KEYS[1], "recoveredSteerId") then ' +
|
|
'local recoveryLeaseTtl = ttl + parkedTtl ' +
|
|
'if recoveryLeaseTtl > effectiveReceiptTtl then effectiveReceiptTtl = recoveryLeaseTtl end ' +
|
|
'local pt = redis.call("TTL", KEYS[7]) ' +
|
|
'if pt >= 0 and pt < recoveryLeaseTtl then redis.call("EXPIRE", KEYS[7], recoveryLeaseTtl) end end ' +
|
|
'if effectiveReceiptTtl > 0 then ' +
|
|
'for i = 9, 10 do local rt = redis.call("TTL", KEYS[i]) ' +
|
|
'if rt >= 0 and rt < effectiveReceiptTtl then redis.call("EXPIRE", KEYS[i], effectiveReceiptTtl) end end ' +
|
|
'end ' +
|
|
'local seqTtl = redis.call("TTL", KEYS[2]) ' +
|
|
'if seqTtl >= 0 and seqTtl < ttl then redis.call("EXPIRE", KEYS[2], ttl) end ' +
|
|
'if currentCreatedAt then redis.call("SET", KEYS[8], currentCreatedAt, "EX", ttl + generationEpochGraceTtl) end ' +
|
|
'if terminal then ' +
|
|
'local items = {} local projected = {} local seen = {} ' +
|
|
'local claimedRows = {} if generationProtocol == 2 then claimedRows = redis.call("LRANGE", KEYS[6], 0, -1) end ' +
|
|
'local sources = { claimedRows, redis.call("LRANGE", KEYS[5], 0, -1) } ' +
|
|
'for s = 1, #sources do for i = 1, #sources[s] do ' +
|
|
'local decoded, item = pcall(cjson.decode, sources[s][i]) ' +
|
|
'if decoded and type(item) == "table" and item.steerId and not seen[item.steerId] then ' +
|
|
'seen[item.steerId] = true ' +
|
|
'items[#items + 1] = item ' +
|
|
'local clientItem = { steerId = item.steerId, text = item.text, createdAt = item.createdAt } ' +
|
|
'if item.clientSteerId then clientItem.clientSteerId = item.clientSteerId end ' +
|
|
'if item.files then clientItem.files = item.files end ' +
|
|
'if item.quotes then clientItem.quotes = item.quotes end ' +
|
|
'if item.preempt then clientItem.preempt = item.preempt end ' +
|
|
'if item.preemptRevision then clientItem.preemptRevision = item.preemptRevision end ' +
|
|
'projected[#projected + 1] = clientItem ' +
|
|
'if generationProtocol == 2 and item.clientSteerId then local raw = redis.call("HGET", KEYS[9], item.clientSteerId) ' +
|
|
'if raw then local receiptOk, receipt = pcall(cjson.decode, raw) ' +
|
|
'if receiptOk and type(receipt) == "table" then receipt.item = item receipt.state = "leftover" ' +
|
|
'redis.call("HSET", KEYS[9], item.clientSteerId, cjson.encode(receipt)) end end end ' +
|
|
'end ' +
|
|
'end end ' +
|
|
'if #projected > 0 and ownerUserId then ' +
|
|
'local merged = {} local parkedSeen = {} ' +
|
|
'for i = 1, #validatedPrior do local item = validatedPrior[i] if not parkedSeen[item.steerId] then ' +
|
|
'parkedSeen[item.steerId] = true merged[#merged + 1] = item end end ' +
|
|
'for i = 1, #projected do local item = projected[i] if item.steerId and not parkedSeen[item.steerId] then ' +
|
|
'parkedSeen[item.steerId] = true merged[#merged + 1] = item end end ' +
|
|
'local parked = { userId = ownerUserId, generationProtocolVersion = parkedProtocol, steers = merged } ' +
|
|
'if ownerTenantId then parked.tenantId = ownerTenantId end ' +
|
|
'redis.call("SET", KEYS[7], cjson.encode(parked), "EX", parkedTtl) ' +
|
|
'end ' +
|
|
'redis.call("DEL", KEYS[5], KEYS[6]) ' +
|
|
'if chunksTtl == 0 then redis.call("DEL", KEYS[3]) else redis.call("EXPIRE", KEYS[3], chunksTtl) end ' +
|
|
'if runStepsTtl == 0 then redis.call("DEL", KEYS[4]) else redis.call("EXPIRE", KEYS[4], runStepsTtl) end ' +
|
|
'if ARGV[12] == "1" then if #items == 0 then return "[]" end return cjson.encode(items) end ' +
|
|
'else ' +
|
|
'redis.call("EXPIRE", KEYS[3], ttl) ' +
|
|
'redis.call("EXPIRE", KEYS[4], ttl) ' +
|
|
'redis.call("EXPIRE", KEYS[5], ttl) ' +
|
|
'redis.call("EXPIRE", KEYS[6], ttl) ' +
|
|
'end ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Atomic idempotency claim. Single-key `SET NX PX`: returns nil when this caller
|
|
* won the claim, or the already-stored stream JSON when a prior request holds it.
|
|
* Touches ONLY KEYS[1], so it is atomic on single-node and Redis Cluster.
|
|
*
|
|
* KEYS: [idempotency]
|
|
* ARGV: [valueJson, ttlMs]
|
|
*/
|
|
const IDEMPOTENCY_CLAIM_LUA =
|
|
'if redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", tonumber(ARGV[2])) then return false end ' +
|
|
'return redis.call("GET", KEYS[1])';
|
|
|
|
const IDEMPOTENCY_TAKEOVER_LUA =
|
|
'local raw = redis.call("GET", KEYS[1]) if not raw then return 0 end ' +
|
|
'local ok, current = pcall(cjson.decode, raw) ' +
|
|
'if not ok or current.claimToken ~= ARGV[1] or current.startedAt then return 0 end ' +
|
|
'redis.call("SET", KEYS[1], ARGV[2], "PX", tonumber(ARGV[3])) return 1';
|
|
|
|
const IDEMPOTENCY_MARK_STARTED_LUA =
|
|
'if ARGV[1] == "" then return 0 end ' +
|
|
'local raw = redis.call("GET", KEYS[1]) if not raw then return 0 end ' +
|
|
'local ok, current = pcall(cjson.decode, raw) ' +
|
|
'if not ok or current.claimToken ~= ARGV[1] then return 0 end ' +
|
|
'if current.startedAt and tostring(current.startedAt) ~= ARGV[2] then return 0 end ' +
|
|
'current.startedAt = tonumber(ARGV[2]) ' +
|
|
'redis.call("SET", KEYS[1], cjson.encode(current), "PX", tonumber(ARGV[3])) return 1';
|
|
|
|
/** Reacquire an expired claim without replacing the live generation it
|
|
* already started. Both keys carry the stream hash tag, so validation and the
|
|
* started tombstone are one atomic cluster-safe decision. */
|
|
const IDEMPOTENCY_ADOPT_LIVE_JOB_LUA =
|
|
'local raw = redis.call("GET", KEYS[1]) if not raw then return 0 end ' +
|
|
'local ok, claim = pcall(cjson.decode, raw) ' +
|
|
'if not ok or claim.claimToken ~= ARGV[1] or claim.startedAt then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[2], "createdAt") ~= ARGV[2] ' +
|
|
'or redis.call("HGET", KEYS[2], "userId") ~= ARGV[3] then return 0 end ' +
|
|
'local storedRequestId = redis.call("HGET", KEYS[2], "idempotencyClientRequestId") ' +
|
|
'if storedRequestId then if storedRequestId ~= ARGV[4] then return 0 end ' +
|
|
'elseif ARGV[7] ~= "1" then return 0 end ' +
|
|
'local storedTenantId = redis.call("HGET", KEYS[2], "tenantId") ' +
|
|
'if storedTenantId and storedTenantId ~= "" and storedTenantId ~= ARGV[5] then return 0 end ' +
|
|
'local status = redis.call("HGET", KEYS[2], "status") ' +
|
|
'if status ~= "running" and status ~= "requires_action" then return 0 end ' +
|
|
'local protocol = redis.call("HGET", KEYS[2], "generationProtocolVersion") ' +
|
|
'if protocol and protocol ~= "1" and protocol ~= "2" then return 0 end ' +
|
|
'claim.startedAt = tonumber(ARGV[2]) ' +
|
|
'claim.generationProtocolVersion = protocol == "2" and 2 or 1 ' +
|
|
'redis.call("SET", KEYS[1], cjson.encode(claim), "PX", tonumber(ARGV[6])) return 1';
|
|
|
|
const IDEMPOTENCY_RELEASE_LUA =
|
|
'if ARGV[1] ~= "" then local raw = redis.call("GET", KEYS[1]) if not raw then return 0 end ' +
|
|
'local ok, current = pcall(cjson.decode, raw) if not ok or current.claimToken ~= ARGV[1] then return 0 end end ' +
|
|
'return redis.call("DEL", KEYS[1])';
|
|
|
|
/** Attempt-fenced acknowledgement of transaction-time predecessor receipts.
|
|
* A replacement either inherits the untrimmed chain before this script or
|
|
* changes the attempt id first and makes this late acknowledgement a no-op. */
|
|
const REPLACEMENT_RECEIPT_ACK_LUA =
|
|
'if redis.call("HGET", KEYS[1], "__creationAttemptId") ~= ARGV[1] then return 0 end ' +
|
|
'local acknowledged = {} for i = 2, #ARGV do acknowledged[ARGV[i]] = true end ' +
|
|
'local raw = redis.call("HGET", KEYS[1], "__replacedGenerations") ' +
|
|
'if raw then local ok, current = pcall(cjson.decode, raw) ' +
|
|
'if not ok or type(current) ~= "table" then return 0 end ' +
|
|
'local retained = {} for i = 1, #current do local item = current[i] ' +
|
|
'if type(item) ~= "table" or type(item.createdAt) ~= "number" then return 0 end ' +
|
|
'if not acknowledged[tostring(item.createdAt)] then retained[#retained + 1] = item end end ' +
|
|
'if #retained == 0 then ' +
|
|
'redis.call("HDEL", KEYS[1], "__replacedGenerations", "__replacedCreatedAt", "__replacedStatus", "__replacedConversationId") ' +
|
|
'else local latest = retained[#retained] ' +
|
|
'redis.call("HSET", KEYS[1], "__replacedGenerations", cjson.encode(retained), ' +
|
|
'"__replacedCreatedAt", tostring(latest.createdAt), "__replacedStatus", latest.status) ' +
|
|
'if latest.conversationId then redis.call("HSET", KEYS[1], "__replacedConversationId", latest.conversationId) ' +
|
|
'else redis.call("HDEL", KEYS[1], "__replacedConversationId") end end return 1 end ' +
|
|
'local immediate = redis.call("HGET", KEYS[1], "__replacedCreatedAt") ' +
|
|
'if immediate and acknowledged[immediate] then ' +
|
|
'redis.call("HDEL", KEYS[1], "__replacedCreatedAt", "__replacedStatus", "__replacedConversationId") end ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Atomic job (re)creation for all generation-scoped same-slot keys. The
|
|
* predecessor hash/content are removed in the same script that installs the
|
|
* replacement hash. Pending and claimed steers are first merged into the
|
|
* owner-scoped parked recovery payload, so replacement cannot discard an ACK.
|
|
*
|
|
* KEYS: [job, chunks, runSteps, steers, claimedSteers, parkedSteers,
|
|
* generationEpoch, steerReceipts, steerReceiptOrder, idempotencyClaim]
|
|
* ARGV: [ttl, requestedCreatedAt, generationEpochGraceTtl, parkedTtl,
|
|
* recoveredSteerId | "", newOwnerUserId, newOwnerTenantId | "",
|
|
* idempotencyClaimToken | "",
|
|
* recoveredSteerPayloadJson | "",
|
|
* generationProtocolVersion,
|
|
* creationAttemptId | "",
|
|
* expectedPredecessorCreatedAt | "", rejectActivePredecessor ("1" | "0"),
|
|
* ...hsetPairs]
|
|
* Returns: [previousUserId | "", previousTenantId | "", createdAt, "",
|
|
* replacedCreatedAt | "", replacedStatus | "", replacedConversationId | "",
|
|
* replacedProviderAbortReady | "", replacedProviderExecutionId | "",
|
|
* replacedProviderDrained | ""]
|
|
* Predecessor mismatch returns the latest retained epoch in the replaced
|
|
* position plus an eighth active flag and ninth verified flag ("1" | "0").
|
|
* Job-only metadata is empty when that epoch has outlived its hash. When all
|
|
* evidence expired, the finite expected epoch is echoed with verified=false.
|
|
*/
|
|
const JOB_CREATE_LUA =
|
|
'if ARGV[8] ~= "" then local claimRaw = redis.call("GET", KEYS[10]) ' +
|
|
'if not claimRaw then return { "", "", "0", "claim_lost" } end ' +
|
|
'local ok, claim = pcall(cjson.decode, claimRaw) ' +
|
|
'if not ok or claim.claimToken ~= ARGV[8] or claim.startedAt then ' +
|
|
'return { "", "", "0", "claim_lost" } end end ' +
|
|
'local previousJobExists = redis.call("EXISTS", KEYS[1]) ' +
|
|
'local previousUserId = redis.call("HGET", KEYS[1], "userId") ' +
|
|
'local previousTenantId = redis.call("HGET", KEYS[1], "tenantId") ' +
|
|
'if previousJobExists == 1 and (not previousUserId or previousUserId == "" or previousUserId ~= ARGV[6] ' +
|
|
'or (previousTenantId and previousTenantId ~= ARGV[7])) then ' +
|
|
'return { "", "", "0", "owner_mismatch" } end ' +
|
|
'local replacedCreatedAt = redis.call("HGET", KEYS[1], "createdAt") ' +
|
|
'local replacedStatus = redis.call("HGET", KEYS[1], "status") ' +
|
|
'local replacedConversationId = redis.call("HGET", KEYS[1], "conversationId") ' +
|
|
'local replacedProviderAbortReady = redis.call("HGET", KEYS[1], "providerAbortReady") ' +
|
|
'local replacedProviderExecutionId = redis.call("HGET", KEYS[1], "providerExecutionId") ' +
|
|
'local replacedProviderDrained = redis.call("HGET", KEYS[1], "providerDrained") ' +
|
|
'local replacedTerminalPersistencePending = redis.call("HGET", KEYS[1], "terminalPersistencePending") ' +
|
|
'local replacedTerminalHostActionPending = redis.call("HGET", KEYS[1], "terminalHostActionPending") ' +
|
|
'local replacedDetachedTerminalHostActionPending = redis.call("HGET", KEYS[1], "detachedAgentEventTerminalHostActionPending") ' +
|
|
'local replacedProtocol = redis.call("HGET", KEYS[1], "generationProtocolVersion") ' +
|
|
'local MAX_SAFE_EPOCH = 9007199254740991 ' +
|
|
'local function isSafeEpoch(value) return type(value) == "number" and value >= 0 ' +
|
|
'and value <= MAX_SAFE_EPOCH and value == math.floor(value) end ' +
|
|
'local function isValidJobStatus(value) return value == "running" or value == "requires_action" ' +
|
|
'or value == "complete" or value == "error" or value == "aborted" end ' +
|
|
'local detachedTerminalShield = replacedStatus == "detached_terminal_pending_v1" ' +
|
|
'and replacedDetachedTerminalHostActionPending == "1" ' +
|
|
'local replacedEpoch = tonumber(replacedCreatedAt) local previousCreatedAt = replacedEpoch ' +
|
|
'if previousJobExists == 1 and (not isSafeEpoch(replacedEpoch) ' +
|
|
'or (not isValidJobStatus(replacedStatus) and not detachedTerminalShield)) then ' +
|
|
'return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'if (replacedProviderExecutionId and not replacedProviderDrained) ' +
|
|
'or (replacedProviderDrained and not replacedProviderExecutionId) ' +
|
|
'or (replacedProviderExecutionId and (replacedProviderExecutionId == "" ' +
|
|
'or string.len(replacedProviderExecutionId) > 128)) ' +
|
|
'or (replacedProviderDrained and replacedProviderDrained ~= "0" and replacedProviderDrained ~= "1") then ' +
|
|
'return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'local retainedEpochRaw = redis.call("GET", KEYS[7]) local retainedEpoch = tonumber(retainedEpochRaw) ' +
|
|
'if retainedEpochRaw and not isSafeEpoch(retainedEpoch) then ' +
|
|
'return { "", "", "0", "generation_epoch_corrupt" } end ' +
|
|
'local observedCreatedAt = replacedCreatedAt local observedStatus = replacedStatus ' +
|
|
'local observedConversationId = replacedConversationId ' +
|
|
'local observedActive = previousJobExists == 1 and ' +
|
|
'(replacedStatus == "running" or replacedStatus == "requires_action" ' +
|
|
'or replacedTerminalPersistencePending == "1" or replacedTerminalHostActionPending == "1" or replacedDetachedTerminalHostActionPending == "1") ' +
|
|
'if retainedEpoch and (not previousCreatedAt or retainedEpoch > previousCreatedAt) then ' +
|
|
'previousCreatedAt = retainedEpoch observedCreatedAt = retainedEpochRaw ' +
|
|
'observedStatus = nil observedConversationId = nil observedActive = false end ' +
|
|
'if observedActive and (replacedTerminalHostActionPending == "1" or replacedDetachedTerminalHostActionPending == "1") then ' +
|
|
'return { previousUserId or "", previousTenantId or "", "0", "predecessor_mismatch", ' +
|
|
'observedCreatedAt, observedStatus or "", observedConversationId or "", "1", "1" } end ' +
|
|
'if ARGV[13] == "1" and observedActive then ' +
|
|
'return { previousUserId or "", previousTenantId or "", "0", "predecessor_mismatch", ' +
|
|
'observedCreatedAt, observedStatus or "", observedConversationId or "", "1", "1" } end ' +
|
|
'if ARGV[12] ~= "" and (not observedCreatedAt or observedCreatedAt ~= ARGV[12]) then ' +
|
|
'return { previousUserId or "", previousTenantId or "", "0", "predecessor_mismatch", ' +
|
|
'observedCreatedAt or ARGV[12], observedStatus or "", observedConversationId or "", ' +
|
|
'observedActive and "1" or "0", observedCreatedAt and "1" or "0" } end ' +
|
|
'local createdAt = tonumber(ARGV[2]) ' +
|
|
'if not isSafeEpoch(createdAt) then return { "", "", "0", "generation_epoch_corrupt" } end ' +
|
|
'if previousCreatedAt and previousCreatedAt >= MAX_SAFE_EPOCH then ' +
|
|
'return { "", "", "0", "generation_epoch_exhausted" } end ' +
|
|
'if previousCreatedAt and previousCreatedAt >= createdAt then createdAt = previousCreatedAt + 1 end ' +
|
|
'local merged = {} local seen = {} local parkedUserId = previousUserId local parkedTenantId = previousTenantId ' +
|
|
'local parkedProtocol = replacedProtocol == "2" and 2 or 1 ' +
|
|
'local function isDenseArray(value) if type(value) ~= "table" then return false end ' +
|
|
'local count = 0 for key, _ in pairs(value) do ' +
|
|
'if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then return false end count = count + 1 end ' +
|
|
'return count == #value end ' +
|
|
// Carry every still-unacknowledged transaction-time predecessor forward.
|
|
// A later replacement can then stop providers skipped when an earlier
|
|
// create reply was lost. Reject before mutation rather than evicting an old
|
|
// active epoch when the bounded receipt chain is full.
|
|
'local replacementChain = {} local replacementSeen = {} local lastReplacementEpoch = -1 ' +
|
|
'local replacementRaw = redis.call("HGET", KEYS[1], "__replacedGenerations") ' +
|
|
'local inheritedCreatedAtRaw = redis.call("HGET", KEYS[1], "__replacedCreatedAt") ' +
|
|
'local inheritedStatus = redis.call("HGET", KEYS[1], "__replacedStatus") ' +
|
|
'local inheritedConversationId = redis.call("HGET", KEYS[1], "__replacedConversationId") ' +
|
|
'if (inheritedCreatedAtRaw and not inheritedStatus) or (inheritedStatus and not inheritedCreatedAtRaw) then ' +
|
|
'return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'if replacementRaw then local chainOk, inherited = pcall(cjson.decode, replacementRaw) ' +
|
|
'if not chainOk or not isDenseArray(inherited) or #inherited == 0 or #inherited > 32 then ' +
|
|
'return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'for i = 1, #inherited do local item = inherited[i] ' +
|
|
'local validStatus = type(item) == "table" and isValidJobStatus(item.status) ' +
|
|
'if not validStatus or not isSafeEpoch(item.createdAt) ' +
|
|
'or item.createdAt <= lastReplacementEpoch ' +
|
|
'or (previousJobExists == 1 and item.createdAt >= replacedEpoch) ' +
|
|
'or (item.conversationId and type(item.conversationId) ~= "string") ' +
|
|
'or (item.providerAbortReady ~= nil and type(item.providerAbortReady) ~= "boolean") ' +
|
|
'or (item.providerExecutionId ~= nil and (type(item.providerExecutionId) ~= "string" ' +
|
|
'or item.providerExecutionId == "" or string.len(item.providerExecutionId) > 128)) ' +
|
|
'or (item.providerDrained ~= nil and type(item.providerDrained) ~= "boolean") ' +
|
|
'or ((item.providerExecutionId ~= nil) ~= (item.providerDrained ~= nil)) ' +
|
|
'or replacementSeen[tostring(item.createdAt)] then ' +
|
|
'return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'lastReplacementEpoch = item.createdAt replacementSeen[tostring(item.createdAt)] = true ' +
|
|
'replacementChain[#replacementChain + 1] = item end ' +
|
|
'if not inheritedCreatedAtRaw then return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'local inheritedEpoch = tonumber(inheritedCreatedAtRaw) local latest = replacementChain[#replacementChain] ' +
|
|
'if not isSafeEpoch(inheritedEpoch) or not isValidJobStatus(inheritedStatus) ' +
|
|
'or latest.createdAt ~= inheritedEpoch or latest.status ~= inheritedStatus ' +
|
|
'or (latest.conversationId or false) ~= inheritedConversationId then ' +
|
|
'return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'elseif inheritedCreatedAtRaw then local inheritedEpoch = tonumber(inheritedCreatedAtRaw) ' +
|
|
'if not isSafeEpoch(inheritedEpoch) or not isValidJobStatus(inheritedStatus) ' +
|
|
'or previousJobExists ~= 1 or inheritedEpoch >= replacedEpoch then ' +
|
|
'return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'local inherited = { createdAt = inheritedEpoch, status = inheritedStatus } ' +
|
|
'if inheritedConversationId then inherited.conversationId = inheritedConversationId end ' +
|
|
'replacementSeen[tostring(inheritedEpoch)] = true replacementChain[1] = inherited end ' +
|
|
'if previousJobExists == 1 then ' +
|
|
'if replacementSeen[tostring(replacedEpoch)] then ' +
|
|
'return { "", "", "0", "replacement_receipt_corrupt" } end ' +
|
|
'if #replacementChain >= 32 then return { "", "", "0", "replacement_chain_full" } end ' +
|
|
'local replaced = { createdAt = replacedEpoch, status = replacedStatus } ' +
|
|
'if replacedConversationId then replaced.conversationId = replacedConversationId end ' +
|
|
'if replacedProviderAbortReady then replaced.providerAbortReady = replacedProviderAbortReady == "1" end ' +
|
|
'if replacedProviderExecutionId then replaced.providerExecutionId = replacedProviderExecutionId end ' +
|
|
'if replacedProviderDrained then replaced.providerDrained = replacedProviderDrained == "1" end ' +
|
|
'replacementChain[#replacementChain + 1] = replaced replacementSeen[tostring(replacedEpoch)] = true end ' +
|
|
'local recoveredSteerId = ARGV[5] local expectedRecovery = nil ' +
|
|
'if recoveredSteerId ~= "" and ARGV[10] ~= "2" then return { "", "", "0", "recovery_payload_mismatch" } end ' +
|
|
'if recoveredSteerId ~= "" then local ok, decoded = pcall(cjson.decode, ARGV[9]) ' +
|
|
'if not ok or type(decoded) ~= "table" or type(decoded.text) ~= "string" ' +
|
|
'or not isDenseArray(decoded.fileIds) then return { "", "", "0", "recovery_payload_mismatch" } end ' +
|
|
'local expectedSeen = {} for i = 1, #decoded.fileIds do local fileId = decoded.fileIds[i] ' +
|
|
'if type(fileId) ~= "string" or fileId == "" or expectedSeen[fileId] then ' +
|
|
'return { "", "", "0", "recovery_payload_mismatch" } end expectedSeen[fileId] = true end ' +
|
|
'if decoded.quotes ~= nil then if not isDenseArray(decoded.quotes) then ' +
|
|
'return { "", "", "0", "recovery_payload_mismatch" } end ' +
|
|
'for i = 1, #decoded.quotes do if type(decoded.quotes[i]) ~= "string" or decoded.quotes[i] == "" then ' +
|
|
'return { "", "", "0", "recovery_payload_mismatch" } end end end ' +
|
|
'expectedRecovery = decoded elseif ARGV[9] ~= "" then ' +
|
|
'return { "", "", "0", "recovery_payload_mismatch" } end ' +
|
|
'local function recoveryMatches(item, expected) ' +
|
|
'if not expected or type(item.text) ~= "string" or item.text ~= expected.text then return false end ' +
|
|
// Quotes are model-bound like the text: order-significant identity, with a
|
|
// missing array on either side reading as empty (pre-quotes compatibility).
|
|
'local expectedQuotes = expected.quotes or {} local itemQuotes = item.quotes ' +
|
|
'if itemQuotes ~= nil and not isDenseArray(itemQuotes) then return false end ' +
|
|
'itemQuotes = itemQuotes or {} if #itemQuotes ~= #expectedQuotes then return false end ' +
|
|
'for i = 1, #itemQuotes do if itemQuotes[i] ~= expectedQuotes[i] then return false end end ' +
|
|
'local actualSeen = {} local actualCount = 0 local files = item.files ' +
|
|
'if files then if not isDenseArray(files) then return false end ' +
|
|
'for i = 1, #files do local file = files[i] ' +
|
|
'if type(file) ~= "table" or type(file.file_id) ~= "string" or file.file_id == "" then return false end ' +
|
|
'if not actualSeen[file.file_id] then actualSeen[file.file_id] = true actualCount = actualCount + 1 end end end ' +
|
|
'if actualCount ~= #expected.fileIds then return false end ' +
|
|
'for i = 1, #expected.fileIds do if not actualSeen[expected.fileIds[i]] then return false end end return true end ' +
|
|
'local parkedRaw = redis.call("GET", KEYS[6]) ' +
|
|
'if parkedRaw then local ok, parked = pcall(cjson.decode, parkedRaw) ' +
|
|
'if not ok or type(parked) ~= "table" or type(parked.userId) ~= "string" ' +
|
|
'or parked.userId == "" or not isDenseArray(parked.steers) or #parked.steers == 0 then ' +
|
|
'return { "", "", "0", "recovery_corrupt" } end ' +
|
|
'if parked.generationProtocolVersion and parked.generationProtocolVersion ~= 1 ' +
|
|
'and parked.generationProtocolVersion ~= 2 then return { "", "", "0", "recovery_corrupt" } end ' +
|
|
'if parked.generationProtocolVersion == 2 then parkedProtocol = 2 end ' +
|
|
'for i = 1, #parked.steers do local item = parked.steers[i] ' +
|
|
'if type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" ' +
|
|
'or (item.clientSteerId and (type(item.clientSteerId) ~= "string" or item.clientSteerId == "")) ' +
|
|
'or (item.text and type(item.text) ~= "string") ' +
|
|
'or (item.createdAt and (type(item.createdAt) ~= "number" or item.createdAt < 0)) ' +
|
|
'or (item.recoveringCreatedAt and (type(item.recoveringCreatedAt) ~= "number" or item.recoveringCreatedAt < 0)) then ' +
|
|
'return { "", "", "0", "recovery_corrupt" } end end ' +
|
|
'if recoveredSteerId ~= "" and parked.generationProtocolVersion ~= 2 then ' +
|
|
'for i = 1, #parked.steers do if parked.steers[i].steerId == recoveredSteerId then ' +
|
|
'return { "", "", "0", "recovery_payload_mismatch" } end end end ' +
|
|
'if parked.userId ~= ARGV[6] or (parked.tenantId and parked.tenantId ~= ARGV[7]) then ' +
|
|
'return { "", "", "0", "owner_mismatch" } end ' +
|
|
'parkedUserId = parked.userId parkedTenantId = parked.tenantId ' +
|
|
'for i = 1, #parked.steers do local item = parked.steers[i] ' +
|
|
'if item.steerId and not seen[item.steerId] then seen[item.steerId] = true merged[#merged + 1] = item end end end ' +
|
|
'local receiptUpdates = {} local ownerMatches = not parkedUserId or not previousUserId or ' +
|
|
'(parkedUserId == previousUserId and (not parkedTenantId or parkedTenantId == previousTenantId)) ' +
|
|
'if ownerMatches then if not parkedUserId then parkedUserId = previousUserId parkedTenantId = previousTenantId end ' +
|
|
'local claimedRows = {} if replacedProtocol == "2" then claimedRows = redis.call("LRANGE", KEYS[5], 0, -1) end ' +
|
|
'local sources = { claimedRows, redis.call("LRANGE", KEYS[4], 0, -1) } ' +
|
|
'for s = 1, #sources do for i = 1, #sources[s] do local ok, item = pcall(cjson.decode, sources[s][i]) ' +
|
|
'if ok and recoveredSteerId ~= "" and item.steerId == recoveredSteerId and replacedProtocol ~= "2" then ' +
|
|
'return { "", "", "0", "recovery_payload_mismatch" } end ' +
|
|
'if ok and item.steerId and not seen[item.steerId] then seen[item.steerId] = true ' +
|
|
'local projected = { steerId = item.steerId, text = item.text, createdAt = item.createdAt } ' +
|
|
'if item.clientSteerId then projected.clientSteerId = item.clientSteerId end ' +
|
|
'if item.files then projected.files = item.files end if item.quotes then projected.quotes = item.quotes end ' +
|
|
'if item.preempt then projected.preempt = item.preempt end ' +
|
|
'if item.preemptRevision then projected.preemptRevision = item.preemptRevision end ' +
|
|
'merged[#merged + 1] = projected receiptUpdates[#receiptUpdates + 1] = item end end end end ' +
|
|
'local recoveryOwnerMatches = parkedUserId == ARGV[6] and ' +
|
|
'(not parkedTenantId or parkedTenantId == ARGV[7]) ' +
|
|
'local recoveryFound = recoveredSteerId == "" ' +
|
|
'for i = 1, #merged do local item = merged[i] ' +
|
|
'item.recoveringCreatedAt = nil ' +
|
|
'if recoveredSteerId ~= "" and item.steerId == recoveredSteerId then ' +
|
|
'if not recoveryOwnerMatches or not recoveryMatches(item, expectedRecovery) then ' +
|
|
'return { "", "", "0", "recovery_payload_mismatch" } end ' +
|
|
'item.recoveringCreatedAt = createdAt recoveryFound = true end end ' +
|
|
'if not recoveryFound then return { "", "", "0", "recovery_payload_mismatch" } end ' +
|
|
'for i = 1, #receiptUpdates do local item = receiptUpdates[i] ' +
|
|
'if replacedProtocol == "2" and item.clientSteerId then local raw = redis.call("HGET", KEYS[8], item.clientSteerId) ' +
|
|
'if raw then local receiptOk, receipt = pcall(cjson.decode, raw) ' +
|
|
'if receiptOk and type(receipt) == "table" then receipt.item = item receipt.state = "leftover" ' +
|
|
'redis.call("HSET", KEYS[8], item.clientSteerId, cjson.encode(receipt)) end end end end ' +
|
|
'if #merged > 0 and parkedUserId then local parked = { userId = parkedUserId, generationProtocolVersion = parkedProtocol, steers = merged } ' +
|
|
'if parkedTenantId then parked.tenantId = parkedTenantId end ' +
|
|
'redis.call("SET", KEYS[6], cjson.encode(parked), "EX", ARGV[4]) else redis.call("DEL", KEYS[6]) end ' +
|
|
'if #merged > 0 then for i = 8, 9 do local rt = redis.call("TTL", KEYS[i]) ' +
|
|
'if rt >= 0 and rt < tonumber(ARGV[4]) then redis.call("EXPIRE", KEYS[i], ARGV[4]) end end end ' +
|
|
'redis.call("DEL", KEYS[1], KEYS[2], KEYS[3], KEYS[4], KEYS[5]) ' +
|
|
'local ttl = tonumber(ARGV[1]) ' +
|
|
'local generationEpochGraceTtl = tonumber(ARGV[3]) ' +
|
|
'local hset = {} ' +
|
|
'for i = 14, #ARGV do hset[#hset + 1] = ARGV[i] end ' +
|
|
'redis.call("HSET", KEYS[1], unpack(hset)) ' +
|
|
'redis.call("HSET", KEYS[1], "createdAt", tostring(createdAt)) ' +
|
|
'if ARGV[11] ~= "" then redis.call("HSET", KEYS[1], "__creationAttemptId", ARGV[11]) end ' +
|
|
// Keep the transaction-time predecessor receipt in the replacement hash.
|
|
// deserializeJob reconstructs it as non-enumerable return metadata, so an
|
|
// eval reply lost after commit can still stop the exact replaced provider.
|
|
'if replacedCreatedAt and replacedStatus then ' +
|
|
'redis.call("HSET", KEYS[1], "__replacedCreatedAt", replacedCreatedAt, "__replacedStatus", replacedStatus) ' +
|
|
'if replacedConversationId then redis.call("HSET", KEYS[1], "__replacedConversationId", replacedConversationId) end end ' +
|
|
'if #replacementChain > 0 then redis.call("HSET", KEYS[1], "__replacedGenerations", cjson.encode(replacementChain)) end ' +
|
|
'if ARGV[10] == "2" then redis.call("HSET", KEYS[1], "checkpointNamespace", tostring(createdAt)) ' +
|
|
'else redis.call("HDEL", KEYS[1], "checkpointNamespace") end ' +
|
|
'redis.call("EXPIRE", KEYS[1], ttl) ' +
|
|
'redis.call("SET", KEYS[7], tostring(createdAt), "EX", ttl + generationEpochGraceTtl) ' +
|
|
'if ARGV[8] ~= "" then local claimRaw = redis.call("GET", KEYS[10]) ' +
|
|
'local claimTtl = redis.call("PTTL", KEYS[10]) local ok, claim = pcall(cjson.decode, claimRaw) ' +
|
|
'if not ok or claim.claimToken ~= ARGV[8] then return { "", "", "0", "claim_lost" } end ' +
|
|
'claim.startedAt = createdAt redis.call("SET", KEYS[10], cjson.encode(claim)) ' +
|
|
'if claimTtl > 0 then redis.call("PEXPIRE", KEYS[10], claimTtl) end end ' +
|
|
'return { previousUserId or "", previousTenantId or "", tostring(createdAt), "", ' +
|
|
'replacedCreatedAt or "", replacedStatus or "", replacedConversationId or "", ' +
|
|
'replacedProviderAbortReady or "", replacedProviderExecutionId or "", ' +
|
|
'replacedProviderDrained or "" }';
|
|
|
|
/**
|
|
* Epoch-guarded field update. Terminal writes reclaim same-slot content in the
|
|
* same atomic step, so a replacement cannot appear between the guarded write
|
|
* and content cleanup.
|
|
*
|
|
* KEYS: [job, chunks, runSteps, steers, claimedSteers]
|
|
* ARGV: [
|
|
* expectCreatedAt | "",
|
|
* terminal ("0" | "1"),
|
|
* completedTtl,
|
|
* chunksAfterComplete,
|
|
* runStepsAfterComplete,
|
|
* ...hsetPairs
|
|
* ]
|
|
*/
|
|
const JOB_UPDATE_LUA =
|
|
'if redis.call("EXISTS", KEYS[1]) == 0 then return 0 end ' +
|
|
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'local hset = {} ' +
|
|
'for i = 6, #ARGV do hset[#hset + 1] = ARGV[i] end ' +
|
|
'if #hset > 0 then redis.call("HSET", KEYS[1], unpack(hset)) end ' +
|
|
'if ARGV[2] == "1" then ' +
|
|
'local completedTtl = tonumber(ARGV[3]) ' +
|
|
'local chunksTtl = tonumber(ARGV[4]) ' +
|
|
'local runStepsTtl = tonumber(ARGV[5]) ' +
|
|
'redis.call("EXPIRE", KEYS[1], completedTtl) ' +
|
|
'redis.call("DEL", KEYS[4]) ' +
|
|
'if chunksTtl == 0 then redis.call("DEL", KEYS[2]) else redis.call("EXPIRE", KEYS[2], chunksTtl) end ' +
|
|
'if runStepsTtl == 0 then redis.call("DEL", KEYS[3]) else redis.call("EXPIRE", KEYS[3], runStepsTtl) end ' +
|
|
'end ' +
|
|
'return 1';
|
|
|
|
/** Exact provider-segment completion fence. A paused segment finishing after a
|
|
* resume cannot mark the resumed provider drained because its opaque id differs. */
|
|
const PROVIDER_DRAIN_LUA =
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "providerExecutionId") ~= ARGV[2] then return 0 end ' +
|
|
'redis.call("HSET", KEYS[1], "providerDrained", "1") return 1';
|
|
|
|
/** Recover a terminal host action whose provider-owning process disappeared
|
|
* after the terminal CAS. `completedAt` is immutable for this generation, so
|
|
* the deadline cannot be extended by retry enumeration. */
|
|
const RECOVER_TERMINAL_PROVIDER_DRAIN_LUA =
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "terminalHostActionPending") ~= "1" and redis.call("HGET", KEYS[1], "detachedAgentEventTerminalHostActionPending") ~= "1" then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "providerDrained") ~= "0" then return 0 end ' +
|
|
'local completedAt = tonumber(redis.call("HGET", KEYS[1], "completedAt") or "") ' +
|
|
'if not completedAt or completedAt > tonumber(ARGV[2]) then return 0 end ' +
|
|
'redis.call("HSET", KEYS[1], "providerDrained", "1") return 1';
|
|
|
|
/** Exact initial provider-start fence. The controller rechecks account
|
|
* deletion before this CAS; an abort/replacement that wins next prevents the
|
|
* provider from starting after destructive cleanup has begun. */
|
|
const PROVIDER_BEGIN_LUA =
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "providerExecutionId") ~= ARGV[2] then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "providerDrained") ~= "1" then return 0 end ' +
|
|
'redis.call("HSET", KEYS[1], "providerDrained", "0", "providerExecutionStartedId", ARGV[2]) return 1';
|
|
|
|
/** Single-winner promotion from abort-persistence pending to a consumable
|
|
* terminal payload. Owner success/failure and stale-owner recovery share this
|
|
* CAS, so a timeout cannot overwrite a normal FINAL (or vice versa). */
|
|
const TERMINAL_PERSISTENCE_FINALIZE_LUA =
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "terminalPersistencePending") ~= "1" then return 0 end ' +
|
|
'redis.call("HSET", KEYS[1], "terminalPersistencePending", "0", "finalEvent", ARGV[2]) ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Epoch-guarded hard deletion. `expectMissing` makes an unguarded cleanup of
|
|
* already-absent state safe against a replacement appearing after the read.
|
|
* Parked steers intentionally survive: completion parks before deleting.
|
|
*
|
|
* KEYS: [job, chunks, runSteps, steers]
|
|
* ARGV: [expectCreatedAt | "", expectMissing ("0" | "1")]
|
|
*/
|
|
const JOB_DELETE_LUA =
|
|
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'local existed = redis.call("EXISTS", KEYS[1]) ' +
|
|
'if ARGV[2] == "1" and existed == 1 then return 0 end ' +
|
|
'redis.call("DEL", KEYS[1], KEYS[2], KEYS[3], KEYS[4], KEYS[5]) ' +
|
|
'return existed';
|
|
|
|
/**
|
|
* Atomic stale-running reap. The liveness and epoch checks, steer projection
|
|
* and parking, and same-slot deletion are one operation. A replacement either
|
|
* lands before the script and fails the guard, or lands afterward and clears
|
|
* the predecessor's parked payload in {@link JOB_CREATE_LUA}.
|
|
*
|
|
* KEYS: [job, chunks, runSteps, steers, claimedSteers, parkedSteers,
|
|
* generationEpoch, steerReceipts, steerReceiptOrder]
|
|
* ARGV: [expectCreatedAt, nowMs, staleAfterMs, parkedSteersTtl, generationEpochGraceTtl]
|
|
*/
|
|
const STALE_JOB_DELETE_LUA =
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'local liveSince = tonumber(redis.call("HGET", KEYS[1], "lastActiveAt")) ' +
|
|
'if not liveSince then liveSince = tonumber(redis.call("HGET", KEYS[1], "createdAt")) end ' +
|
|
'if not liveSince or tonumber(ARGV[2]) - liveSince <= tonumber(ARGV[3]) then return 0 end ' +
|
|
'local ownerUserId = redis.call("HGET", KEYS[1], "userId") ' +
|
|
'local ownerTenantId = redis.call("HGET", KEYS[1], "tenantId") ' +
|
|
'local generationProtocol = redis.call("HGET", KEYS[1], "generationProtocolVersion") == "2" and 2 or 1 ' +
|
|
'local parkedProtocol = generationProtocol ' +
|
|
'local function isDenseArray(value) if type(value) ~= "table" then return false end ' +
|
|
'local count = 0 for key, _ in pairs(value) do ' +
|
|
'if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then return false end count = count + 1 end ' +
|
|
'return count == #value end ' +
|
|
'local prior = {} local parkedRaw = redis.call("GET", KEYS[6]) ' +
|
|
'if parkedRaw then local ok, parked = pcall(cjson.decode, parkedRaw) ' +
|
|
'if not ok or type(parked) ~= "table" or type(parked.userId) ~= "string" or parked.userId == "" ' +
|
|
'or not isDenseArray(parked.steers) or #parked.steers == 0 or parked.userId ~= ownerUserId ' +
|
|
'or (parked.tenantId and parked.tenantId ~= ownerTenantId) then return 0 end ' +
|
|
'if parked.generationProtocolVersion and parked.generationProtocolVersion ~= 1 ' +
|
|
'and parked.generationProtocolVersion ~= 2 then return 0 end ' +
|
|
'if parked.generationProtocolVersion == 2 then parkedProtocol = 2 end ' +
|
|
'for i = 1, #parked.steers do local item = parked.steers[i] ' +
|
|
'if type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" ' +
|
|
'or (item.clientSteerId and (type(item.clientSteerId) ~= "string" or item.clientSteerId == "")) ' +
|
|
'or (item.text and type(item.text) ~= "string") ' +
|
|
'or (item.createdAt and (type(item.createdAt) ~= "number" or item.createdAt < 0)) ' +
|
|
'or (item.recoveringCreatedAt and (type(item.recoveringCreatedAt) ~= "number" or item.recoveringCreatedAt < 0)) then return 0 end ' +
|
|
'prior[#prior + 1] = item end end ' +
|
|
'local claimedRows = {} if generationProtocol == 2 then claimedRows = redis.call("LRANGE", KEYS[5], 0, -1) end ' +
|
|
'local sourceRows = { claimedRows, redis.call("LRANGE", KEYS[4], 0, -1) } ' +
|
|
'for s = 1, #sourceRows do for i = 1, #sourceRows[s] do local ok, item = pcall(cjson.decode, sourceRows[s][i]) ' +
|
|
'if not ok or type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" then return 0 end end end ' +
|
|
'if (#sourceRows[1] > 0 or #sourceRows[2] > 0) and (not ownerUserId or ownerUserId == "") then return 0 end ' +
|
|
'local fullItems = {} local projected = {} local seen = {} ' +
|
|
'local sources = sourceRows ' +
|
|
'for s = 1, #sources do for i = 1, #sources[s] do ' +
|
|
'local decoded, item = pcall(cjson.decode, sources[s][i]) ' +
|
|
'if decoded and type(item) == "table" then ' +
|
|
'if item.steerId and not seen[item.steerId] then seen[item.steerId] = true fullItems[#fullItems + 1] = item ' +
|
|
'local clientItem = { steerId = item.steerId, text = item.text, createdAt = item.createdAt } ' +
|
|
'if item.clientSteerId then clientItem.clientSteerId = item.clientSteerId end ' +
|
|
'if item.files then clientItem.files = item.files end if item.quotes then clientItem.quotes = item.quotes end ' +
|
|
'if item.preempt then clientItem.preempt = item.preempt end ' +
|
|
'if item.preemptRevision then clientItem.preemptRevision = item.preemptRevision end ' +
|
|
'projected[#projected + 1] = clientItem end ' +
|
|
'if generationProtocol == 2 and item.clientSteerId then local raw = redis.call("HGET", KEYS[8], item.clientSteerId) ' +
|
|
'if raw then local receiptOk, receipt = pcall(cjson.decode, raw) ' +
|
|
'if receiptOk and type(receipt) == "table" then receipt.item = item receipt.state = "leftover" ' +
|
|
'redis.call("HSET", KEYS[8], item.clientSteerId, cjson.encode(receipt)) end end end ' +
|
|
'end ' +
|
|
'end end ' +
|
|
'if #projected > 0 and ownerUserId then ' +
|
|
'local merged = {} local parkedSeen = {} ' +
|
|
'for i = 1, #prior do local item = prior[i] if not parkedSeen[item.steerId] then ' +
|
|
'parkedSeen[item.steerId] = true merged[#merged + 1] = item end end ' +
|
|
'for i = 1, #projected do local item = projected[i] if item.steerId and not parkedSeen[item.steerId] then ' +
|
|
'parkedSeen[item.steerId] = true merged[#merged + 1] = item end end ' +
|
|
'local parked = { userId = ownerUserId, generationProtocolVersion = parkedProtocol, steers = merged } ' +
|
|
'if ownerTenantId then parked.tenantId = ownerTenantId end ' +
|
|
'redis.call("SET", KEYS[6], cjson.encode(parked), "EX", tonumber(ARGV[4])) ' +
|
|
'end ' +
|
|
'for i = 8, 9 do local ttl = redis.call("TTL", KEYS[i]) ' +
|
|
'if ttl >= 0 and ttl < tonumber(ARGV[4]) then redis.call("EXPIRE", KEYS[i], ARGV[4]) end end ' +
|
|
'redis.call("SET", KEYS[7], ARGV[1], "EX", tonumber(ARGV[5])) ' +
|
|
'redis.call("DEL", KEYS[1], KEYS[2], KEYS[3], KEYS[4], KEYS[5]) ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* XADD a chunk + set the chunk-stream TTL to the right window WITHOUT ever shrinking it.
|
|
*
|
|
* During a live stream the running TTL is refreshed on every chunk. But a job paused
|
|
* for HITL review must keep its chunk stream alive for the whole approval window, not
|
|
* the ~20m running TTL — otherwise the pre-pause aggregated content (tool call + earlier
|
|
* text) is evicted before the user resolves and `getResumeState()` loses it.
|
|
*
|
|
* `transitionStatus` extends the chunk-key TTL to the approval window at pause time, but
|
|
* that alone is not enough:
|
|
* 1. The pause's `EXPIRE chunks` is a no-op if the chunk key does not exist yet — and
|
|
* `appendChunk` is fire-and-forget, so the first chunk's XADD can land AFTER the
|
|
* pause, or an ask-user pause can occur before any chunk was ever persisted.
|
|
* 2. The `on_pending_action` chunk (and any chunk that races in after the pause) would
|
|
* otherwise reset an already-extended TTL back to the short running TTL.
|
|
* So this script derives the target window itself: the running TTL normally, but when the
|
|
* job hash is paused (`status == "requires_action"`) it takes the larger of the running
|
|
* TTL and the job key's own remaining TTL (which `transitionStatus` set to the approval
|
|
* window). It only ever EXTENDS — `cur < target` — so a normally-running stream keeps the
|
|
* round-10 extend-only behavior and is never inflated to the approval window.
|
|
*
|
|
* Reading the paused window from the job key (rather than always max-ing against it) is
|
|
* what keeps a normal running run on the short TTL: TTL(jobKey) is only the long approval
|
|
* window while paused; for a running job the job key carries the running TTL, so target
|
|
* stays `run`.
|
|
*
|
|
* KEYS: [chunks, job, steerReceipts, steerReceiptOrder, claimedSteers, steers,
|
|
* parkedSteers, generationEpoch]
|
|
* ARGV: [eventJson, runningTtl, expectCreatedAt | "",
|
|
* deliveredClientSteerId | "", deliveredItemJson | "", nowMs,
|
|
* parkedSteersTtl, generationEpochGraceTtl]
|
|
*/
|
|
const CHUNK_APPEND_LUA =
|
|
'local currentCreatedAt = redis.call("HGET", KEYS[2], "createdAt") ' +
|
|
'if not currentCreatedAt then return 0 end ' +
|
|
'if ARGV[3] ~= "" and currentCreatedAt ~= ARGV[3] then return 0 end ' +
|
|
'local currentStatus = redis.call("HGET", KEYS[2], "status") ' +
|
|
'if currentStatus ~= "running" and currentStatus ~= "requires_action" then return 0 end ' +
|
|
'local retainedEpoch = redis.call("GET", KEYS[8]) ' +
|
|
'if retainedEpoch and retainedEpoch ~= currentCreatedAt then return 0 end ' +
|
|
'local protocolV2 = redis.call("HGET", KEYS[2], "generationProtocolVersion") == "2" ' +
|
|
'local delivered = nil local kept = nil local receipt = nil ' +
|
|
'if ARGV[5] ~= "" then ' +
|
|
'if currentStatus ~= "running" ' +
|
|
'or redis.call("HGET", KEYS[2], "steersClosed") == "1" then return 0 end ' +
|
|
'local deliveredOk, deliveredValue = pcall(cjson.decode, ARGV[5]) ' +
|
|
'if not deliveredOk or type(deliveredValue) ~= "table" or not deliveredValue.steerId then return 0 end ' +
|
|
'delivered = deliveredValue ' +
|
|
'if protocolV2 and delivered.clientSteerId then ' +
|
|
'if ARGV[4] == "" or delivered.clientSteerId ~= ARGV[4] then return 0 end ' +
|
|
'local raw = redis.call("HGET", KEYS[3], ARGV[4]) if not raw then return 0 end ' +
|
|
'local receiptOk, receiptValue = pcall(cjson.decode, raw) ' +
|
|
'if not receiptOk or type(receiptValue) ~= "table" ' +
|
|
'or receiptValue.clientSteerId ~= ARGV[4] ' +
|
|
'or tostring(receiptValue.generationCreatedAt or "") ~= currentCreatedAt ' +
|
|
'or receiptValue.state ~= "claimed" or not receiptValue.item ' +
|
|
'or receiptValue.item.steerId ~= delivered.steerId ' +
|
|
'or receiptValue.item.clientSteerId ~= ARGV[4] then return 0 end ' +
|
|
'receipt = receiptValue ' +
|
|
'elseif protocolV2 and ARGV[4] ~= "" then return 0 end ' +
|
|
'if protocolV2 then ' +
|
|
'local claims = redis.call("LRANGE", KEYS[5], 0, -1) ' +
|
|
'kept = {} local found = false ' +
|
|
'for i = 1, #claims do local ok, item = pcall(cjson.decode, claims[i]) ' +
|
|
'local sameClient = ok and item.clientSteerId == delivered.clientSteerId ' +
|
|
'if not found and ok and item.steerId == delivered.steerId and sameClient then found = true ' +
|
|
'else kept[#kept + 1] = claims[i] end end ' +
|
|
'if not found then return 0 end end ' +
|
|
'end ' +
|
|
'local run = tonumber(ARGV[2]) ' +
|
|
'local target = run ' +
|
|
'local jobTtl = redis.call("TTL", KEYS[2]) ' +
|
|
'if jobTtl < target then redis.call("EXPIRE", KEYS[2], target) ' +
|
|
'elseif jobTtl > target then target = jobTtl end ' +
|
|
'local recoveryTarget = target ' +
|
|
'if redis.call("HGET", KEYS[2], "recoveredSteerId") then ' +
|
|
'recoveryTarget = target + tonumber(ARGV[7]) ' +
|
|
'local pt = redis.call("TTL", KEYS[7]) ' +
|
|
'if pt >= 0 and pt < recoveryTarget then redis.call("EXPIRE", KEYS[7], recoveryTarget) end end ' +
|
|
'local epochTarget = target + tonumber(ARGV[8]) ' +
|
|
'if retainedEpoch then local epochTtl = redis.call("TTL", KEYS[8]) ' +
|
|
'if epochTtl >= 0 and epochTtl < epochTarget then redis.call("EXPIRE", KEYS[8], epochTarget) end ' +
|
|
'else redis.call("SET", KEYS[8], currentCreatedAt, "EX", epochTarget) end ' +
|
|
'if delivered and protocolV2 then local claimTtl = redis.call("PTTL", KEYS[5]) redis.call("DEL", KEYS[5]) ' +
|
|
'if #kept > 0 then redis.call("RPUSH", KEYS[5], unpack(kept)) ' +
|
|
'if claimTtl > 0 then redis.call("PEXPIRE", KEYS[5], claimTtl) end end end ' +
|
|
'redis.call("XADD", KEYS[1], "*", "event", ARGV[1]) ' +
|
|
'if currentStatus == "running" then ' +
|
|
'redis.call("HSET", KEYS[2], "lastActiveAt", ARGV[6]) end ' +
|
|
'local cur = redis.call("TTL", KEYS[1]) ' +
|
|
'if cur < target then redis.call("EXPIRE", KEYS[1], target) end ' +
|
|
'for i = 3, 4 do local rt = redis.call("TTL", KEYS[i]) ' +
|
|
'if rt >= 0 and rt < recoveryTarget then redis.call("EXPIRE", KEYS[i], recoveryTarget) end end ' +
|
|
'for i = 5, 6 do local qt = redis.call("TTL", KEYS[i]) ' +
|
|
'if qt >= 0 and qt < target then redis.call("EXPIRE", KEYS[i], target) end end ' +
|
|
'if receipt then receipt.item = delivered receipt.state = "delivered" ' +
|
|
'redis.call("HSET", KEYS[3], ARGV[4], cjson.encode(receipt)) end ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Batched CHUNK_APPEND_LUA for plain streaming deltas: identical generation/status/epoch
|
|
* guards and extend-only TTL housekeeping, evaluated once per batch, with one XADD per
|
|
* event. Steer-delivery settlement is deliberately absent — an append carrying a steer
|
|
* receipt is a barrier and stays on the per-event script.
|
|
*
|
|
* KEYS: [chunks, job, steerReceipts, steerReceiptOrder, claimedSteers, steers,
|
|
* parkedSteers, generationEpoch]
|
|
* ARGV: [runningTtl, expectCreatedAt | "", nowMs, parkedSteersTtl,
|
|
* generationEpochGraceTtl, eventJson...]
|
|
*/
|
|
const CHUNK_APPEND_BATCH_LUA =
|
|
'local currentCreatedAt = redis.call("HGET", KEYS[2], "createdAt") ' +
|
|
'if not currentCreatedAt then return 0 end ' +
|
|
'if ARGV[2] ~= "" and currentCreatedAt ~= ARGV[2] then return 0 end ' +
|
|
'local currentStatus = redis.call("HGET", KEYS[2], "status") ' +
|
|
'if currentStatus ~= "running" and currentStatus ~= "requires_action" then return 0 end ' +
|
|
'local retainedEpoch = redis.call("GET", KEYS[8]) ' +
|
|
'if retainedEpoch and retainedEpoch ~= currentCreatedAt then return 0 end ' +
|
|
'local run = tonumber(ARGV[1]) ' +
|
|
'local target = run ' +
|
|
'local jobTtl = redis.call("TTL", KEYS[2]) ' +
|
|
'if jobTtl < target then redis.call("EXPIRE", KEYS[2], target) ' +
|
|
'elseif jobTtl > target then target = jobTtl end ' +
|
|
'local recoveryTarget = target ' +
|
|
'if redis.call("HGET", KEYS[2], "recoveredSteerId") then ' +
|
|
'recoveryTarget = target + tonumber(ARGV[4]) ' +
|
|
'local pt = redis.call("TTL", KEYS[7]) ' +
|
|
'if pt >= 0 and pt < recoveryTarget then redis.call("EXPIRE", KEYS[7], recoveryTarget) end end ' +
|
|
'local epochTarget = target + tonumber(ARGV[5]) ' +
|
|
'if retainedEpoch then local epochTtl = redis.call("TTL", KEYS[8]) ' +
|
|
'if epochTtl >= 0 and epochTtl < epochTarget then redis.call("EXPIRE", KEYS[8], epochTarget) end ' +
|
|
'else redis.call("SET", KEYS[8], currentCreatedAt, "EX", epochTarget) end ' +
|
|
'for i = 6, #ARGV do redis.call("XADD", KEYS[1], "*", "event", ARGV[i]) end ' +
|
|
'if currentStatus == "running" then ' +
|
|
'redis.call("HSET", KEYS[2], "lastActiveAt", ARGV[3]) end ' +
|
|
'local cur = redis.call("TTL", KEYS[1]) ' +
|
|
'if cur < target then redis.call("EXPIRE", KEYS[1], target) end ' +
|
|
'for i = 3, 4 do local rt = redis.call("TTL", KEYS[i]) ' +
|
|
'if rt >= 0 and rt < recoveryTarget then redis.call("EXPIRE", KEYS[i], recoveryTarget) end end ' +
|
|
'for i = 5, 6 do local qt = redis.call("TTL", KEYS[i]) ' +
|
|
'if qt >= 0 and qt < target then redis.call("EXPIRE", KEYS[i], target) end end ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Persist the run-step timeline with the same paused-window TTL as the chunk stream.
|
|
* `saveRunSteps` SETs (overwrites) the whole array, so unlike the chunk append there's no
|
|
* prior key TTL worth preserving — but the write must still extend to the APPROVAL window
|
|
* when the job is paused (`status == "requires_action"`). Otherwise a run-step save that
|
|
* lands at/after a fast pause resets the key to the short running TTL, and a reload of a
|
|
* still-live approval after that window loses the tool/run-step timeline even though the
|
|
* approval remains resumable. Reads the paused window from the job key (which
|
|
* `transitionStatus` set); a normally-running job keeps the short running TTL. The write
|
|
* also requires either an active status or the exact terminal host-action marker. That
|
|
* narrow terminal window lets a draining provider owner commit its final evidence before
|
|
* the host callback acknowledges; after acknowledgement, late writes are fenced out.
|
|
*
|
|
* KEYS: [runSteps, job]
|
|
* ARGV: [runStepsJson, runningTtl, expectCreatedAt | ""]
|
|
*/
|
|
const RUNSTEPS_SAVE_LUA =
|
|
'local currentCreatedAt = redis.call("HGET", KEYS[2], "createdAt") ' +
|
|
'if not currentCreatedAt then return 0 end ' +
|
|
'if ARGV[3] ~= "" and currentCreatedAt ~= ARGV[3] then return 0 end ' +
|
|
'local currentStatus = redis.call("HGET", KEYS[2], "status") ' +
|
|
'local terminalHostActionPending = redis.call("HGET", KEYS[2], "terminalHostActionPending") == "1" or redis.call("HGET", KEYS[2], "detachedAgentEventTerminalHostActionPending") == "1" ' +
|
|
'if currentStatus ~= "running" and currentStatus ~= "requires_action" and not terminalHostActionPending then return 0 end ' +
|
|
'redis.call("SET", KEYS[1], ARGV[1]) ' +
|
|
'local run = tonumber(ARGV[2]) ' +
|
|
'local target = run ' +
|
|
'if currentStatus == "requires_action" or terminalHostActionPending then ' +
|
|
'local jt = redis.call("TTL", KEYS[2]) ' +
|
|
'if jt > target then target = jt end ' +
|
|
'end ' +
|
|
'redis.call("EXPIRE", KEYS[1], target) ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Clear same-slot content unless the stream already belongs to a replacement.
|
|
* A missing job is safe: terminal deletion may remove the hash before this
|
|
* best-effort cache cleanup, and Redis executes the check + deletes atomically.
|
|
*
|
|
* KEYS: [chunks, runSteps, job]
|
|
* ARGV: [expectCreatedAt | ""]
|
|
*/
|
|
const CONTENT_CLEAR_LUA =
|
|
'if ARGV[1] ~= "" and redis.call("EXISTS", KEYS[3]) == 1 and redis.call("HGET", KEYS[3], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'redis.call("DEL", KEYS[1], KEYS[2]) ' +
|
|
'return 1';
|
|
|
|
const CHUNKS_READ_LUA =
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return {} end ' +
|
|
'return redis.call("XRANGE", KEYS[2], "-", "+")';
|
|
|
|
const RUNSTEPS_READ_LUA =
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return false end ' +
|
|
'return redis.call("GET", KEYS[2])';
|
|
|
|
/**
|
|
* Atomically append a steer, guarded on the job hash still being `running`
|
|
* AND the queue not being closed by a terminal drain (`steersClosed` field,
|
|
* set by {@link STEER_CLOSE_DRAIN_LUA}, cleared on createJob). Both keys share
|
|
* the {streamId} hash tag (single slot), so the script is atomic on cluster
|
|
* too — a steer can never land on a completed/aborted/finalizing job, and the
|
|
* depth cap can't be raced past by concurrent enqueues.
|
|
*
|
|
* KEYS: [job, steers, receipts]
|
|
* ARGV: [itemJson, ttl, maxDepth]
|
|
* Returns: new depth, -1 (not running / closed), or -2 (queue full)
|
|
*/
|
|
const STEER_ENQUEUE_LUA =
|
|
'if ARGV[4] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[4] then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "steersClosed") == "1" then return -1 end ' +
|
|
'if redis.call("LLEN", KEYS[2]) >= tonumber(ARGV[3]) then return -2 end ' +
|
|
'redis.call("RPUSH", KEYS[2], ARGV[1]) ' +
|
|
'redis.call("EXPIRE", KEYS[2], tonumber(ARGV[2])) ' +
|
|
'return redis.call("LLEN", KEYS[2])';
|
|
|
|
/** Atomic capability-normalized enqueue for callers without a receipt id.
|
|
* The persisted item and its queue position are returned from the same Lua
|
|
* step, eliminating the legacy enqueue→arm failure window. */
|
|
const STEER_ENQUEUE_VERSIONED_LUA =
|
|
'if ARGV[4] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[4] then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "steersClosed") == "1" then return -1 end ' +
|
|
'if redis.call("LLEN", KEYS[2]) >= tonumber(ARGV[3]) then return -2 end ' +
|
|
'local item = cjson.decode(ARGV[1]) ' +
|
|
'if item.quotes then local qexec = redis.call("HGET", KEYS[1], "steerQuotesExecutionId") if not qexec or qexec == "" or qexec ~= redis.call("HGET", KEYS[1], "providerExecutionId") then item.quotes = nil end end ' +
|
|
'if ARGV[5] == "1" then item.preemptRevision = 1 ' +
|
|
'if redis.call("HGET", KEYS[1], "preemptCapable") == "1" then item.preempt = true end end ' +
|
|
'local itemJson = cjson.encode(item) ' +
|
|
'redis.call("RPUSH", KEYS[2], itemJson) ' +
|
|
'redis.call("EXPIRE", KEYS[2], tonumber(ARGV[2])) ' +
|
|
'return cjson.encode({ item = item, position = redis.call("LLEN", KEYS[2]) })';
|
|
|
|
/** Lost-ACK-safe enqueue. The receipt hash deliberately outlives both the
|
|
* queue and job hash; all three keys share the stream hash slot. Existing
|
|
* receipts win before live-job guards, so retry after drain/terminal/replace
|
|
* returns the original ACK instead of re-injecting. */
|
|
const STEER_ENQUEUE_RECEIPT_LUA =
|
|
'local existing = redis.call("HGET", KEYS[3], ARGV[5]) ' +
|
|
'if existing then ' +
|
|
'local receipt = cjson.decode(existing) ' +
|
|
'local epoch = redis.call("HGET", KEYS[1], "createdAt") ' +
|
|
'local status = redis.call("HGET", KEYS[1], "status") ' +
|
|
'local sameActive = redis.call("HGET", KEYS[1], "generationProtocolVersion") == "2" ' +
|
|
'and epoch and tostring(receipt.generationCreatedAt or "") == epoch ' +
|
|
'and (status == "running" or status == "requires_action") ' +
|
|
'if receipt.state == "queued" then ' +
|
|
'if not sameActive then receipt.state = "leftover" existing = cjson.encode(receipt) ' +
|
|
'redis.call("HSET", KEYS[3], ARGV[5], existing) else ' +
|
|
'local found = false local queued = redis.call("LRANGE", KEYS[2], 0, -1) ' +
|
|
'for i = 1, #queued do local ok, candidate = pcall(cjson.decode, queued[i]) ' +
|
|
'if ok and candidate.clientSteerId == ARGV[5] then found = true break end end ' +
|
|
'if not found then receipt.state = "leftover" existing = cjson.encode(receipt) ' +
|
|
'redis.call("HSET", KEYS[3], ARGV[5], existing) end end ' +
|
|
'elseif receipt.state == "claimed" and not sameActive then ' +
|
|
'receipt.state = "leftover" existing = cjson.encode(receipt) ' +
|
|
'redis.call("HSET", KEYS[3], ARGV[5], existing) end ' +
|
|
'return existing end ' +
|
|
'if redis.call("HGET", KEYS[1], "generationProtocolVersion") ~= "2" then ' +
|
|
'if ARGV[4] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[4] then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "steersClosed") == "1" then return -1 end ' +
|
|
'if redis.call("LLEN", KEYS[2]) >= tonumber(ARGV[3]) then return -2 end ' +
|
|
'local legacyItem = cjson.decode(ARGV[1]) ' +
|
|
'if legacyItem.quotes then local qexec = redis.call("HGET", KEYS[1], "steerQuotesExecutionId") ' +
|
|
'if not qexec or qexec == "" or qexec ~= redis.call("HGET", KEYS[1], "providerExecutionId") then legacyItem.quotes = nil end end ' +
|
|
'if ARGV[7] == "1" then legacyItem.preemptRevision = 1 ' +
|
|
'if redis.call("HGET", KEYS[1], "preemptCapable") == "1" then legacyItem.preempt = true end end ' +
|
|
'redis.call("RPUSH", KEYS[2], cjson.encode(legacyItem)) ' +
|
|
'redis.call("EXPIRE", KEYS[2], tonumber(ARGV[2])) ' +
|
|
'return cjson.encode({ item = legacyItem, position = redis.call("LLEN", KEYS[2]) }) end ' +
|
|
'if ARGV[4] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[4] then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "steersClosed") == "1" then return -1 end ' +
|
|
'if redis.call("LLEN", KEYS[2]) >= tonumber(ARGV[3]) then return -2 end ' +
|
|
'if redis.call("ZCARD", KEYS[4]) >= tonumber(ARGV[9]) then return -3 end ' +
|
|
'local item = cjson.decode(ARGV[1]) ' +
|
|
'if item.quotes then local qexec = redis.call("HGET", KEYS[1], "steerQuotesExecutionId") if not qexec or qexec == "" or qexec ~= redis.call("HGET", KEYS[1], "providerExecutionId") then item.quotes = nil end end ' +
|
|
'if ARGV[7] == "1" then ' +
|
|
'item.preemptRevision = 1 ' +
|
|
'if redis.call("HGET", KEYS[1], "preemptCapable") == "1" then item.preempt = true end ' +
|
|
'end ' +
|
|
'local itemJson = cjson.encode(item) ' +
|
|
'redis.call("RPUSH", KEYS[2], itemJson) ' +
|
|
'redis.call("EXPIRE", KEYS[2], tonumber(ARGV[2])) ' +
|
|
'local receipt = cjson.decode(ARGV[6]) ' +
|
|
'receipt.item = item receipt.position = redis.call("LLEN", KEYS[2]) receipt.state = "queued" ' +
|
|
'local receiptJson = cjson.encode(receipt) ' +
|
|
'redis.call("HSET", KEYS[3], ARGV[5], receiptJson) ' +
|
|
'redis.call("ZADD", KEYS[4], item.createdAt, ARGV[5]) ' +
|
|
'local receiptTtl = tonumber(ARGV[8]) ' +
|
|
'for i = 3, 4 do local existingTtl = redis.call("TTL", KEYS[i]) ' +
|
|
'if existingTtl == -1 or (existingTtl >= 0 and existingTtl < receiptTtl) then ' +
|
|
'redis.call("EXPIRE", KEYS[i], receiptTtl) end end ' +
|
|
'return receiptJson';
|
|
|
|
/** Receipt read with lazy repair for terminal/reaper/replacement scripts that
|
|
* delete the queue directly. Generation identity distinguishes a same-run
|
|
* older drain from a replacement that discarded an undrained predecessor. */
|
|
const STEER_RECEIPT_GET_LUA =
|
|
'local raw = redis.call("HGET", KEYS[1], ARGV[1]) ' +
|
|
'if not raw then return false end ' +
|
|
'local receipt = cjson.decode(raw) ' +
|
|
'local epoch = redis.call("HGET", KEYS[2], "createdAt") ' +
|
|
'local status = redis.call("HGET", KEYS[2], "status") ' +
|
|
'local sameActive = redis.call("HGET", KEYS[2], "generationProtocolVersion") == "2" ' +
|
|
'and epoch and tostring(receipt.generationCreatedAt or "") == epoch ' +
|
|
'and (status == "running" or status == "requires_action") ' +
|
|
'if receipt.state == "claimed" then ' +
|
|
'if not sameActive then receipt.state = "leftover" raw = cjson.encode(receipt) ' +
|
|
'redis.call("HSET", KEYS[1], ARGV[1], raw) end return raw end ' +
|
|
'if receipt.state ~= "queued" then return raw end ' +
|
|
'if not sameActive then receipt.state = "leftover" raw = cjson.encode(receipt) ' +
|
|
'redis.call("HSET", KEYS[1], ARGV[1], raw) return raw end ' +
|
|
'local items = redis.call("LRANGE", KEYS[3], 0, -1) ' +
|
|
'for i = 1, #items do local ok, item = pcall(cjson.decode, items[i]) ' +
|
|
'if ok and item.clientSteerId == ARGV[1] then return raw end end ' +
|
|
'receipt.state = "leftover" ' +
|
|
'raw = cjson.encode(receipt) redis.call("HSET", KEYS[1], ARGV[1], raw) return raw';
|
|
|
|
/**
|
|
* Atomic take-all: read the whole queue FIFO and delete the key in one step,
|
|
* so two concurrent drains can never both deliver the same steer. When an
|
|
* expected `createdAt` is supplied, the drain is additionally guarded against
|
|
* job replacement INSIDE the script — a stale run's hook can never consume a
|
|
* replacement job's queue (the check-then-drain would otherwise race
|
|
* `createJob`).
|
|
*
|
|
* KEYS: [job, steers, claimedSteers, receipts, receiptOrder]
|
|
* ARGV: [expectedCreatedAt or "", runningTtl]
|
|
* Returns: array of item JSON strings (possibly empty)
|
|
*/
|
|
const STEER_DRAIN_LUA =
|
|
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return {} end ' +
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" ' +
|
|
'or redis.call("HGET", KEYS[1], "steersClosed") == "1" then return {} end ' +
|
|
'local currentCreatedAt = redis.call("HGET", KEYS[1], "createdAt") ' +
|
|
'local items = redis.call("LRANGE", KEYS[2], 0, -1) ' +
|
|
'if redis.call("HGET", KEYS[1], "generationProtocolVersion") ~= "2" then ' +
|
|
'redis.call("DEL", KEYS[2]) return items end ' +
|
|
'local decodedItems = {} local decodedReceipts = {} ' +
|
|
'for i = 1, #items do ' +
|
|
'local ok, item = pcall(cjson.decode, items[i]) ' +
|
|
'if not ok or type(item) ~= "table" or not item.steerId then ' +
|
|
'return redis.error_reply("invalid steer queue item") end ' +
|
|
'decodedItems[i] = item ' +
|
|
'if item.clientSteerId then ' +
|
|
'local raw = redis.call("HGET", KEYS[4], item.clientSteerId) ' +
|
|
'if not raw then return redis.error_reply("missing steer receipt") end ' +
|
|
'local receiptOk, receipt = pcall(cjson.decode, raw) ' +
|
|
'if not receiptOk or type(receipt) ~= "table" ' +
|
|
'or receipt.clientSteerId ~= item.clientSteerId ' +
|
|
'or tostring(receipt.generationCreatedAt or "") ~= currentCreatedAt ' +
|
|
'or receipt.state ~= "queued" or not receipt.item ' +
|
|
'or receipt.item.steerId ~= item.steerId ' +
|
|
'or receipt.item.clientSteerId ~= item.clientSteerId then ' +
|
|
'return redis.error_reply("invalid steer receipt") end ' +
|
|
'decodedReceipts[i] = receipt ' +
|
|
'end end ' +
|
|
'if #items > 0 then redis.call("RPUSH", KEYS[3], unpack(items)) redis.call("EXPIRE", KEYS[3], ARGV[2]) end ' +
|
|
'for i = 1, #items do local item = decodedItems[i] local receipt = decodedReceipts[i] ' +
|
|
'if receipt then receipt.item = item receipt.state = "claimed" ' +
|
|
'redis.call("HSET", KEYS[4], item.clientSteerId, cjson.encode(receipt)) end end ' +
|
|
'for i = 4, 5 do local ttl = redis.call("TTL", KEYS[i]) ' +
|
|
'if ttl >= 0 and ttl < tonumber(ARGV[2]) then redis.call("EXPIRE", KEYS[i], ARGV[2]) end end ' +
|
|
'redis.call("DEL", KEYS[2]) ' +
|
|
'return items';
|
|
|
|
/** Roll back claimed items whose durable applied-part write failed. New
|
|
* enqueues may have landed after the drain, so the failed accepted batch is
|
|
* prepended in its original order rather than replacing the live queue. */
|
|
const STEER_RESTORE_CLAIMED_LUA =
|
|
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "steersClosed") == "1" then return 0 end ' +
|
|
'local currentCreatedAt = redis.call("HGET", KEYS[1], "createdAt") ' +
|
|
'local incoming = cjson.decode(ARGV[2]) local incomingIds = {} ' +
|
|
'for i = 1, #incoming do if incoming[i].steerId then incomingIds[incoming[i].steerId] = true end end ' +
|
|
'if redis.call("HGET", KEYS[1], "generationProtocolVersion") ~= "2" then ' +
|
|
'local present = {} local current = redis.call("LRANGE", KEYS[2], 0, -1) ' +
|
|
'for i = 1, #current do local ok, item = pcall(cjson.decode, current[i]) ' +
|
|
'if not ok or type(item) ~= "table" or not item.steerId then return 0 end present[item.steerId] = true end ' +
|
|
'for i = #incoming, 1, -1 do local item = incoming[i] if item.steerId and not present[item.steerId] then ' +
|
|
'redis.call("LPUSH", KEYS[2], cjson.encode(item)) end end ' +
|
|
'if redis.call("EXISTS", KEYS[2]) == 1 then redis.call("EXPIRE", KEYS[2], ARGV[3]) end return 1 end ' +
|
|
'local claims = redis.call("LRANGE", KEYS[3], 0, -1) local keptClaims = {} local matched = 0 ' +
|
|
'for i = 1, #claims do local ok, item = pcall(cjson.decode, claims[i]) ' +
|
|
'if ok and item.steerId and incomingIds[item.steerId] then matched = matched + 1 ' +
|
|
'else keptClaims[#keptClaims + 1] = claims[i] end end ' +
|
|
'if matched ~= #incoming then return 0 end ' +
|
|
'local receiptByClient = {} ' +
|
|
'for i = 1, #incoming do local item = incoming[i] if item.clientSteerId then ' +
|
|
'local raw = redis.call("HGET", KEYS[4], item.clientSteerId) if not raw then return 0 end ' +
|
|
'local ok, receipt = pcall(cjson.decode, raw) ' +
|
|
'if not ok or type(receipt) ~= "table" ' +
|
|
'or receipt.clientSteerId ~= item.clientSteerId ' +
|
|
'or tostring(receipt.generationCreatedAt or "") ~= currentCreatedAt ' +
|
|
'or receipt.state ~= "claimed" or not receipt.item ' +
|
|
'or receipt.item.steerId ~= item.steerId ' +
|
|
'or receipt.item.clientSteerId ~= item.clientSteerId then return 0 end ' +
|
|
'receiptByClient[item.clientSteerId] = receipt end end ' +
|
|
'local present = {} local current = redis.call("LRANGE", KEYS[2], 0, -1) ' +
|
|
'for i = 1, #current do local ok, item = pcall(cjson.decode, current[i]) ' +
|
|
'if not ok or type(item) ~= "table" or not item.steerId then return 0 end ' +
|
|
'present[item.steerId] = true end ' +
|
|
'local claimsTtl = redis.call("PTTL", KEYS[3]) redis.call("DEL", KEYS[3]) ' +
|
|
'if #keptClaims > 0 then redis.call("RPUSH", KEYS[3], unpack(keptClaims)) ' +
|
|
'if claimsTtl > 0 then redis.call("PEXPIRE", KEYS[3], claimsTtl) end end ' +
|
|
'for i = #incoming, 1, -1 do local item = incoming[i] ' +
|
|
'if item.steerId and not present[item.steerId] then ' +
|
|
'local itemJson = cjson.encode(item) redis.call("LPUSH", KEYS[2], itemJson) ' +
|
|
'if item.clientSteerId then local receipt = receiptByClient[item.clientSteerId] ' +
|
|
'receipt.item = item receipt.state = "queued" ' +
|
|
'redis.call("HSET", KEYS[4], item.clientSteerId, cjson.encode(receipt)) end end end ' +
|
|
'if redis.call("EXISTS", KEYS[2]) == 1 then redis.call("EXPIRE", KEYS[2], ARGV[3]) end ' +
|
|
'return 1';
|
|
|
|
const STEER_PEEK_LUA =
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return {} end ' +
|
|
'return redis.call("LRANGE", KEYS[2], 0, -1)';
|
|
|
|
const STEER_PEEK_CLAIMED_LUA =
|
|
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return {} end ' +
|
|
'if redis.call("HGET", KEYS[1], "generationProtocolVersion") ~= "2" then return {} end ' +
|
|
'return redis.call("LRANGE", KEYS[2], 0, -1)';
|
|
|
|
/**
|
|
* Remove ONE queued steer by id without disturbing the rest: the list is
|
|
* rebuilt atomically, so a concurrent drain either delivers the steer or the
|
|
* cancel wins — never a torn queue. The generation fence and receipt
|
|
* validation happen before either the queue or receipt is changed. The list
|
|
* TTL survives the rebuild.
|
|
*
|
|
* KEYS: [job, steers, receipts]
|
|
* ARGV: [steerId, expectedCreatedAt or ""]
|
|
* Returns: 1 when removed, 0 when not found
|
|
*/
|
|
const STEER_REMOVE_LUA =
|
|
'if ARGV[2] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[2] then return 0 end ' +
|
|
'local currentCreatedAt = redis.call("HGET", KEYS[1], "createdAt") ' +
|
|
'local protocolV2 = redis.call("HGET", KEYS[1], "generationProtocolVersion") == "2" ' +
|
|
'local items = redis.call("LRANGE", KEYS[2], 0, -1) ' +
|
|
'if #items == 0 then return 0 end ' +
|
|
'local target = 0 local targetItem = nil local targetReceipt = nil ' +
|
|
'for i = 1, #items do ' +
|
|
'local ok, item = pcall(cjson.decode, items[i]) ' +
|
|
'if not ok or type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" then ' +
|
|
'return redis.error_reply("invalid steer queue item") end ' +
|
|
'if item.steerId == ARGV[1] then ' +
|
|
'if target ~= 0 then return redis.error_reply("duplicate steer id") end ' +
|
|
'target = i targetItem = item ' +
|
|
'if protocolV2 and item.clientSteerId then ' +
|
|
'if type(item.clientSteerId) ~= "string" or item.clientSteerId == "" or not currentCreatedAt then ' +
|
|
'return redis.error_reply("invalid steer receipt") end ' +
|
|
'local raw = redis.call("HGET", KEYS[3], item.clientSteerId) ' +
|
|
'if not raw then return redis.error_reply("missing steer receipt") end ' +
|
|
'local receiptOk, receipt = pcall(cjson.decode, raw) ' +
|
|
'if not receiptOk or type(receipt) ~= "table" ' +
|
|
'or receipt.clientSteerId ~= item.clientSteerId ' +
|
|
'or tostring(receipt.generationCreatedAt or "") ~= currentCreatedAt ' +
|
|
'or receipt.state ~= "queued" or type(receipt.item) ~= "table" ' +
|
|
'or receipt.item.steerId ~= item.steerId ' +
|
|
'or receipt.item.clientSteerId ~= item.clientSteerId then ' +
|
|
'return redis.error_reply("invalid steer receipt") end targetReceipt = receipt end end ' +
|
|
'end ' +
|
|
'if target == 0 then return 0 end ' +
|
|
'local ttl = redis.call("PTTL", KEYS[2]) ' +
|
|
'redis.call("DEL", KEYS[2]) ' +
|
|
'local kept = {} for i = 1, #items do if i ~= target then kept[#kept + 1] = items[i] end end ' +
|
|
'if #kept > 0 then ' +
|
|
'redis.call("RPUSH", KEYS[2], unpack(kept)) ' +
|
|
'if ttl > 0 then redis.call("PEXPIRE", KEYS[2], ttl) end ' +
|
|
'end ' +
|
|
'if targetReceipt then targetReceipt.item = targetItem targetReceipt.state = "cancelled" ' +
|
|
'redis.call("HSET", KEYS[3], targetItem.clientSteerId, cjson.encode(targetReceipt)) end ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Escalate ONE queued steer to an interrupt IN PLACE: decode the whole item,
|
|
* set `preempt`, and LSET it back at its index, so its FIFO position is
|
|
* untouched (the entire queue drains at the seal, in order). Guarded like
|
|
* {@link STEER_ENQUEUE_LUA}: a non-running job, closed queue, or generation
|
|
* mismatch refuses, so an arm racing a pause cannot leak into the resumed
|
|
* segment and a stale request can never arm a replacement run's steer. The
|
|
* owner's LIVE `preemptCapable` is part of the same atomic predicate — a HITL
|
|
* resume on a rolling deploy rewrites it for the SAME generation, so a value
|
|
* the caller read earlier is not trustworthy.
|
|
*
|
|
* KEYS: [job, steers, receipts]
|
|
* ARGV: [steerId, expectedCreatedAt or ""]
|
|
* Returns: the updated item JSON, 0 not found / non-running / closed / fenced,
|
|
* or -1 when the owner cannot seal.
|
|
*/
|
|
const STEER_ARM_LUA =
|
|
'if ARGV[2] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[2] then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "status") ~= "running" then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "steersClosed") == "1" then return 0 end ' +
|
|
'local protocolV2 = redis.call("HGET", KEYS[1], "generationProtocolVersion") == "2" ' +
|
|
'local currentCreatedAt = redis.call("HGET", KEYS[1], "createdAt") ' +
|
|
'local items = redis.call("LRANGE", KEYS[2], 0, -1) ' +
|
|
'local target = 0 local item = nil ' +
|
|
'for i = 1, #items do ' +
|
|
'local decoded, candidate = pcall(cjson.decode, items[i]) ' +
|
|
'if not decoded or type(candidate) ~= "table" or type(candidate.steerId) ~= "string" ' +
|
|
'or candidate.steerId == "" then return redis.error_reply("invalid steer queue item") end ' +
|
|
'if candidate.steerId == ARGV[1] then ' +
|
|
'if target ~= 0 then return redis.error_reply("duplicate steer id") end ' +
|
|
'target = i item = candidate end ' +
|
|
'end ' +
|
|
'if target == 0 then return 0 end ' +
|
|
'if redis.call("HGET", KEYS[1], "preemptCapable") ~= "1" then return -1 end ' +
|
|
'local receipt = nil ' +
|
|
'if protocolV2 and item.clientSteerId then ' +
|
|
'if type(item.clientSteerId) ~= "string" or item.clientSteerId == "" or not currentCreatedAt then ' +
|
|
'return redis.error_reply("invalid steer receipt") end ' +
|
|
'local receiptJson = redis.call("HGET", KEYS[3], item.clientSteerId) ' +
|
|
'if not receiptJson then return redis.error_reply("missing steer receipt") end ' +
|
|
'local receiptOk, receiptValue = pcall(cjson.decode, receiptJson) ' +
|
|
'if not receiptOk or type(receiptValue) ~= "table" ' +
|
|
'or receiptValue.clientSteerId ~= item.clientSteerId ' +
|
|
'or tostring(receiptValue.generationCreatedAt or "") ~= currentCreatedAt ' +
|
|
'or receiptValue.state ~= "queued" or not receiptValue.item ' +
|
|
'or receiptValue.item.steerId ~= item.steerId ' +
|
|
'or receiptValue.item.clientSteerId ~= item.clientSteerId then ' +
|
|
'return redis.error_reply("invalid steer receipt") end receipt = receiptValue end ' +
|
|
'item.preemptRevision = tonumber(item.preemptRevision or 0) + 1 ' +
|
|
'item.preempt = true ' +
|
|
'redis.call("LSET", KEYS[2], target - 1, cjson.encode(item)) ' +
|
|
'if receipt then receipt.item = item ' +
|
|
'redis.call("HSET", KEYS[3], item.clientSteerId, cjson.encode(receipt)) end ' +
|
|
'return cjson.encode(item)';
|
|
|
|
/** Downgrade durable interrupt labels during a capable→incapable owner
|
|
* handover. Capability and queue edits share one Lua transaction, so a later
|
|
* capable resume cannot race a stale cleanup from the previous owner. */
|
|
const STEER_DOWNGRADE_PREEMPTS_LUA =
|
|
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return -1 end ' +
|
|
'if redis.call("EXISTS", KEYS[1]) == 0 then return -1 end ' +
|
|
'if redis.call("HGET", KEYS[1], "preemptCapable") == "1" then return -1 end ' +
|
|
'local protocolV2 = redis.call("HGET", KEYS[1], "generationProtocolVersion") == "2" ' +
|
|
'local items = redis.call("LRANGE", KEYS[2], 0, -1) ' +
|
|
'local decodedItems = {} local decodedReceipts = {} ' +
|
|
'for i = 1, #items do ' +
|
|
'local decoded, item = pcall(cjson.decode, items[i]) ' +
|
|
'if decoded and item.preempt == true then ' +
|
|
'decodedItems[i] = item ' +
|
|
'if protocolV2 and item.clientSteerId then local receiptJson = redis.call("HGET", KEYS[3], item.clientSteerId) ' +
|
|
'if not receiptJson then return redis.error_reply("missing steer receipt") end ' +
|
|
'local receiptOk, receipt = pcall(cjson.decode, receiptJson) ' +
|
|
'local epoch = redis.call("HGET", KEYS[1], "createdAt") ' +
|
|
'if not receiptOk or type(receipt) ~= "table" ' +
|
|
'or receipt.clientSteerId ~= item.clientSteerId ' +
|
|
'or tostring(receipt.generationCreatedAt or "") ~= epoch ' +
|
|
'or receipt.state ~= "queued" or not receipt.item ' +
|
|
'or receipt.item.steerId ~= item.steerId ' +
|
|
'or receipt.item.clientSteerId ~= item.clientSteerId then ' +
|
|
'return redis.error_reply("invalid steer receipt") end decodedReceipts[i] = receipt end end end ' +
|
|
'local changed = {} ' +
|
|
'for i = 1, #items do local item = decodedItems[i] ' +
|
|
'if item then ' +
|
|
'item.preempt = nil ' +
|
|
'item.preemptRevision = tonumber(item.preemptRevision or 0) + 1 ' +
|
|
'redis.call("LSET", KEYS[2], i - 1, cjson.encode(item)) ' +
|
|
'local receipt = decodedReceipts[i] if receipt then receipt.item = item ' +
|
|
'redis.call("HSET", KEYS[3], item.clientSteerId, cjson.encode(receipt)) end ' +
|
|
'changed[#changed + 1] = cjson.encode(item) ' +
|
|
'end ' +
|
|
'end ' +
|
|
'return changed';
|
|
|
|
/**
|
|
* Owner-gated replay read for parked steers. It intentionally does not delete:
|
|
* createJob only leases the exact recovered item while its deterministic next
|
|
* turn is active. Durable user-message persistence commits the removal, so a
|
|
* failed startup or lost status response cannot erase the only recovery copy.
|
|
*
|
|
* KEYS: [parkedSteers, job]
|
|
* ARGV: [ownerUserId, ownerTenantId | "", requestedProtocolVersion]
|
|
* Returns: [parked payload JSON, protocol], '' when not the owner, or nil
|
|
*/
|
|
const CLAIM_PARKED_LUA =
|
|
'local v = redis.call("GET", KEYS[1]) ' +
|
|
'if not v then return v end ' +
|
|
'local function isDenseArray(value) if type(value) ~= "table" then return false end ' +
|
|
'local count = 0 for key, _ in pairs(value) do ' +
|
|
'if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then return false end count = count + 1 end ' +
|
|
'return count == #value end ' +
|
|
'local ok, parked = pcall(cjson.decode, v) ' +
|
|
'if not ok or type(parked) ~= "table" or type(parked.userId) ~= "string" or parked.userId == "" ' +
|
|
'or not isDenseArray(parked.steers) or #parked.steers == 0 then return "" end ' +
|
|
'if parked.generationProtocolVersion and parked.generationProtocolVersion ~= 1 ' +
|
|
'and parked.generationProtocolVersion ~= 2 then return "" end ' +
|
|
'for i = 1, #parked.steers do local item = parked.steers[i] ' +
|
|
'if type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" ' +
|
|
'or (item.clientSteerId and (type(item.clientSteerId) ~= "string" or item.clientSteerId == "")) ' +
|
|
'or (item.text and type(item.text) ~= "string") ' +
|
|
'or (item.createdAt and (type(item.createdAt) ~= "number" or item.createdAt < 0)) ' +
|
|
'or (item.recoveringCreatedAt and (type(item.recoveringCreatedAt) ~= "number" or item.recoveringCreatedAt < 0)) then return "" end end ' +
|
|
'if parked.userId ~= ARGV[1] then return "" end ' +
|
|
'if parked.tenantId and parked.tenantId ~= ARGV[2] then return "" end ' +
|
|
'local createdAt = redis.call("HGET", KEYS[2], "createdAt") ' +
|
|
'local status = redis.call("HGET", KEYS[2], "status") ' +
|
|
'local recoveredSteerId = redis.call("HGET", KEYS[2], "recoveredSteerId") ' +
|
|
'local jobTenantId = redis.call("HGET", KEYS[2], "tenantId") ' +
|
|
'local activeRecovery = createdAt and (status == "running" or status == "requires_action") ' +
|
|
'and redis.call("HGET", KEYS[2], "generationProtocolVersion") == "2" ' +
|
|
'and recoveredSteerId and recoveredSteerId ~= "" ' +
|
|
'and redis.call("HGET", KEYS[2], "userId") == ARGV[1] ' +
|
|
'and (not jobTenantId or jobTenantId == ARGV[2]) ' +
|
|
'local visible = {} local leased = {} for i = 1, #parked.steers do local item = parked.steers[i] ' +
|
|
'if activeRecovery and item.steerId == recoveredSteerId ' +
|
|
'and tostring(item.recoveringCreatedAt or "") == createdAt then leased[#leased + 1] = item ' +
|
|
'else item.recoveringCreatedAt = nil visible[#visible + 1] = item end end ' +
|
|
'local protocol = parked.generationProtocolVersion == 2 and ARGV[3] == "2" and 2 or 1 ' +
|
|
'if protocol == 1 and #leased == 0 then parked.generationProtocolVersion = 1 ' +
|
|
'parked.steers = visible redis.call("DEL", KEYS[1]) ' +
|
|
'return { cjson.encode(parked), "1" } end ' +
|
|
'if #visible == 0 then return "" end parked.steers = visible ' +
|
|
'if protocol == 1 then local ttl = redis.call("PTTL", KEYS[1]) ' +
|
|
'local retained = { userId = parked.userId, generationProtocolVersion = 2, steers = leased } ' +
|
|
'if parked.tenantId then retained.tenantId = parked.tenantId end ' +
|
|
'redis.call("SET", KEYS[1], cjson.encode(retained)) ' +
|
|
'if ttl > 0 then redis.call("PEXPIRE", KEYS[1], ttl) end ' +
|
|
'parked.generationProtocolVersion = 1 end ' +
|
|
'return { cjson.encode(parked), tostring(protocol) }';
|
|
|
|
/** Commit a leased recovery only after its ordinary user message is durable. */
|
|
const CONSUME_PARKED_STEER_LUA =
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] ' +
|
|
'or redis.call("HGET", KEYS[1], "userId") ~= ARGV[3] ' +
|
|
'or redis.call("HGET", KEYS[1], "recoveredSteerId") ~= ARGV[2] then return 0 end ' +
|
|
'local jobTenant = redis.call("HGET", KEYS[1], "tenantId") ' +
|
|
'if jobTenant and jobTenant ~= ARGV[4] then return 0 end ' +
|
|
'local raw = redis.call("GET", KEYS[2]) if not raw then return 1 end ' +
|
|
'local ok, parked = pcall(cjson.decode, raw) ' +
|
|
'if not ok or type(parked) ~= "table" or type(parked.steers) ~= "table" then return 0 end ' +
|
|
'local count = 0 for key, _ in pairs(parked.steers) do ' +
|
|
'if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then return 0 end count = count + 1 end ' +
|
|
'if count == 0 or count ~= #parked.steers then return 0 end ' +
|
|
'if parked.userId ~= ARGV[3] or (parked.tenantId and parked.tenantId ~= ARGV[4]) then return 0 end ' +
|
|
'local consumed = nil local kept = {} for i = 1, #parked.steers do local item = parked.steers[i] ' +
|
|
'if type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" then return 0 end ' +
|
|
'if not consumed and item.steerId == ARGV[2] ' +
|
|
'and tostring(item.recoveringCreatedAt or "") == ARGV[1] then consumed = item ' +
|
|
'else kept[#kept + 1] = item end end if not consumed then return 0 end ' +
|
|
'local receipt = nil if consumed.clientSteerId then ' +
|
|
'local receiptRaw = redis.call("HGET", KEYS[3], consumed.clientSteerId) if not receiptRaw then return 0 end ' +
|
|
'local receiptOk, decoded = pcall(cjson.decode, receiptRaw) ' +
|
|
'if not receiptOk or decoded.state ~= "leftover" or decoded.userId ~= ARGV[3] ' +
|
|
'or (decoded.tenantId and decoded.tenantId ~= ARGV[4]) or not decoded.item ' +
|
|
'or decoded.item.steerId ~= ARGV[2] then return 0 end receipt = decoded end ' +
|
|
'local ttl = redis.call("PTTL", KEYS[2]) if #kept == 0 then redis.call("DEL", KEYS[2]) ' +
|
|
'else parked.steers = kept redis.call("SET", KEYS[2], cjson.encode(parked)) ' +
|
|
'if ttl > 0 then redis.call("PEXPIRE", KEYS[2], ttl) end end ' +
|
|
'if receipt then receipt.state = "recovered" ' +
|
|
'redis.call("HSET", KEYS[3], consumed.clientSteerId, cjson.encode(receipt)) end return 1';
|
|
|
|
/** Idempotent terminal reclaim for Edit/Queue/dismiss. */
|
|
const DISCARD_STEER_LEFTOVER_LUA =
|
|
'local raw = redis.call("HGET", KEYS[1], ARGV[1]) if not raw then return 0 end ' +
|
|
'local ok, receipt = pcall(cjson.decode, raw) if not ok or type(receipt) ~= "table" then return 0 end ' +
|
|
'if ARGV[5] ~= "" and tostring(receipt.generationCreatedAt or "") ~= ARGV[5] then return 0 end ' +
|
|
'local tenantMatches = not receipt.tenantId or receipt.tenantId == ARGV[4] ' +
|
|
'if receipt.state ~= "leftover" or receipt.userId ~= ARGV[3] or not tenantMatches ' +
|
|
'or not receipt.item or receipt.item.steerId ~= ARGV[2] then return 0 end ' +
|
|
'local status = redis.call("HGET", KEYS[3], "status") ' +
|
|
'local activeRecovery = (status == "running" or status == "requires_action") ' +
|
|
'and redis.call("HGET", KEYS[3], "recoveredSteerId") == ARGV[2] ' +
|
|
'and redis.call("HGET", KEYS[3], "userId") == ARGV[3] ' +
|
|
'local jobTenant = redis.call("HGET", KEYS[3], "tenantId") ' +
|
|
'if activeRecovery and (not jobTenant or jobTenant == ARGV[4]) then return 0 end ' +
|
|
'local parkedRaw = redis.call("GET", KEYS[2]) ' +
|
|
'if parkedRaw then local parsed, parked = pcall(cjson.decode, parkedRaw) ' +
|
|
'if not parsed or type(parked) ~= "table" or type(parked.steers) ~= "table" then return 0 end ' +
|
|
'local count = 0 for key, _ in pairs(parked.steers) do ' +
|
|
'if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then return 0 end count = count + 1 end ' +
|
|
'if count == 0 or count ~= #parked.steers then return 0 end ' +
|
|
'local parkedTenantMatches = not parked.tenantId or parked.tenantId == ARGV[4] ' +
|
|
'if parked.userId ~= ARGV[3] or not parkedTenantMatches then return 0 end ' +
|
|
'local kept = {} for i = 1, #parked.steers do local item = parked.steers[i] ' +
|
|
'if type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" then return 0 end ' +
|
|
'if item.steerId ~= ARGV[2] then kept[#kept + 1] = item end end ' +
|
|
'if #kept == 0 then redis.call("DEL", KEYS[2]) else parked.steers = kept ' +
|
|
'local ttl = redis.call("PTTL", KEYS[2]) redis.call("SET", KEYS[2], cjson.encode(parked)) ' +
|
|
'if ttl > 0 then redis.call("PEXPIRE", KEYS[2], ttl) end end end ' +
|
|
'receipt.state = "cancelled" redis.call("HSET", KEYS[1], ARGV[1], cjson.encode(receipt)) ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Park leftovers only while the generation that drained them still owns the
|
|
* stream ID. If a replacement already exists, writing its parked key would
|
|
* leak predecessor state into the new run. A replacement created afterward
|
|
* retains the owner-gated payload and leases only an explicitly selected item.
|
|
*
|
|
* KEYS: [job, parkedSteers]
|
|
* ARGV: [expectedCreatedAt | "", payload, ttl]
|
|
*/
|
|
const PARK_STEERS_LUA =
|
|
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'local ok, parked = pcall(cjson.decode, ARGV[2]) if not ok or type(parked) ~= "table" then return 0 end ' +
|
|
'local protocol = redis.call("HGET", KEYS[1], "generationProtocolVersion") ' +
|
|
'if protocol == "2" then parked.generationProtocolVersion = 2 ' +
|
|
'else parked.generationProtocolVersion = 1 end ' +
|
|
'redis.call("SET", KEYS[2], cjson.encode(parked), "EX", ARGV[3]) ' +
|
|
'return 1';
|
|
|
|
/**
|
|
* Terminal close-then-drain in one atomic step: mark the queue closed on the
|
|
* job hash (only when the hash still exists — a bare HSET would resurrect a
|
|
* deleted job as a stray hash), then take the whole queue. Once closed,
|
|
* {@link STEER_ENQUEUE_LUA} rejects, so a steer POST racing finalization can
|
|
* never be ACKed after the last drain and then silently cleared. The same
|
|
* expected-`createdAt` guard as {@link STEER_DRAIN_LUA} keeps a stale run's
|
|
* finalization from closing (and stealing) a replacement job's queue.
|
|
*
|
|
* KEYS: [job, steers, claimedSteers, receipts, receiptOrder, parkedSteers]
|
|
* ARGV: [expectedCreatedAt or "", parkedTtl]
|
|
* Returns: array of item JSON strings (possibly empty), or the
|
|
* `recovery_corrupt` sentinel when destructive recovery is unsafe.
|
|
*/
|
|
const STEER_CLOSE_DRAIN_LUA =
|
|
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return {} end ' +
|
|
'if redis.call("EXISTS", KEYS[1]) == 0 then return {} end ' +
|
|
'local ownerUserId = redis.call("HGET", KEYS[1], "userId") ' +
|
|
'local ownerTenantId = redis.call("HGET", KEYS[1], "tenantId") ' +
|
|
'local generationProtocol = redis.call("HGET", KEYS[1], "generationProtocolVersion") == "2" and 2 or 1 ' +
|
|
'local parkedProtocol = generationProtocol ' +
|
|
'local function isDenseArray(value) if type(value) ~= "table" then return false end ' +
|
|
'local count = 0 for key, _ in pairs(value) do ' +
|
|
'if type(key) ~= "number" or key < 1 or key ~= math.floor(key) then return false end count = count + 1 end ' +
|
|
'return count == #value end ' +
|
|
'local claimedRows = {} if generationProtocol == 2 then claimedRows = redis.call("LRANGE", KEYS[3], 0, -1) end ' +
|
|
'local sources = { claimedRows, redis.call("LRANGE", KEYS[2], 0, -1) } ' +
|
|
'for s = 1, #sources do for i = 1, #sources[s] do local ok, item = pcall(cjson.decode, sources[s][i]) ' +
|
|
'if not ok or type(item) ~= "table" or type(item.steerId) ~= "string" or item.steerId == "" then return "recovery_corrupt" end end end ' +
|
|
'if (#sources[1] > 0 or #sources[2] > 0) and (not ownerUserId or ownerUserId == "") then return "recovery_corrupt" end ' +
|
|
'local parkedSteers = {} local parkedRaw = redis.call("GET", KEYS[6]) ' +
|
|
'if parkedRaw then local parsed, parked = pcall(cjson.decode, parkedRaw) ' +
|
|
'if not parsed or type(parked) ~= "table" or type(parked.userId) ~= "string" or parked.userId == "" ' +
|
|
'or not isDenseArray(parked.steers) or #parked.steers == 0 or parked.userId ~= ownerUserId ' +
|
|
'or (parked.tenantId and parked.tenantId ~= ownerTenantId) then return "recovery_corrupt" end ' +
|
|
'if parked.generationProtocolVersion and parked.generationProtocolVersion ~= 1 ' +
|
|
'and parked.generationProtocolVersion ~= 2 then return "recovery_corrupt" end ' +
|
|
'if parked.generationProtocolVersion == 2 then parkedProtocol = 2 end ' +
|
|
'for i = 1, #parked.steers do local prior = parked.steers[i] ' +
|
|
'if type(prior) ~= "table" or type(prior.steerId) ~= "string" or prior.steerId == "" ' +
|
|
'or (prior.clientSteerId and (type(prior.clientSteerId) ~= "string" or prior.clientSteerId == "")) ' +
|
|
'or (prior.text and type(prior.text) ~= "string") ' +
|
|
'or (prior.createdAt and (type(prior.createdAt) ~= "number" or prior.createdAt < 0)) ' +
|
|
'or (prior.recoveringCreatedAt and (type(prior.recoveringCreatedAt) ~= "number" or prior.recoveringCreatedAt < 0)) then return "recovery_corrupt" end ' +
|
|
'parkedSteers[#parkedSteers + 1] = prior end end ' +
|
|
'if redis.call("EXISTS", KEYS[1]) == 1 then redis.call("HSET", KEYS[1], "steersClosed", "1") end ' +
|
|
'local combined = {} local seen = {} ' +
|
|
'for s = 1, #sources do for i = 1, #sources[s] do ' +
|
|
'local ok, item = pcall(cjson.decode, sources[s][i]) ' +
|
|
'if ok and item.steerId and not seen[item.steerId] then ' +
|
|
'seen[item.steerId] = true combined[#combined + 1] = item end ' +
|
|
'if generationProtocol == 2 and ok and item.clientSteerId then ' +
|
|
'local raw = redis.call("HGET", KEYS[4], item.clientSteerId) ' +
|
|
'if raw then local receiptOk, receipt = pcall(cjson.decode, raw) ' +
|
|
'if receiptOk and type(receipt) == "table" then receipt.item = item receipt.state = "leftover" ' +
|
|
'redis.call("HSET", KEYS[4], item.clientSteerId, cjson.encode(receipt)) end end ' +
|
|
'end end end ' +
|
|
'if #combined > 0 and ownerUserId then ' +
|
|
'local filteredParkedSteers = {} ' +
|
|
'for i = 1, #parkedSteers do local prior = parkedSteers[i] ' +
|
|
'if not seen[prior.steerId] then seen[prior.steerId] = true ' +
|
|
'filteredParkedSteers[#filteredParkedSteers + 1] = prior end end ' +
|
|
'local currentProjected = {} ' +
|
|
'for i = 1, #combined do local item = combined[i] ' +
|
|
'local projected = { steerId = item.steerId, text = item.text, createdAt = item.createdAt } ' +
|
|
'if item.clientSteerId then projected.clientSteerId = item.clientSteerId end ' +
|
|
'if item.files then projected.files = item.files end ' +
|
|
'if item.quotes then projected.quotes = item.quotes end ' +
|
|
'if item.preempt then projected.preempt = item.preempt end ' +
|
|
'if item.preemptRevision then projected.preemptRevision = item.preemptRevision end ' +
|
|
'currentProjected[#currentProjected + 1] = projected end ' +
|
|
'local merged = {} for i = 1, #filteredParkedSteers do merged[#merged + 1] = filteredParkedSteers[i] end ' +
|
|
'for i = 1, #currentProjected do merged[#merged + 1] = currentProjected[i] end ' +
|
|
'local parked = { userId = ownerUserId, generationProtocolVersion = parkedProtocol, steers = merged } ' +
|
|
'if ownerTenantId then parked.tenantId = ownerTenantId end ' +
|
|
'redis.call("SET", KEYS[6], cjson.encode(parked), "EX", ARGV[2]) end ' +
|
|
'for i = 4, 5 do local ttl = redis.call("TTL", KEYS[i]) ' +
|
|
'if ttl >= 0 and ttl < tonumber(ARGV[2]) then redis.call("EXPIRE", KEYS[i], ARGV[2]) end end ' +
|
|
'redis.call("DEL", KEYS[2], KEYS[3]) ' +
|
|
'local encoded = {} for i = 1, #combined do encoded[i] = cjson.encode(combined[i]) end ' +
|
|
'return encoded';
|
|
|
|
/** Decision kinds the SDK can emit, used to sanity-check persisted records. */
|
|
const KNOWN_INTERRUPT_TYPES = new Set(['tool_approval', 'ask_user_question']);
|
|
|
|
/** Recovery window (seconds) for parked steers when `completedTtl` is
|
|
* configured to 0 — Redis rejects `EX 0`, which would silently kill
|
|
* park-based recovery. */
|
|
const PARKED_RECOVERY_TTL_S: number = 300;
|
|
const STEER_RECEIPT_MAX_PER_STREAM: number = 100;
|
|
/** Grace window for publishing terminal/reaper events after the live job hash expires. */
|
|
const GENERATION_EPOCH_GRACE_TTL_S: number = 300;
|
|
/** A terminal record carrying an uncommitted final event must survive the
|
|
* persistence-owner timeout even when completedTtl is configured to zero. */
|
|
const TERMINAL_PERSISTENCE_RETENTION_TTL_S: number = 300;
|
|
|
|
/** Bound pathological replacement churn without leaving the request unbounded. */
|
|
const MEMBERSHIP_RECONCILE_MAX_ATTEMPTS: number = 8;
|
|
|
|
/**
|
|
* Key prefixes for Redis storage.
|
|
* All keys include the streamId for easy cleanup.
|
|
* Note: streamId === conversationId, so no separate mapping needed.
|
|
*
|
|
* IMPORTANT: Uses hash tags {streamId} for Redis Cluster compatibility.
|
|
* All keys for the same stream hash to the same slot, enabling:
|
|
* - Pipeline operations across related keys
|
|
* - Atomic multi-key operations
|
|
*/
|
|
const KEYS = {
|
|
/** Job metadata: stream:{streamId}:job */
|
|
job: (streamId: string) => `stream:{${streamId}}:job`,
|
|
/** Pub/sub event sequence counter: stream:{streamId}:seq */
|
|
sequence: (streamId: string) => `stream:{${streamId}}:seq`,
|
|
/** Chunk stream (Redis Streams): stream:{streamId}:chunks */
|
|
chunks: (streamId: string) => `stream:{${streamId}}:chunks`,
|
|
/** Run steps: stream:{streamId}:runsteps */
|
|
runSteps: (streamId: string) => `stream:{${streamId}}:runsteps`,
|
|
/** Pending steer messages (FIFO list): stream:{streamId}:steers */
|
|
steers: (streamId: string) => `stream:{${streamId}}:steers`,
|
|
/** Drained steers awaiting an atomic durable applied-chunk commit. */
|
|
claimedSteers: (streamId: string) => `stream:{${streamId}}:steers-claimed`,
|
|
/** Parked terminally-drained steers (own TTL — must outlive the job hash,
|
|
* which the default completeJob path deletes immediately) */
|
|
parkedSteers: (streamId: string) => `stream:{${streamId}}:parked`,
|
|
/** Lost-ACK steer receipts (hash fields are clientSteerIds). */
|
|
steerReceipts: (streamId: string) => `stream:{${streamId}}:steer-receipts`,
|
|
steerReceiptOrder: (streamId: string) => `stream:{${streamId}}:steer-receipt-order`,
|
|
/** Latest generation epoch, retained briefly beyond the live job hash. */
|
|
generationEpoch: (streamId: string) => `stream:{${streamId}}:generation-epoch`,
|
|
/** Running jobs set for cleanup (global set - single slot) */
|
|
runningJobs: 'stream:running',
|
|
/** Jobs paused for human review (global set - single slot) */
|
|
requiresActionJobs: 'stream:requires_action',
|
|
/** Terminal jobs that still owe a durable host lifecycle hook (global set). Retains
|
|
* the aborted approval-expiry job for cross-replica / post-restart hook retry. */
|
|
terminalHostActionJobs: 'stream:terminal_host_action',
|
|
/** Versioned recovery lane for detached Event Actor completion generations.
|
|
* Pre-detached replicas only scan `terminalHostActionJobs`, so they cannot
|
|
* claim a generation whose host hook requires both invocation identities. */
|
|
detachedAgentEventTerminalHostActionJobsV1: 'stream:agent_event_detached:terminal_host_action:v1',
|
|
/** User's active jobs set, tenant-qualified when tenantId is available */
|
|
userJobs: (userId: string, tenantId?: string) =>
|
|
tenantId ? `stream:user:{${tenantId}:${userId}}:jobs` : `stream:user:{${userId}}:jobs`,
|
|
/** Idempotency claim for a start-generation request: stream:idem:{userId:clientRequestId} */
|
|
idempotency: (key: string) => `stream:idem:${key}`,
|
|
};
|
|
|
|
/** Pre-detached creation scripts reject an unknown status before replacement,
|
|
* while capable readers project the private logical terminal status. */
|
|
const DETACHED_TERMINAL_LEGACY_STATUS = 'detached_terminal_pending_v1';
|
|
|
|
interface TerminalHostActionMember {
|
|
streamId: string;
|
|
createdAt?: number;
|
|
}
|
|
|
|
/** The retry index is global (and therefore outside the per-stream Redis
|
|
* Cluster slot), so its members must carry generation identity. Otherwise an
|
|
* acknowledgement for generation A can remove generation B's pre-armed hint. */
|
|
function terminalHostActionMember(streamId: string, createdAt: number): string {
|
|
return JSON.stringify([streamId, createdAt]);
|
|
}
|
|
|
|
function isDetachedAgentEventCompletionJob(job: SerializableJobData): boolean {
|
|
// The invocation key alone is enough to make legacy recovery unsafe. Keep a
|
|
// malformed completion with a missing generation timestamp in the capable
|
|
// lane so it fails closed there instead of exposing it to an old consumer.
|
|
return job.agentEventInvocationKey != null;
|
|
}
|
|
|
|
function parseTerminalHostActionMember(member: string): TerminalHostActionMember {
|
|
try {
|
|
const parsed = JSON.parse(member) as unknown;
|
|
if (
|
|
Array.isArray(parsed) &&
|
|
parsed.length === 2 &&
|
|
typeof parsed[0] === 'string' &&
|
|
typeof parsed[1] === 'number' &&
|
|
Number.isSafeInteger(parsed[1]) &&
|
|
parsed[1] >= 0
|
|
) {
|
|
return { streamId: parsed[0], createdAt: parsed[1] };
|
|
}
|
|
} catch {
|
|
// Older releases indexed the bare stream id. Enumeration migrates any
|
|
// still-live legacy member to the generation-scoped representation.
|
|
}
|
|
return { streamId: member };
|
|
}
|
|
|
|
/**
|
|
* Default TTL values in seconds.
|
|
* Can be overridden via constructor options.
|
|
*/
|
|
const DEFAULT_TTL = {
|
|
/** TTL for completed jobs (5 minutes) */
|
|
completed: 300,
|
|
/** TTL for running jobs/chunks (20 minutes - failsafe for crashed jobs) */
|
|
running: 1200,
|
|
/** TTL for chunks after completion (0 = delete immediately) */
|
|
chunksAfterComplete: 0,
|
|
/** TTL for run steps after completion (0 = delete immediately) */
|
|
runStepsAfterComplete: 0,
|
|
/** Safety-net TTL for per-user job tracking sets (24 hours). Refreshed on each createJob. */
|
|
userJobsSet: 86400,
|
|
/**
|
|
* Backstop TTL for a job paused for human review (24 hours). A paused job is
|
|
* NOT a hung generation, so it must not inherit the 20-minute running TTL —
|
|
* an approval with no explicit `expiresAt` is "live" per the API contract and
|
|
* would otherwise be evicted mid-window. A pendingAction with a longer
|
|
* `expiresAt` extends beyond this (see pauseTtlSeconds).
|
|
*/
|
|
requiresAction: 86400,
|
|
};
|
|
|
|
/**
|
|
* Redis implementation of IJobStoreV2.
|
|
* Enables horizontal scaling with multi-instance deployments.
|
|
*
|
|
* Storage strategy:
|
|
* - Job metadata: Redis Hash (fast field access)
|
|
* - Chunks: Redis Streams (append-only, efficient for streaming)
|
|
* - Run steps: Redis String (JSON serialized)
|
|
*
|
|
* Note: streamId === conversationId, so getJob(conversationId) works directly.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { ioredisClient } from '~/cache';
|
|
* const store = new RedisJobStore(ioredisClient);
|
|
* await store.initialize();
|
|
* ```
|
|
*/
|
|
/**
|
|
* Configuration options for RedisJobStore
|
|
*/
|
|
export interface RedisJobStoreOptions {
|
|
/** TTL for completed jobs in seconds (default: 300 = 5 minutes) */
|
|
completedTtl?: number;
|
|
/** TTL for running jobs/chunks in seconds (default: 1200 = 20 minutes) */
|
|
runningTtl?: number;
|
|
/** TTL for chunks after completion in seconds (default: 0 = delete immediately) */
|
|
chunksAfterCompleteTtl?: number;
|
|
/** TTL for run steps after completion in seconds (default: 0 = delete immediately) */
|
|
runStepsAfterCompleteTtl?: number;
|
|
/** TTL for per-user job tracking sets in seconds (default: 86400 = 24 hours). 0 = no TTL. */
|
|
userJobsSetTtl?: number;
|
|
/** Backstop TTL for a paused (requires_action) job in seconds (default: 86400 = 24 hours). */
|
|
requiresActionTtl?: number;
|
|
}
|
|
|
|
interface LocalCacheEntry<T> {
|
|
createdAt?: number;
|
|
value: T;
|
|
}
|
|
|
|
/**
|
|
* Coalescable durable appends buffered for one stream. Events are
|
|
* pre-serialized at enqueue; a flush XADDs them in order under one guard pass.
|
|
* The whole batch shares one fate, so every resolver settles identically.
|
|
*/
|
|
interface PendingChunkAppendBatch {
|
|
expectedCreatedAt?: number;
|
|
events: string[];
|
|
settlers: Array<{ resolve: (appended: boolean) => void; reject: (err: unknown) => void }>;
|
|
bytes: number;
|
|
timer: ReturnType<typeof setTimeout> | null;
|
|
}
|
|
|
|
export class RedisJobStore implements IJobStoreV2 {
|
|
readonly detachedAgentEventActionStoreMode = 'distributed' as const;
|
|
|
|
private redis: Redis | Cluster;
|
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
|
private ttl: typeof DEFAULT_TTL;
|
|
/** Coalescable chunk appends awaiting their window flush, per stream */
|
|
private pendingAppends = new Map<string, PendingChunkAppendBatch>();
|
|
/** Durable-append coalescing window; 0 keeps every append on the per-event path */
|
|
private readonly coalesceWindowMs: number;
|
|
|
|
/** Whether Redis client is in cluster mode (affects pipeline usage) */
|
|
private isCluster: boolean;
|
|
|
|
/**
|
|
* Local cache for graph references on THIS instance.
|
|
* Enables fast reconnects when client returns to the same server.
|
|
* Uses WeakRef to allow garbage collection when graph is no longer needed.
|
|
*/
|
|
private localGraphCache = new Map<string, LocalCacheEntry<WeakRef<StandardGraph>>>();
|
|
|
|
/**
|
|
* Local cache for collectedUsage arrays.
|
|
* Generation happens on a single instance, so collectedUsage is only available locally.
|
|
* For cross-replica abort, the abort handler falls back to text-based token counting.
|
|
*/
|
|
private localCollectedUsageCache = new Map<string, LocalCacheEntry<UsageMetadata[]>>();
|
|
/** Same-instance HOST content view (includes host-authored parts like
|
|
* steers, which the SDK graph never sees). Preferred over the graph cache
|
|
* on local reads; cross-instance reads reconstruct from chunks. */
|
|
private localContentParts = new Map<
|
|
string,
|
|
LocalCacheEntry<WeakRef<Agents.MessageContentComplex[]>>
|
|
>();
|
|
|
|
/** Cleanup interval in ms (1 minute) */
|
|
private cleanupIntervalMs = 60000;
|
|
|
|
constructor(redis: Redis | Cluster, options?: RedisJobStoreOptions) {
|
|
this.redis = instrumentIORedisClient(redis, RedisUseCases.GENERATION_STREAM);
|
|
this.coalesceWindowMs = resolveCoalesceWindowMs();
|
|
this.ttl = {
|
|
completed: options?.completedTtl ?? DEFAULT_TTL.completed,
|
|
running: options?.runningTtl ?? DEFAULT_TTL.running,
|
|
chunksAfterComplete: options?.chunksAfterCompleteTtl ?? DEFAULT_TTL.chunksAfterComplete,
|
|
runStepsAfterComplete: options?.runStepsAfterCompleteTtl ?? DEFAULT_TTL.runStepsAfterComplete,
|
|
userJobsSet: options?.userJobsSetTtl ?? DEFAULT_TTL.userJobsSet,
|
|
requiresAction: options?.requiresActionTtl ?? DEFAULT_TTL.requiresAction,
|
|
};
|
|
// Detect cluster mode using ioredis's isCluster property
|
|
this.isCluster = (redis as Cluster).isCluster === true;
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
if (this.cleanupInterval) {
|
|
return;
|
|
}
|
|
|
|
// Start periodic cleanup
|
|
this.cleanupInterval = setInterval(() => {
|
|
this.cleanup().catch((err) => {
|
|
logger.error('[RedisJobStore] Cleanup error:', err);
|
|
});
|
|
}, this.cleanupIntervalMs);
|
|
|
|
if (this.cleanupInterval.unref) {
|
|
this.cleanupInterval.unref();
|
|
}
|
|
|
|
logger.info('[RedisJobStore] Initialized with cleanup interval');
|
|
}
|
|
|
|
private getLocalEntry<T>(
|
|
cache: Map<string, LocalCacheEntry<T>>,
|
|
streamId: string,
|
|
expectedCreatedAt?: number,
|
|
): LocalCacheEntry<T> | undefined {
|
|
const entry = cache.get(streamId);
|
|
if (expectedCreatedAt != null && entry?.createdAt !== expectedCreatedAt) {
|
|
return undefined;
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
private setLocalEntry<T>(
|
|
cache: Map<string, LocalCacheEntry<T>>,
|
|
streamId: string,
|
|
entry: LocalCacheEntry<T>,
|
|
): void {
|
|
const currentEntry = cache.get(streamId);
|
|
if (
|
|
currentEntry?.createdAt != null &&
|
|
(entry.createdAt == null || entry.createdAt < currentEntry.createdAt)
|
|
) {
|
|
return;
|
|
}
|
|
cache.set(streamId, entry);
|
|
}
|
|
|
|
private deleteLocalEntry<T>(
|
|
cache: Map<string, LocalCacheEntry<T>>,
|
|
streamId: string,
|
|
expectedCreatedAt?: number,
|
|
observedEntry?: LocalCacheEntry<T>,
|
|
): void {
|
|
const entry = cache.get(streamId);
|
|
if (
|
|
!entry ||
|
|
(observedEntry != null && entry !== observedEntry) ||
|
|
(expectedCreatedAt != null && entry.createdAt !== expectedCreatedAt)
|
|
) {
|
|
return;
|
|
}
|
|
cache.delete(streamId);
|
|
}
|
|
|
|
private clearLocalState(streamId: string, expectedCreatedAt?: number): void {
|
|
this.deleteLocalEntry(this.localGraphCache, streamId, expectedCreatedAt);
|
|
this.deleteLocalEntry(this.localContentParts, streamId, expectedCreatedAt);
|
|
this.deleteLocalEntry(this.localCollectedUsageCache, streamId, expectedCreatedAt);
|
|
}
|
|
|
|
private clearPredecessorLocalState(streamId: string, createdAt: number): void {
|
|
const graphEntry = this.localGraphCache.get(streamId);
|
|
if (graphEntry && (graphEntry.createdAt == null || graphEntry.createdAt < createdAt)) {
|
|
this.deleteLocalEntry(this.localGraphCache, streamId, undefined, graphEntry);
|
|
}
|
|
const contentEntry = this.localContentParts.get(streamId);
|
|
if (contentEntry && (contentEntry.createdAt == null || contentEntry.createdAt < createdAt)) {
|
|
this.deleteLocalEntry(this.localContentParts, streamId, undefined, contentEntry);
|
|
}
|
|
const usageEntry = this.localCollectedUsageCache.get(streamId);
|
|
if (usageEntry && (usageEntry.createdAt == null || usageEntry.createdAt < createdAt)) {
|
|
this.deleteLocalEntry(this.localCollectedUsageCache, streamId, undefined, usageEntry);
|
|
}
|
|
}
|
|
|
|
async createJob(
|
|
streamId: string,
|
|
userId: string,
|
|
conversationId?: string,
|
|
tenantId?: string,
|
|
initialMetadata: JobMetadataPatch = {},
|
|
recoveredSteerId?: string,
|
|
idempotencyClaimKey?: string,
|
|
idempotencyClaimToken?: string,
|
|
idempotencyClientRequestId?: string,
|
|
recoveredSteerPayload?: RecoveredSteerPayload,
|
|
creationAttemptId?: string,
|
|
expectedPredecessorCreatedAt?: number,
|
|
rejectActivePredecessor?: boolean,
|
|
): Promise<CreatedJobData> {
|
|
if (typeof userId !== 'string' || userId.length === 0) {
|
|
throw new Error('Generation job requires a non-empty user id');
|
|
}
|
|
assertCreateIdempotencyArguments(
|
|
idempotencyClaimKey,
|
|
idempotencyClaimToken,
|
|
idempotencyClientRequestId,
|
|
);
|
|
if (
|
|
creationAttemptId != null &&
|
|
(creationAttemptId.length === 0 || creationAttemptId.length > 128)
|
|
) {
|
|
throw new Error('Invalid generation creation attempt id');
|
|
}
|
|
if (
|
|
expectedPredecessorCreatedAt != null &&
|
|
(!Number.isSafeInteger(expectedPredecessorCreatedAt) || expectedPredecessorCreatedAt < 0)
|
|
) {
|
|
throw new Error('Invalid expected generation predecessor');
|
|
}
|
|
if (rejectActivePredecessor != null && typeof rejectActivePredecessor !== 'boolean') {
|
|
throw new Error('Invalid active generation predecessor policy');
|
|
}
|
|
const providerExecutionId = initialMetadata.providerExecutionId;
|
|
if (
|
|
providerExecutionId != null &&
|
|
(providerExecutionId.length === 0 || providerExecutionId.length > 128)
|
|
) {
|
|
throw new Error('Invalid provider execution id');
|
|
}
|
|
const safeInitialMetadata = { ...initialMetadata };
|
|
delete safeInitialMetadata.providerDrained;
|
|
let generationProtocolVersion: 1 | 2 = 2;
|
|
if (
|
|
initialMetadata.generationProtocolVersion === 1 ||
|
|
initialMetadata.generationProtocolVersion === 2
|
|
) {
|
|
generationProtocolVersion = initialMetadata.generationProtocolVersion;
|
|
}
|
|
const job: CreatedJobData = {
|
|
...safeInitialMetadata,
|
|
streamId,
|
|
userId,
|
|
...(tenantId && { tenantId }),
|
|
status: 'running',
|
|
createdAt: Date.now(),
|
|
generationProtocolVersion,
|
|
...(conversationId !== undefined && { conversationId }),
|
|
...(idempotencyClientRequestId !== undefined && { idempotencyClientRequestId }),
|
|
...(recoveredSteerId !== undefined && { recoveredSteerId }),
|
|
providerAbortReady: false,
|
|
...(providerExecutionId != null && { providerDrained: true }),
|
|
syncSent: false,
|
|
};
|
|
if (creationAttemptId != null) {
|
|
Object.defineProperty(job, 'creationAttemptId', {
|
|
value: creationAttemptId,
|
|
enumerable: false,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
const key = KEYS.job(streamId);
|
|
|
|
// For cluster mode, we can't pipeline keys on different slots
|
|
// The job key uses hash tag {streamId}, runningJobs and userJobs are on different slots
|
|
// Generation-state reset + job-hash write happen ATOMICALLY (same-slot Lua).
|
|
const hsetPairs = Object.entries(this.serializeJob(job)).flat();
|
|
const parkedTtl = this.parkedRecoveryTtlSeconds();
|
|
/** A leased source is hidden while this job is active. Keep it for one
|
|
* normal recovery window beyond the live job's storage horizon so delayed
|
|
* cleanup or natural job expiry cannot make both keys disappear together. */
|
|
const createParkedTtl =
|
|
recoveredSteerId != null ? this.runningStorageTtlSeconds() + parkedTtl : parkedTtl;
|
|
const previousOwner = await this.redis.eval(
|
|
JOB_CREATE_LUA,
|
|
10,
|
|
key,
|
|
KEYS.chunks(streamId),
|
|
KEYS.runSteps(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
KEYS.generationEpoch(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.steerReceiptOrder(streamId),
|
|
idempotencyClaimKey != null ? KEYS.idempotency(idempotencyClaimKey) : key,
|
|
String(this.runningStorageTtlSeconds()),
|
|
String(job.createdAt),
|
|
String(GENERATION_EPOCH_GRACE_TTL_S),
|
|
String(createParkedTtl),
|
|
recoveredSteerId ?? '',
|
|
userId,
|
|
tenantId ?? '',
|
|
idempotencyClaimToken ?? '',
|
|
recoveredSteerPayload == null ? '' : JSON.stringify(recoveredSteerPayload),
|
|
String(job.generationProtocolVersion),
|
|
creationAttemptId ?? '',
|
|
expectedPredecessorCreatedAt == null ? '' : String(expectedPredecessorCreatedAt),
|
|
rejectActivePredecessor === true ? '1' : '0',
|
|
...hsetPairs,
|
|
);
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'claim_lost') {
|
|
throw new Error('Generation idempotency claim was taken over before job creation');
|
|
}
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'owner_mismatch') {
|
|
throw new Error('Generation job owner mismatch');
|
|
}
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'recovery_corrupt') {
|
|
throw new Error('Generation recovery state is corrupt');
|
|
}
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'recovery_payload_mismatch') {
|
|
throw new RecoveredSteerPayloadMismatchError();
|
|
}
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'replacement_receipt_corrupt') {
|
|
throw new Error('Generation replacement receipt is corrupt');
|
|
}
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'generation_epoch_corrupt') {
|
|
throw new Error('Generation epoch is corrupt');
|
|
}
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'generation_epoch_exhausted') {
|
|
throw new Error('Generation epoch is exhausted');
|
|
}
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'replacement_chain_full') {
|
|
throw new Error('Generation replacement receipt chain is full');
|
|
}
|
|
if (Array.isArray(previousOwner) && previousOwner[3] === 'predecessor_mismatch') {
|
|
const currentCreatedAt = Number(previousOwner[4]);
|
|
const currentStatus =
|
|
typeof previousOwner[5] === 'string' && previousOwner[5] !== ''
|
|
? (previousOwner[5] as JobStatus)
|
|
: undefined;
|
|
const currentConversationId =
|
|
typeof previousOwner[6] === 'string' && previousOwner[6] !== ''
|
|
? previousOwner[6]
|
|
: undefined;
|
|
throw new JobPredecessorMismatchError({
|
|
createdAt: currentCreatedAt,
|
|
active:
|
|
previousOwner[7] === '1' ||
|
|
currentStatus === 'running' ||
|
|
currentStatus === 'requires_action',
|
|
verified: previousOwner[8] !== '0',
|
|
...(currentStatus !== undefined && { status: currentStatus }),
|
|
...(currentConversationId !== undefined && { conversationId: currentConversationId }),
|
|
});
|
|
}
|
|
const previousUserId =
|
|
Array.isArray(previousOwner) && typeof previousOwner[0] === 'string' ? previousOwner[0] : '';
|
|
const previousTenantId =
|
|
Array.isArray(previousOwner) && typeof previousOwner[1] === 'string' ? previousOwner[1] : '';
|
|
const createdAt =
|
|
Array.isArray(previousOwner) &&
|
|
(typeof previousOwner[2] === 'string' || typeof previousOwner[2] === 'number')
|
|
? Number(previousOwner[2])
|
|
: job.createdAt;
|
|
job.createdAt = Number.isFinite(createdAt) ? createdAt : job.createdAt;
|
|
if (job.generationProtocolVersion === 2) {
|
|
job.checkpointNamespace = String(job.createdAt);
|
|
} else {
|
|
delete job.checkpointNamespace;
|
|
}
|
|
const replacedCreatedAt =
|
|
Array.isArray(previousOwner) &&
|
|
(typeof previousOwner[4] === 'string' || typeof previousOwner[4] === 'number') &&
|
|
String(previousOwner[4]) !== ''
|
|
? Number(previousOwner[4])
|
|
: undefined;
|
|
const replacedStatus =
|
|
Array.isArray(previousOwner) && typeof previousOwner[5] === 'string'
|
|
? (previousOwner[5] as JobStatus)
|
|
: undefined;
|
|
const replacedConversationId =
|
|
Array.isArray(previousOwner) &&
|
|
typeof previousOwner[6] === 'string' &&
|
|
previousOwner[6] !== ''
|
|
? previousOwner[6]
|
|
: undefined;
|
|
const replacedProviderAbortReady =
|
|
Array.isArray(previousOwner) &&
|
|
typeof previousOwner[7] === 'string' &&
|
|
previousOwner[7] !== ''
|
|
? previousOwner[7] === '1'
|
|
: undefined;
|
|
const replacedProviderExecutionId =
|
|
Array.isArray(previousOwner) &&
|
|
typeof previousOwner[8] === 'string' &&
|
|
previousOwner[8] !== ''
|
|
? previousOwner[8]
|
|
: undefined;
|
|
const replacedProviderDrained =
|
|
Array.isArray(previousOwner) &&
|
|
typeof previousOwner[9] === 'string' &&
|
|
previousOwner[9] !== ''
|
|
? previousOwner[9] === '1'
|
|
: undefined;
|
|
const replacedJob =
|
|
replacedCreatedAt != null && Number.isFinite(replacedCreatedAt) && replacedStatus != null
|
|
? {
|
|
createdAt: replacedCreatedAt,
|
|
status: replacedStatus,
|
|
...(replacedConversationId !== undefined && {
|
|
conversationId: replacedConversationId,
|
|
}),
|
|
}
|
|
: undefined;
|
|
if (replacedJob != null && replacedProviderAbortReady != null) {
|
|
Object.defineProperty(replacedJob, 'providerAbortReady', {
|
|
value: replacedProviderAbortReady,
|
|
enumerable: false,
|
|
});
|
|
}
|
|
if (replacedJob != null && replacedProviderExecutionId != null) {
|
|
Object.defineProperties(replacedJob, {
|
|
providerExecutionId: {
|
|
value: replacedProviderExecutionId,
|
|
enumerable: false,
|
|
},
|
|
providerDrained: {
|
|
value: replacedProviderDrained,
|
|
enumerable: false,
|
|
},
|
|
});
|
|
}
|
|
const previousUserKeys =
|
|
previousUserId !== ''
|
|
? [KEYS.userJobs(previousUserId, previousTenantId || undefined)]
|
|
: undefined;
|
|
// Cross-slot membership cannot join the creation Lua transaction. Reconcile
|
|
// from the durable hash and verify after writing so an overlapping status
|
|
// transition or same-stream replacement always gets the final word.
|
|
const currentJob = await this.reconcileJobMembership(streamId, {
|
|
initialJob: job,
|
|
previousUserKeys,
|
|
});
|
|
if (
|
|
currentJob !== undefined &&
|
|
(!currentJob || currentJob.createdAt !== job.createdAt || currentJob.status !== 'running')
|
|
) {
|
|
if (replacedJob != null) {
|
|
Object.defineProperty(job, 'replacedJob', {
|
|
value: replacedJob,
|
|
enumerable: false,
|
|
});
|
|
}
|
|
throw new JobCreationSupersededError(job);
|
|
}
|
|
if (currentJob === undefined) {
|
|
if (replacedJob != null) {
|
|
Object.defineProperty(job, 'replacedJob', {
|
|
value: replacedJob,
|
|
enumerable: false,
|
|
});
|
|
}
|
|
// The same-slot create may already be durable, but exposing a provider
|
|
// before cross-slot running/user membership is verified would strand an
|
|
// untracked generation. The manager's attempt-id recovery probes the
|
|
// durable job, repairs membership, and reconstructs the full predecessor
|
|
// chain before deciding whether it is safe to proceed.
|
|
throw new Error('Created job membership could not be verified');
|
|
}
|
|
this.clearPredecessorLocalState(streamId, currentJob.createdAt);
|
|
|
|
if (replacedJob != null) {
|
|
Object.defineProperty(currentJob, 'replacedJob', {
|
|
value: replacedJob,
|
|
enumerable: false,
|
|
});
|
|
}
|
|
|
|
logger.debug(`[RedisJobStore] Created job: ${streamId}`);
|
|
return currentJob;
|
|
}
|
|
|
|
async getJob(streamId: string): Promise<SerializableJobData | null> {
|
|
const data = await this.redis.hgetall(KEYS.job(streamId));
|
|
if (!data || Object.keys(data).length === 0) {
|
|
return null;
|
|
}
|
|
return this.deserializeJob(data);
|
|
}
|
|
|
|
async acknowledgeReplacedJobs(
|
|
streamId: string,
|
|
creationAttemptId: string,
|
|
replacedCreatedAts: readonly number[],
|
|
): Promise<boolean> {
|
|
if (creationAttemptId.length === 0 || replacedCreatedAts.length === 0) {
|
|
return false;
|
|
}
|
|
const acknowledged = await this.redis.eval(
|
|
REPLACEMENT_RECEIPT_ACK_LUA,
|
|
1,
|
|
KEYS.job(streamId),
|
|
creationAttemptId,
|
|
...replacedCreatedAts.map(String),
|
|
);
|
|
return acknowledged === 1;
|
|
}
|
|
|
|
async updateJob(
|
|
streamId: string,
|
|
updates: Partial<SerializableJobData>,
|
|
expectedCreatedAt?: number,
|
|
): Promise<void> {
|
|
const key = KEYS.job(streamId);
|
|
const requestedTerminal =
|
|
updates.status != null && ['complete', 'error', 'aborted'].includes(updates.status);
|
|
if (requestedTerminal) {
|
|
const observed = await this.getJob(streamId);
|
|
if (
|
|
observed != null &&
|
|
(expectedCreatedAt == null || observed.createdAt === expectedCreatedAt) &&
|
|
(observed.status === 'running' || observed.status === 'requires_action')
|
|
) {
|
|
const { status, ...patch } = updates;
|
|
await this.transitionStatus(streamId, {
|
|
from: observed.status,
|
|
to: status!,
|
|
patch,
|
|
expectCreatedAt: expectedCreatedAt ?? observed.createdAt,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Plain field writer. The membership-aware status transitions
|
|
// (running ⇄ requires_action — sets, TTLs, the actionId guard) go solely
|
|
// through transitionStatus. The optional epoch guard keeps late metadata
|
|
// and terminal-event persistence from mutating a same-stream replacement.
|
|
const serialized = this.serializeJob(updates as SerializableJobData);
|
|
if (Object.keys(serialized).length === 0) {
|
|
return;
|
|
}
|
|
|
|
const terminal = requestedTerminal;
|
|
const observedJob = terminal ? await this.getJob(streamId) : null;
|
|
const completedTtl =
|
|
updates.terminalPersistencePending === true ||
|
|
observedJob?.terminalPersistencePending === true
|
|
? Math.max(this.ttl.completed, TERMINAL_PERSISTENCE_RETENTION_TTL_S)
|
|
: this.ttl.completed;
|
|
const fields = Object.entries(serialized).flat();
|
|
const updated = await this.redis.eval(
|
|
JOB_UPDATE_LUA,
|
|
4,
|
|
key,
|
|
KEYS.chunks(streamId),
|
|
KEYS.runSteps(streamId),
|
|
KEYS.steers(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
terminal ? '1' : '0',
|
|
String(completedTtl),
|
|
String(this.ttl.chunksAfterComplete),
|
|
String(this.ttl.runStepsAfterComplete),
|
|
...fields,
|
|
);
|
|
if (updated !== 1) {
|
|
return;
|
|
}
|
|
|
|
if (terminal) {
|
|
const currentJob = await this.reconcileJobMembership(streamId, {
|
|
previousJob: observedJob,
|
|
});
|
|
this.clearLocalStateUnlessActive(
|
|
streamId,
|
|
currentJob,
|
|
expectedCreatedAt ?? observedJob?.createdAt,
|
|
);
|
|
}
|
|
}
|
|
|
|
async markProviderExecutionDrained(
|
|
streamId: string,
|
|
expectedCreatedAt: number,
|
|
providerExecutionId: string,
|
|
): Promise<boolean> {
|
|
return (
|
|
Number(
|
|
await this.redis.eval(
|
|
PROVIDER_DRAIN_LUA,
|
|
1,
|
|
KEYS.job(streamId),
|
|
String(expectedCreatedAt),
|
|
providerExecutionId,
|
|
),
|
|
) === 1
|
|
);
|
|
}
|
|
|
|
async beginProviderExecution(
|
|
streamId: string,
|
|
expectedCreatedAt: number,
|
|
providerExecutionId: string,
|
|
): Promise<boolean> {
|
|
return (
|
|
Number(
|
|
await this.redis.eval(
|
|
PROVIDER_BEGIN_LUA,
|
|
1,
|
|
KEYS.job(streamId),
|
|
String(expectedCreatedAt),
|
|
providerExecutionId,
|
|
),
|
|
) === 1
|
|
);
|
|
}
|
|
|
|
async finalizeTerminalPersistence(
|
|
streamId: string,
|
|
expectedCreatedAt: number,
|
|
finalEvent: string,
|
|
): Promise<boolean> {
|
|
return (
|
|
Number(
|
|
await this.redis.eval(
|
|
TERMINAL_PERSISTENCE_FINALIZE_LUA,
|
|
1,
|
|
KEYS.job(streamId),
|
|
String(expectedCreatedAt),
|
|
finalEvent,
|
|
),
|
|
) === 1
|
|
);
|
|
}
|
|
|
|
private sameMembershipSource(
|
|
left: SerializableJobData | null,
|
|
right: SerializableJobData | null,
|
|
): boolean {
|
|
if (left == null || right == null) {
|
|
return left === right;
|
|
}
|
|
return (
|
|
left.createdAt === right.createdAt &&
|
|
left.status === right.status &&
|
|
left.userId === right.userId &&
|
|
left.tenantId === right.tenantId &&
|
|
left.providerDrained === right.providerDrained
|
|
);
|
|
}
|
|
|
|
private addObservedUserKey(keys: Set<string>, job: SerializableJobData | null): void {
|
|
if (job?.userId) {
|
|
keys.add(KEYS.userJobs(job.userId, job.tenantId));
|
|
}
|
|
}
|
|
|
|
private async applyMembershipSnapshot(
|
|
streamId: string,
|
|
job: SerializableJobData | null,
|
|
observedUserKeys: Set<string>,
|
|
): Promise<SerializableJobData | null> {
|
|
const statusKey = job ? this.statusSetKey(job.status) : null;
|
|
const activeUserKey =
|
|
job && (statusKey != null || job.providerDrained === false)
|
|
? KEYS.userJobs(job.userId, job.tenantId)
|
|
: null;
|
|
const terminalMember = job == null ? null : terminalHostActionMember(streamId, job.createdAt);
|
|
const terminalHostActionIndex =
|
|
job != null && isDetachedAgentEventCompletionJob(job)
|
|
? KEYS.detachedAgentEventTerminalHostActionJobsV1
|
|
: KEYS.terminalHostActionJobs;
|
|
const otherTerminalHostActionIndex =
|
|
terminalHostActionIndex === KEYS.terminalHostActionJobs
|
|
? KEYS.detachedAgentEventTerminalHostActionJobsV1
|
|
: KEYS.terminalHostActionJobs;
|
|
|
|
if (this.isCluster) {
|
|
const operations: Promise<unknown>[] = [
|
|
statusKey === KEYS.runningJobs
|
|
? this.redis.sadd(KEYS.runningJobs, streamId)
|
|
: this.redis.srem(KEYS.runningJobs, streamId),
|
|
statusKey === KEYS.requiresActionJobs
|
|
? this.redis.sadd(KEYS.requiresActionJobs, streamId)
|
|
: this.redis.srem(KEYS.requiresActionJobs, streamId),
|
|
];
|
|
if (job?.terminalHostActionPending === true) {
|
|
operations.push(this.redis.sadd(terminalHostActionIndex, terminalMember!));
|
|
operations.push(this.redis.srem(otherTerminalHostActionIndex, terminalMember!));
|
|
if (terminalHostActionIndex !== KEYS.terminalHostActionJobs) {
|
|
operations.push(this.redis.srem(KEYS.terminalHostActionJobs, streamId));
|
|
}
|
|
} else if (terminalMember == null) {
|
|
operations.push(this.redis.srem(KEYS.terminalHostActionJobs, streamId));
|
|
operations.push(this.redis.srem(KEYS.detachedAgentEventTerminalHostActionJobsV1, streamId));
|
|
} else {
|
|
operations.push(this.redis.srem(KEYS.terminalHostActionJobs, streamId, terminalMember));
|
|
operations.push(
|
|
this.redis.srem(
|
|
KEYS.detachedAgentEventTerminalHostActionJobsV1,
|
|
streamId,
|
|
terminalMember,
|
|
),
|
|
);
|
|
}
|
|
// Terminal host-action membership follows the durable hash field, not status, so
|
|
// an aborted approval-expiry job stays enumerable for hook retry until acked.
|
|
for (const userJobsKey of observedUserKeys) {
|
|
if (userJobsKey !== activeUserKey) {
|
|
operations.push(this.redis.srem(userJobsKey, streamId));
|
|
}
|
|
}
|
|
if (activeUserKey) {
|
|
operations.push(
|
|
(async () => {
|
|
await this.redis.sadd(activeUserKey, streamId);
|
|
if (this.ttl.userJobsSet > 0) {
|
|
await this.redis.expire(activeUserKey, this.ttl.userJobsSet);
|
|
}
|
|
})(),
|
|
);
|
|
}
|
|
await Promise.all(operations);
|
|
return this.getJob(streamId);
|
|
}
|
|
|
|
const pipeline = this.redis.pipeline();
|
|
if (statusKey === KEYS.runningJobs) {
|
|
pipeline.sadd(KEYS.runningJobs, streamId);
|
|
} else {
|
|
pipeline.srem(KEYS.runningJobs, streamId);
|
|
}
|
|
if (statusKey === KEYS.requiresActionJobs) {
|
|
pipeline.sadd(KEYS.requiresActionJobs, streamId);
|
|
} else {
|
|
pipeline.srem(KEYS.requiresActionJobs, streamId);
|
|
}
|
|
if (job?.terminalHostActionPending === true) {
|
|
pipeline.sadd(terminalHostActionIndex, terminalMember!);
|
|
pipeline.srem(otherTerminalHostActionIndex, terminalMember!);
|
|
pipeline.srem(KEYS.terminalHostActionJobs, streamId);
|
|
} else if (terminalMember != null) {
|
|
pipeline.srem(KEYS.terminalHostActionJobs, streamId, terminalMember);
|
|
pipeline.srem(KEYS.detachedAgentEventTerminalHostActionJobsV1, streamId, terminalMember);
|
|
} else {
|
|
pipeline.srem(KEYS.terminalHostActionJobs, streamId);
|
|
pipeline.srem(KEYS.detachedAgentEventTerminalHostActionJobsV1, streamId);
|
|
}
|
|
for (const userJobsKey of observedUserKeys) {
|
|
if (userJobsKey !== activeUserKey) {
|
|
pipeline.srem(userJobsKey, streamId);
|
|
}
|
|
}
|
|
if (activeUserKey) {
|
|
pipeline.sadd(activeUserKey, streamId);
|
|
if (this.ttl.userJobsSet > 0) {
|
|
pipeline.expire(activeUserKey, this.ttl.userJobsSet);
|
|
}
|
|
}
|
|
// Keep the verification read in this network flush. Redis executes it
|
|
// after the membership commands, preserving the guarded loop without an
|
|
// extra round trip on the default single-node deployment.
|
|
pipeline.hgetall(KEYS.job(streamId));
|
|
const results = await pipeline.exec();
|
|
const verification = results?.[results.length - 1];
|
|
if (verification?.[0]) {
|
|
throw verification[0];
|
|
}
|
|
const data = verification?.[1] as Record<string, string> | null | undefined;
|
|
if (!data || Object.keys(data).length === 0) {
|
|
return null;
|
|
}
|
|
return this.deserializeJob(data);
|
|
}
|
|
|
|
/**
|
|
* Cross-slot sets are derived state, so every mutation writes the membership
|
|
* implied by the durable job hash and then reads the hash again. If a status
|
|
* change or replacement crossed that window, the loop repairs from the newer
|
|
* source. Because every writer uses this path, the final writer always
|
|
* converges even when an older reconciliation finishes later.
|
|
*/
|
|
private async reconcileJobMembership(
|
|
streamId: string,
|
|
options: {
|
|
initialJob?: SerializableJobData | null;
|
|
previousJob?: SerializableJobData | null;
|
|
previousUserKeys?: string[];
|
|
} = {},
|
|
): Promise<SerializableJobData | null | undefined> {
|
|
const observedUserKeys = new Set(options.previousUserKeys ?? []);
|
|
this.addObservedUserKey(observedUserKeys, options.previousJob ?? null);
|
|
let currentJob = options.initialJob ?? null;
|
|
try {
|
|
if (options.initialJob === undefined) {
|
|
currentJob = await this.getJob(streamId);
|
|
}
|
|
|
|
for (let attempt = 0; attempt < MEMBERSHIP_RECONCILE_MAX_ATTEMPTS; attempt++) {
|
|
this.addObservedUserKey(observedUserKeys, currentJob);
|
|
const verifiedJob = await this.applyMembershipSnapshot(
|
|
streamId,
|
|
currentJob,
|
|
observedUserKeys,
|
|
);
|
|
this.addObservedUserKey(observedUserKeys, verifiedJob);
|
|
if (this.sameMembershipSource(currentJob, verifiedJob)) {
|
|
return verifiedJob;
|
|
}
|
|
currentJob = verifiedJob;
|
|
}
|
|
|
|
logger.warn(
|
|
`[RedisJobStore] Membership reconciliation did not stabilize after ${MEMBERSHIP_RECONCILE_MAX_ATTEMPTS} attempts: ${streamId}`,
|
|
);
|
|
} catch (err) {
|
|
logger.warn(`[RedisJobStore] Failed to reconcile job membership ${streamId}:`, err);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private clearLocalStateUnlessActive(
|
|
streamId: string,
|
|
currentJob: SerializableJobData | null | undefined,
|
|
expectedCreatedAt?: number,
|
|
): void {
|
|
if (currentJob === undefined) {
|
|
return;
|
|
}
|
|
if (expectedCreatedAt == null && currentJob && this.statusSetKey(currentJob.status)) {
|
|
return;
|
|
}
|
|
this.clearLocalState(streamId, expectedCreatedAt);
|
|
}
|
|
|
|
/**
|
|
* Live-key TTL (seconds) for a paused job. A paused job isn't a hung
|
|
* generation, so it uses the longer requires_action backstop rather than the
|
|
* running TTL — otherwise a no-expiry approval (the buildPendingAction
|
|
* default), which the API treats as "live", would be evicted after the 20m
|
|
* running window. A pendingAction with an `expiresAt` farther out than the
|
|
* backstop extends to cover it, plus a grace margin so a decision arriving
|
|
* right at the deadline can still resume.
|
|
*/
|
|
private pauseTtlSeconds(pendingAction?: Agents.PendingAction): number {
|
|
const exp = pendingAction?.expiresAt;
|
|
if (exp == null) {
|
|
return this.ttl.requiresAction + GENERATION_EPOCH_GRACE_TTL_S;
|
|
}
|
|
const secondsUntilExpiry = Math.ceil((exp - Date.now()) / 1000) + 60;
|
|
return Math.max(this.ttl.requiresAction, secondsUntilExpiry) + GENERATION_EPOCH_GRACE_TTL_S;
|
|
}
|
|
|
|
/** Same grace lets cleanup park accepted words before Redis expires the
|
|
* live hash/list keys at the semantic stale cutoff. */
|
|
private runningStorageTtlSeconds(): number {
|
|
return this.ttl.running + GENERATION_EPOCH_GRACE_TTL_S;
|
|
}
|
|
|
|
private parkedRecoveryTtlSeconds(): number {
|
|
return this.ttl.completed > 0 ? this.ttl.completed : PARKED_RECOVERY_TTL_S;
|
|
}
|
|
|
|
/** The membership set a status belongs to; terminal statuses have none. */
|
|
private statusSetKey(status: JobStatus): string | null {
|
|
if (status === 'running') {
|
|
return KEYS.runningJobs;
|
|
}
|
|
if (status === 'requires_action') {
|
|
return KEYS.requiresActionJobs;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async transitionStatus(streamId: string, args: JobStatusTransition): Promise<boolean> {
|
|
return (await this.transitionStatusInternal(streamId, args, false)) === true;
|
|
}
|
|
|
|
async transitionStatusAndDrainSteers(
|
|
streamId: string,
|
|
args: JobStatusTransition,
|
|
): Promise<SteerQueueItem[] | null> {
|
|
if (this.statusSetKey(args.to) !== null) {
|
|
throw new Error('Steer-draining status transitions must be terminal');
|
|
}
|
|
const result = await this.transitionStatusInternal(streamId, args, true);
|
|
return Array.isArray(result) ? result : null;
|
|
}
|
|
|
|
private async transitionStatusInternal(
|
|
streamId: string,
|
|
args: JobStatusTransition,
|
|
returnDrainedSteers: boolean,
|
|
): Promise<true | SteerQueueItem[] | null> {
|
|
const { from, to, patch, clear, expectActionId, expectCreatedAt, notAfterMs } = args;
|
|
const key = KEYS.job(streamId);
|
|
const terminal = this.statusSetKey(to) === null;
|
|
const terminalJob = terminal ? await this.getJob(streamId) : null;
|
|
const detachedTerminalHostActionPending =
|
|
terminal &&
|
|
patch?.terminalHostActionPending === true &&
|
|
terminalJob != null &&
|
|
isDetachedAgentEventCompletionJob(terminalJob);
|
|
const persistedPatch = detachedTerminalHostActionPending
|
|
? {
|
|
...patch,
|
|
status: DETACHED_TERMINAL_LEGACY_STATUS,
|
|
terminalHostActionPending: undefined,
|
|
detachedAgentEventTerminalHostActionPending: true,
|
|
detachedAgentEventTerminalStatus: to as Extract<
|
|
JobStatus,
|
|
'complete' | 'aborted' | 'error'
|
|
>,
|
|
}
|
|
: patch;
|
|
|
|
// status + patch become HSET pairs; serializeJob skips undefined, so
|
|
// cleared fields go through HDEL (`clear`) instead.
|
|
const fields = Object.entries(
|
|
this.serializeJob({ status: to, ...(persistedPatch ?? {}) } as SerializableJobData),
|
|
).flat();
|
|
const clearFields = (clear ?? []).map(String);
|
|
|
|
let ttl = terminal ? this.ttl.completed : this.runningStorageTtlSeconds();
|
|
if (terminal && patch?.terminalPersistencePending === true) {
|
|
ttl = Math.max(ttl, TERMINAL_PERSISTENCE_RETENTION_TTL_S);
|
|
}
|
|
if (terminal && patch?.terminalHostActionPending === true) {
|
|
// A terminal job owing a host hook must outlive the normal completed TTL so cleanup
|
|
// can still enumerate and retry it across restarts; the pause backstop (24h) bounds
|
|
// the retry window. Cleared to the completed TTL on acknowledgement.
|
|
ttl = Math.max(ttl, this.ttl.requiresAction);
|
|
}
|
|
if (to === 'requires_action') {
|
|
// A paused job must outlive its approval window, even when that window is
|
|
// longer than the running TTL — otherwise Redis evicts it before a
|
|
// decision can resume it.
|
|
ttl = this.pauseTtlSeconds(patch?.pendingAction);
|
|
}
|
|
// Redis Cluster cannot atomically update the same-slot job hash and this
|
|
// global retry index. Arm a generation-scoped retry hint before the
|
|
// terminal CAS. A predecessor acknowledgement can remove only its own
|
|
// member, regardless of how a successor's SADD and CAS interleave.
|
|
const terminalMemberCreatedAt = expectCreatedAt ?? terminalJob?.createdAt;
|
|
if (terminal && patch?.terminalHostActionPending === true && terminalMemberCreatedAt != null) {
|
|
const terminalHostActionIndex =
|
|
terminalJob != null && isDetachedAgentEventCompletionJob(terminalJob)
|
|
? KEYS.detachedAgentEventTerminalHostActionJobsV1
|
|
: KEYS.terminalHostActionJobs;
|
|
await this.redis.sadd(
|
|
terminalHostActionIndex,
|
|
terminalHostActionMember(streamId, terminalMemberCreatedAt),
|
|
);
|
|
}
|
|
|
|
// 1) Single-winner decision: an atomic CAS on the single-slot job hash.
|
|
// Works identically on cluster and single-node, so two concurrent
|
|
// resolves can never both win (and drive the run twice).
|
|
const result = await this.redis.eval(
|
|
JOB_CAS_LUA,
|
|
10,
|
|
key,
|
|
KEYS.sequence(streamId),
|
|
KEYS.chunks(streamId),
|
|
KEYS.runSteps(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
KEYS.generationEpoch(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.steerReceiptOrder(streamId),
|
|
from,
|
|
expectActionId ?? '',
|
|
expectCreatedAt != null ? String(expectCreatedAt) : '',
|
|
notAfterMs != null ? String(notAfterMs) : '',
|
|
String(ttl),
|
|
terminal ? '1' : '0',
|
|
String(
|
|
terminal && patch?.terminalHostActionPending === true ? ttl : this.ttl.chunksAfterComplete,
|
|
),
|
|
String(
|
|
terminal && patch?.terminalHostActionPending === true
|
|
? ttl
|
|
: this.ttl.runStepsAfterComplete,
|
|
),
|
|
String(this.parkedRecoveryTtlSeconds()),
|
|
String(GENERATION_EPOCH_GRACE_TTL_S),
|
|
String(args.steerReceiptTtlSeconds ?? 0),
|
|
returnDrainedSteers ? '1' : '0',
|
|
String(clearFields.length),
|
|
...clearFields,
|
|
...fields,
|
|
);
|
|
if (result === -1 && notAfterMs != null) {
|
|
throw new JobStatusTransitionDeadlineError(notAfterMs);
|
|
}
|
|
if (returnDrainedSteers ? typeof result !== 'string' : result !== 1) {
|
|
return null;
|
|
}
|
|
|
|
// 2) Same-slot TTL/content changes happened atomically in the CAS. Cross-slot
|
|
// indexes are reconciled last and verified against the durable hash.
|
|
const currentJob = await this.reconcileJobMembership(streamId, {
|
|
previousJob: terminalJob,
|
|
});
|
|
if (terminal) {
|
|
this.clearLocalStateUnlessActive(
|
|
streamId,
|
|
currentJob,
|
|
expectCreatedAt ?? terminalJob?.createdAt,
|
|
);
|
|
}
|
|
if (!returnDrainedSteers) {
|
|
return true;
|
|
}
|
|
const drained = JSON.parse(result as string) as unknown;
|
|
if (!Array.isArray(drained)) {
|
|
throw new Error('Invalid terminal steer drain response');
|
|
}
|
|
return drained as SteerQueueItem[];
|
|
}
|
|
|
|
async claimIdempotencyKey(
|
|
key: string,
|
|
value: IdempotencyClaimValue,
|
|
ttlSeconds: number,
|
|
): Promise<IdempotencyClaimResult> {
|
|
const result = await this.redis.eval(
|
|
IDEMPOTENCY_CLAIM_LUA,
|
|
1,
|
|
KEYS.idempotency(key),
|
|
JSON.stringify(value),
|
|
String(ttlSeconds * 1000),
|
|
);
|
|
if (result == null) {
|
|
return { claimed: true, existing: value };
|
|
}
|
|
try {
|
|
return { claimed: false, existing: JSON.parse(result as string) as IdempotencyClaimValue };
|
|
} catch {
|
|
// An unreadable existing owner is outcome-ambiguous. Never turn store
|
|
// corruption into a duplicate generation by pretending the key is free.
|
|
throw new Error('Invalid generation idempotency claim');
|
|
}
|
|
}
|
|
|
|
async takeoverIdempotencyKey(
|
|
key: string,
|
|
expected: IdempotencyClaimValue,
|
|
value: IdempotencyClaimValue,
|
|
ttlSeconds: number,
|
|
): Promise<boolean> {
|
|
const taken = await this.redis.eval(
|
|
IDEMPOTENCY_TAKEOVER_LUA,
|
|
1,
|
|
KEYS.idempotency(key),
|
|
expected.claimToken ?? '',
|
|
JSON.stringify(value),
|
|
String(ttlSeconds * 1000),
|
|
);
|
|
return taken === 1;
|
|
}
|
|
|
|
async markIdempotencyKeyStarted(
|
|
key: string,
|
|
claimToken: string,
|
|
startedAt: number,
|
|
ttlSeconds: number,
|
|
): Promise<boolean> {
|
|
const marked = await this.redis.eval(
|
|
IDEMPOTENCY_MARK_STARTED_LUA,
|
|
1,
|
|
KEYS.idempotency(key),
|
|
claimToken,
|
|
String(startedAt),
|
|
String(ttlSeconds * 1000),
|
|
);
|
|
return marked === 1;
|
|
}
|
|
|
|
async adoptIdempotencyKeyForJob(
|
|
key: string,
|
|
expected: IdempotencyClaimValue,
|
|
streamId: string,
|
|
userId: string,
|
|
clientRequestId: string,
|
|
tenantId: string | undefined,
|
|
expectedCreatedAt: number,
|
|
ttlSeconds: number,
|
|
allowMissingClientRequestId = false,
|
|
): Promise<boolean> {
|
|
const adopted = await this.redis.eval(
|
|
IDEMPOTENCY_ADOPT_LIVE_JOB_LUA,
|
|
2,
|
|
KEYS.idempotency(key),
|
|
KEYS.job(streamId),
|
|
expected.claimToken ?? '',
|
|
String(expectedCreatedAt),
|
|
userId,
|
|
clientRequestId,
|
|
tenantId ?? '',
|
|
String(ttlSeconds * 1000),
|
|
allowMissingClientRequestId ? '1' : '0',
|
|
);
|
|
return adopted === 1;
|
|
}
|
|
|
|
async releaseIdempotencyKey(key: string, expected?: IdempotencyClaimValue): Promise<void> {
|
|
await this.redis.eval(
|
|
IDEMPOTENCY_RELEASE_LUA,
|
|
1,
|
|
KEYS.idempotency(key),
|
|
expected?.claimToken ?? '',
|
|
);
|
|
}
|
|
|
|
async deleteJob(streamId: string, expectedCreatedAt?: number): Promise<boolean> {
|
|
const observedJob = await this.getJob(streamId);
|
|
const targetCreatedAt = expectedCreatedAt ?? observedJob?.createdAt;
|
|
const expectMissing = expectedCreatedAt == null && observedJob == null;
|
|
const deleted = await this.redis.eval(
|
|
JOB_DELETE_LUA,
|
|
5,
|
|
KEYS.job(streamId),
|
|
KEYS.chunks(streamId),
|
|
KEYS.runSteps(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
targetCreatedAt != null ? String(targetCreatedAt) : '',
|
|
expectMissing ? '1' : '0',
|
|
);
|
|
if (deleted !== 1) {
|
|
return false;
|
|
}
|
|
|
|
const currentJob = await this.reconcileJobMembership(streamId, {
|
|
initialJob: null,
|
|
previousJob: observedJob,
|
|
});
|
|
this.clearLocalStateUnlessActive(streamId, currentJob, targetCreatedAt);
|
|
logger.debug(`[RedisJobStore] Deleted job: ${streamId}`);
|
|
return true;
|
|
}
|
|
|
|
private async deleteStaleRunningJob(
|
|
streamId: string,
|
|
observedJob: SerializableJobData,
|
|
now: number,
|
|
): Promise<boolean> {
|
|
const deleted = await this.redis.eval(
|
|
STALE_JOB_DELETE_LUA,
|
|
9,
|
|
KEYS.job(streamId),
|
|
KEYS.chunks(streamId),
|
|
KEYS.runSteps(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
KEYS.generationEpoch(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.steerReceiptOrder(streamId),
|
|
String(observedJob.createdAt),
|
|
String(now),
|
|
String(this.ttl.running * 1000),
|
|
String(this.ttl.completed > 0 ? this.ttl.completed : PARKED_RECOVERY_TTL_S),
|
|
String(GENERATION_EPOCH_GRACE_TTL_S),
|
|
);
|
|
if (deleted !== 1) {
|
|
return false;
|
|
}
|
|
|
|
const currentJob = await this.reconcileJobMembership(streamId, {
|
|
initialJob: null,
|
|
previousJob: observedJob,
|
|
});
|
|
this.clearLocalStateUnlessActive(streamId, currentJob, observedJob.createdAt);
|
|
return true;
|
|
}
|
|
|
|
async hasJob(streamId: string): Promise<boolean> {
|
|
const exists = await this.redis.exists(KEYS.job(streamId));
|
|
return exists === 1;
|
|
}
|
|
|
|
async getRunningJobs(): Promise<SerializableJobData[]> {
|
|
const streamIds = await this.redis.smembers(KEYS.runningJobs);
|
|
if (streamIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const jobs: SerializableJobData[] = [];
|
|
for (const streamId of streamIds) {
|
|
const job = await this.getJob(streamId);
|
|
if (job && job.status === 'running') {
|
|
jobs.push(job);
|
|
}
|
|
}
|
|
return jobs;
|
|
}
|
|
|
|
async getRequiresActionJobs(): Promise<SerializableJobData[]> {
|
|
const streamIds = await this.redis.smembers(KEYS.requiresActionJobs);
|
|
if (streamIds.length === 0) {
|
|
return [];
|
|
}
|
|
const jobs = await Promise.all(streamIds.map((streamId) => this.getJob(streamId)));
|
|
return jobs.filter(
|
|
(job): job is SerializableJobData => job != null && job.status === 'requires_action',
|
|
);
|
|
}
|
|
|
|
async getTerminalHostActionJobs(): Promise<SerializableJobData[]> {
|
|
return this.getIndexedTerminalHostActionJobs(KEYS.terminalHostActionJobs, false);
|
|
}
|
|
|
|
async getDetachedAgentEventTerminalHostActionJobs(): Promise<SerializableJobData[]> {
|
|
return this.getIndexedTerminalHostActionJobs(
|
|
KEYS.detachedAgentEventTerminalHostActionJobsV1,
|
|
true,
|
|
);
|
|
}
|
|
|
|
private async getIndexedTerminalHostActionJobs(
|
|
indexKey: string,
|
|
detachedAgentEventCompletion: boolean,
|
|
): Promise<SerializableJobData[]> {
|
|
const members = await this.redis.smembers(indexKey);
|
|
if (members.length === 0) {
|
|
return [];
|
|
}
|
|
const indexed = members.map((member) => ({ member, ...parseTerminalHostActionMember(member) }));
|
|
const jobs = await Promise.all(indexed.map(({ streamId }) => this.getJob(streamId)));
|
|
// The durable hash field is the source of truth; a stale set entry (job reaped, or the
|
|
// marker/generation already replaced) is filtered out. Bare legacy members
|
|
// are migrated while they are still live.
|
|
const stale: string[] = [];
|
|
const legacy: string[] = [];
|
|
const migrate: string[] = [];
|
|
const rerouteToLegacy: string[] = [];
|
|
const rerouteToDetached: string[] = [];
|
|
const heldByGeneration = new Map<string, SerializableJobData>();
|
|
const readyByGeneration = new Map<string, SerializableJobData>();
|
|
const providerLossCutoff = Date.now() - PROVIDER_DRAIN_TIMEOUT_MS;
|
|
for (let i = 0; i < indexed.length; i++) {
|
|
const indexedMember = indexed[i];
|
|
let job = jobs[i];
|
|
if (
|
|
job != null &&
|
|
job.terminalHostActionPending === true &&
|
|
(indexedMember.createdAt == null || indexedMember.createdAt === job.createdAt)
|
|
) {
|
|
const jobIsDetachedCompletion = isDetachedAgentEventCompletionJob(job);
|
|
if (jobIsDetachedCompletion !== detachedAgentEventCompletion) {
|
|
const generationMember = terminalHostActionMember(job.streamId, job.createdAt);
|
|
if (jobIsDetachedCompletion) {
|
|
rerouteToDetached.push(generationMember);
|
|
} else {
|
|
rerouteToLegacy.push(generationMember);
|
|
}
|
|
stale.push(indexedMember.member);
|
|
continue;
|
|
}
|
|
if (indexedMember.createdAt == null) {
|
|
legacy.push(indexedMember.member);
|
|
migrate.push(terminalHostActionMember(job.streamId, job.createdAt));
|
|
}
|
|
if (
|
|
job.providerDrained === false &&
|
|
job.completedAt != null &&
|
|
job.completedAt <= providerLossCutoff
|
|
) {
|
|
const recovered =
|
|
Number(
|
|
await this.redis.eval(
|
|
RECOVER_TERMINAL_PROVIDER_DRAIN_LUA,
|
|
1,
|
|
KEYS.job(job.streamId),
|
|
String(job.createdAt),
|
|
String(providerLossCutoff),
|
|
),
|
|
) === 1;
|
|
if (recovered) {
|
|
job = { ...job, providerDrained: true };
|
|
}
|
|
}
|
|
const generationKey = terminalHostActionMember(job.streamId, job.createdAt);
|
|
heldByGeneration.set(generationKey, job);
|
|
// A terminal provider can still be committing its last tool result.
|
|
// The provider owner persists the complete run-step snapshot before it
|
|
// flips this fence, so another replica must not settle earlier.
|
|
if (job.providerDrained !== false) {
|
|
readyByGeneration.set(generationKey, job);
|
|
}
|
|
} else if (
|
|
job != null &&
|
|
indexedMember.createdAt != null &&
|
|
indexedMember.createdAt === job.createdAt
|
|
) {
|
|
/** The terminal transition pre-arms this exact generation before its
|
|
* hash CAS. Do not delete that hint merely because the CAS has not
|
|
* become visible yet: the producer can commit and die before its
|
|
* post-CAS reconciliation. The hint becomes removable once the exact
|
|
* generation hash is replaced or reaped. */
|
|
continue;
|
|
} else {
|
|
stale.push(indexedMember.member);
|
|
}
|
|
}
|
|
// Repair a hint written by an earlier capable build before removing it
|
|
// from the wrong lane. In particular, this drains detached completions out
|
|
// of the legacy set without ever returning them to a capable claimant.
|
|
if (rerouteToLegacy.length > 0) {
|
|
await this.redis.sadd(KEYS.terminalHostActionJobs, ...rerouteToLegacy);
|
|
}
|
|
if (rerouteToDetached.length > 0) {
|
|
await this.redis.sadd(KEYS.detachedAgentEventTerminalHostActionJobsV1, ...rerouteToDetached);
|
|
}
|
|
if (migrate.length > 0) {
|
|
try {
|
|
await this.redis.sadd(indexKey, ...migrate);
|
|
await this.redis.srem(indexKey, ...legacy);
|
|
} catch {
|
|
// Preserve the legacy hint if migration cannot prove the replacement
|
|
// member was written. A duplicate hint is safer than lost discovery.
|
|
}
|
|
}
|
|
if (stale.length > 0) {
|
|
await this.redis.srem(indexKey, ...stale).catch(() => undefined);
|
|
}
|
|
// Enumerating IS the retry attempt: extend each pending job's TTL so unacknowledged
|
|
// host-action evidence outlives a host dependency (e.g. Mongo) that stays unreachable
|
|
// longer than the retention window. A deployment that stops sweeping lets it age out.
|
|
const held = [...heldByGeneration.values()];
|
|
if (held.length > 0 && this.ttl.requiresAction > 0) {
|
|
await Promise.all(
|
|
held.flatMap((job) =>
|
|
[KEYS.job(job.streamId), KEYS.chunks(job.streamId), KEYS.runSteps(job.streamId)].map(
|
|
(key) => this.redis.expire(key, this.ttl.requiresAction).catch(() => undefined),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return [...readyByGeneration.values()];
|
|
}
|
|
|
|
async clearTerminalHostAction(streamId: string, expectedCreatedAt?: number): Promise<void> {
|
|
// Identity-fenced: only clear when the hash still holds this exact generation, so a
|
|
// replacement at the same streamId is never cleared through its predecessor. The HDEL
|
|
// and configured evidence-TTL reset happen atomically. The global retry
|
|
// member includes this generation, so removing it cannot affect a successor.
|
|
const cleared = (await this.redis.eval(
|
|
'if redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return 0 end ' +
|
|
'local detachedStatus = redis.call("HGET", KEYS[1], "detachedAgentEventTerminalStatus") ' +
|
|
'if detachedStatus then redis.call("HSET", KEYS[1], "status", detachedStatus) end ' +
|
|
'redis.call("HDEL", KEYS[1], "terminalHostActionPending", "detachedAgentEventTerminalHostActionPending", "detachedAgentEventTerminalStatus") ' +
|
|
'if detachedStatus then redis.call("HDEL", KEYS[1], "lastActiveAt") end ' +
|
|
'if tonumber(ARGV[2]) > 0 then redis.call("EXPIRE", KEYS[1], ARGV[2]) else redis.call("DEL", KEYS[1]) end ' +
|
|
'if tonumber(ARGV[3]) > 0 then redis.call("EXPIRE", KEYS[2], ARGV[3]) else redis.call("DEL", KEYS[2]) end ' +
|
|
'if tonumber(ARGV[4]) > 0 then redis.call("EXPIRE", KEYS[3], ARGV[4]) else redis.call("DEL", KEYS[3]) end ' +
|
|
'return 1',
|
|
3,
|
|
KEYS.job(streamId),
|
|
KEYS.chunks(streamId),
|
|
KEYS.runSteps(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
String(this.ttl.completed),
|
|
String(this.ttl.chunksAfterComplete),
|
|
String(this.ttl.runStepsAfterComplete),
|
|
)) as number;
|
|
if (cleared === 1 && expectedCreatedAt != null) {
|
|
const member = terminalHostActionMember(streamId, expectedCreatedAt);
|
|
await Promise.all([
|
|
this.redis.srem(KEYS.terminalHostActionJobs, member).catch(() => undefined),
|
|
this.redis
|
|
.srem(KEYS.detachedAgentEventTerminalHostActionJobsV1, member)
|
|
.catch(() => undefined),
|
|
]);
|
|
}
|
|
}
|
|
|
|
async cleanup(): Promise<number> {
|
|
const now = Date.now();
|
|
const streamIds = await this.redis.smembers(KEYS.runningJobs);
|
|
let cleaned = 0;
|
|
|
|
// Clean up stale local graph cache entries (WeakRefs that were collected)
|
|
for (const [streamId, graphEntry] of this.localGraphCache) {
|
|
if (!graphEntry.value.deref()) {
|
|
this.deleteLocalEntry(this.localGraphCache, streamId, undefined, graphEntry);
|
|
}
|
|
}
|
|
|
|
// Process in batches of 50 to avoid sequential per-job round-trips
|
|
const BATCH_SIZE = 50;
|
|
for (let i = 0; i < streamIds.length; i += BATCH_SIZE) {
|
|
const batch = streamIds.slice(i, i + BATCH_SIZE);
|
|
const results = await Promise.allSettled(
|
|
batch.map(async (streamId) => {
|
|
const job = await this.getJob(streamId);
|
|
|
|
// Job no longer exists (TTL expired) - remove from set
|
|
if (!job) {
|
|
const currentJob = await this.reconcileJobMembership(streamId, { initialJob: null });
|
|
this.clearLocalStateUnlessActive(streamId, currentJob);
|
|
return 1;
|
|
}
|
|
|
|
if (job.status === 'requires_action') {
|
|
const currentJob = await this.reconcileJobMembership(streamId, { initialJob: job });
|
|
if (
|
|
currentJob !== undefined &&
|
|
(!currentJob || currentJob.createdAt === job.createdAt)
|
|
) {
|
|
this.clearLocalState(streamId, job.createdAt);
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// Job completed but still in running set (shouldn't happen, but handle it)
|
|
// Only remove from tracking sets — do NOT delete the job hash, which has
|
|
// its own completedTtl so clients can still poll for final status.
|
|
if (job.status !== 'running') {
|
|
const currentJob = await this.reconcileJobMembership(streamId, {
|
|
initialJob: job,
|
|
previousJob: job,
|
|
});
|
|
if (
|
|
currentJob !== undefined &&
|
|
(!currentJob || currentJob.createdAt === job.createdAt)
|
|
) {
|
|
this.clearLocalStateUnlessActive(streamId, currentJob, job.createdAt);
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// Stale running job (failsafe - running for > configured TTL).
|
|
// Keys off `lastActiveAt` when present so a just-resumed approval
|
|
// isn't reaped on the basis of its original creation time.
|
|
const liveSince = job.lastActiveAt ?? job.createdAt;
|
|
if (now - liveSince > this.ttl.running * 1000) {
|
|
logger.warn(`[RedisJobStore] Cleaning up stale job: ${streamId}`);
|
|
// Re-check liveness + epoch, park queued steers, and delete same-slot
|
|
// state in one script. A replacement cannot land in the old
|
|
// park-then-unconditional-delete gap.
|
|
return (await this.deleteStaleRunningJob(streamId, job, now)) ? 1 : 0;
|
|
}
|
|
|
|
return 0;
|
|
}),
|
|
);
|
|
for (const result of results) {
|
|
if (result.status === 'fulfilled') {
|
|
cleaned += result.value;
|
|
} else {
|
|
logger.warn(`[RedisJobStore] Cleanup failed for a job:`, result.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
cleaned += await this.cleanupRequiresActionIndex();
|
|
|
|
if (cleaned > 0) {
|
|
logger.debug(`[RedisJobStore] Cleaned up ${cleaned} jobs`);
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
private async cleanupRequiresActionIndex(): Promise<number> {
|
|
const streamIds = await this.redis.smembers(KEYS.requiresActionJobs);
|
|
let cleaned = 0;
|
|
const now = Date.now();
|
|
|
|
const BATCH_SIZE = 50;
|
|
for (let i = 0; i < streamIds.length; i += BATCH_SIZE) {
|
|
const batch = streamIds.slice(i, i + BATCH_SIZE);
|
|
const results = await Promise.allSettled(
|
|
batch.map(async (streamId) => {
|
|
const job = await this.getJob(streamId);
|
|
|
|
if (!job) {
|
|
const currentJob = await this.reconcileJobMembership(streamId, { initialJob: null });
|
|
this.clearLocalStateUnlessActive(streamId, currentJob);
|
|
return 1;
|
|
}
|
|
|
|
if (job.status !== 'requires_action') {
|
|
await this.reconcileJobMembership(streamId, {
|
|
initialJob: job,
|
|
previousJob: job,
|
|
});
|
|
return 1;
|
|
}
|
|
|
|
if (job.terminalPersistencePending === true) {
|
|
const startedAt = job.terminalPersistenceStartedAt ?? job.createdAt;
|
|
if (now - startedAt < PAUSE_PERSISTENCE_TIMEOUT_MS) {
|
|
// The pause owner is still within its response-write lease.
|
|
return 0;
|
|
}
|
|
|
|
if (job.pendingActionId == null) {
|
|
logger.error(
|
|
`[RedisJobStore] Refusing stale pause-persistence cleanup without an action fence: ${streamId}`,
|
|
);
|
|
return 0;
|
|
}
|
|
const failed = await this.transitionStatusAndDrainSteers(streamId, {
|
|
from: 'requires_action',
|
|
to: 'error',
|
|
expectActionId: job.pendingActionId,
|
|
expectCreatedAt: job.createdAt,
|
|
patch: {
|
|
completedAt: now,
|
|
error: PAUSE_PERSISTENCE_TIMEOUT_ERROR,
|
|
...(job.agentEventDeliveryKey != null && { terminalHostActionPending: true }),
|
|
},
|
|
clear: [
|
|
'pendingAction',
|
|
'pendingActionId',
|
|
'terminalPersistencePending',
|
|
'terminalPersistenceStartedAt',
|
|
],
|
|
});
|
|
if (failed == null) {
|
|
return 0;
|
|
}
|
|
logger.error(`[RedisJobStore] Pause persistence timed out: ${streamId}`);
|
|
return 1;
|
|
}
|
|
|
|
// Stale approval (expired, or missing/malformed pendingAction):
|
|
// finalize it (aborted) so it stops occupying the slot and its stream
|
|
// contents are reclaimed, mirroring ApprovalLifecycle.expire().
|
|
// transitionStatus atomically applies the terminal state and same-slot
|
|
// content cleanup. Cross-slot membership indexes self-heal on read or
|
|
// during the next cleanup pass.
|
|
const exceededNoExpiryBackstop =
|
|
job.pendingAction?.expiresAt == null &&
|
|
now - (job.lastActiveAt ?? job.createdAt) > this.ttl.requiresAction * 1000;
|
|
if (isPendingActionStale(job) || exceededNoExpiryBackstop) {
|
|
const expired = await this.transitionStatus(streamId, {
|
|
from: 'requires_action',
|
|
to: 'aborted',
|
|
clear: ['pendingAction', 'pendingActionId'],
|
|
patch: {
|
|
error: 'Approval expired before a decision was made',
|
|
completedAt: Date.now(),
|
|
// Store-won expiry: mark the host action pending (and extend retention via
|
|
// the TTL rule above) so the manager relay still runs its lifecycle hook on
|
|
// a replica that owns the runtime; cleared once that hook acknowledges.
|
|
terminalHostActionPending: true,
|
|
},
|
|
// Scope the CAS to the action we observed as stale: if the user resolved it
|
|
// and the run re-paused on a fresh action between the read and here, the
|
|
// pendingActionId no longer matches and this no-ops instead of aborting the
|
|
// valid new pause. (Undefined for a missing/malformed pendingAction — nothing
|
|
// to protect — so it falls back to the status-only check.)
|
|
expectActionId: job.pendingAction?.actionId,
|
|
expectCreatedAt: job.createdAt,
|
|
});
|
|
return expired ? 1 : 0;
|
|
}
|
|
|
|
return 0;
|
|
}),
|
|
);
|
|
|
|
for (const result of results) {
|
|
if (result.status === 'fulfilled') {
|
|
cleaned += result.value;
|
|
} else {
|
|
logger.warn(`[RedisJobStore] requires_action cleanup failed for a job:`, result.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
async getJobCount(): Promise<number> {
|
|
const [runningCount, requiresActionCount] = await Promise.all([
|
|
this.countJobsInStatusSet(KEYS.runningJobs, 'running'),
|
|
this.countJobsInStatusSet(KEYS.requiresActionJobs, 'requires_action'),
|
|
]);
|
|
return runningCount + requiresActionCount;
|
|
}
|
|
|
|
async getJobCountByStatus(status: JobStatus): Promise<number> {
|
|
if (status === 'running') {
|
|
return this.countJobsInStatusSet(KEYS.runningJobs, status);
|
|
}
|
|
|
|
if (status === 'requires_action') {
|
|
return this.countJobsInStatusSet(KEYS.requiresActionJobs, status);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private async countJobsInStatusSet(setKey: string, status: JobStatus): Promise<number> {
|
|
const streamIds = await this.redis.smembers(setKey);
|
|
if (streamIds.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
let count = 0;
|
|
for (const streamId of streamIds) {
|
|
const job = await this.getJob(streamId);
|
|
if (job?.status === status) {
|
|
count++;
|
|
} else {
|
|
const currentJob = await this.reconcileJobMembership(streamId, {
|
|
initialJob: job,
|
|
previousJob: job,
|
|
});
|
|
if (currentJob?.status === status) {
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Get active job IDs for a user.
|
|
* Returns conversation IDs of running jobs belonging to the user.
|
|
* Also performs self-healing cleanup: removes stale entries for jobs that no longer exist.
|
|
*
|
|
* @param userId - The user ID to query
|
|
* @returns Array of conversation IDs with active jobs
|
|
*/
|
|
async getActiveJobIdsByUser(userId: string, tenantId?: string): Promise<string[]> {
|
|
return this.getJobIdsByUser(userId, tenantId, false);
|
|
}
|
|
|
|
async getCleanupBlockingJobIdsByUser(userId: string, tenantId?: string): Promise<string[]> {
|
|
return this.getJobIdsByUser(userId, tenantId, true);
|
|
}
|
|
|
|
private async getJobIdsByUser(
|
|
userId: string,
|
|
tenantId: string | undefined,
|
|
includeUndrained: boolean,
|
|
): Promise<string[]> {
|
|
const userJobsKey = KEYS.userJobs(userId, tenantId);
|
|
const trackedIds = await this.redis.smembers(userJobsKey);
|
|
|
|
if (trackedIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const activeIds: string[] = [];
|
|
let healed = 0;
|
|
|
|
for (const streamId of trackedIds) {
|
|
const job = await this.getJob(streamId);
|
|
// Include running jobs and jobs paused for human review (e.g. tool approval).
|
|
// A pending-approval job still occupies the user's conversation slot — but
|
|
// only while its prompt is live: a past-`expiresAt` approval no longer
|
|
// counts as active (cleanup/expiry will finalize it), so the client stops
|
|
// polling and can complete.
|
|
const belongsToUser =
|
|
job?.userId === userId && (job.tenantId ?? undefined) === (tenantId ?? undefined);
|
|
if (
|
|
belongsToUser &&
|
|
job &&
|
|
(job.status === 'running' ||
|
|
job.status === 'requires_action' ||
|
|
(includeUndrained && job.providerDrained === false))
|
|
) {
|
|
if (
|
|
job.status === 'requires_action' &&
|
|
isPendingActionStale(job) &&
|
|
!(includeUndrained && job.providerDrained === false)
|
|
) {
|
|
continue;
|
|
}
|
|
activeIds.push(streamId);
|
|
} else {
|
|
// Self-heal from durable state instead of a raw SREM, which could remove
|
|
// a replacement's membership after the read.
|
|
const currentJob = await this.reconcileJobMembership(streamId, {
|
|
initialJob: job,
|
|
previousJob: job,
|
|
previousUserKeys: [userJobsKey],
|
|
});
|
|
const currentBelongsToUser =
|
|
currentJob?.userId === userId &&
|
|
(currentJob.tenantId ?? undefined) === (tenantId ?? undefined);
|
|
if (
|
|
currentBelongsToUser &&
|
|
currentJob &&
|
|
(currentJob.status === 'running' ||
|
|
currentJob.status === 'requires_action' ||
|
|
(includeUndrained && currentJob.providerDrained === false)) &&
|
|
!(
|
|
currentJob.status === 'requires_action' &&
|
|
isPendingActionStale(currentJob) &&
|
|
!(includeUndrained && currentJob.providerDrained === false)
|
|
)
|
|
) {
|
|
activeIds.push(streamId);
|
|
}
|
|
healed++;
|
|
}
|
|
}
|
|
|
|
if (healed > 0) {
|
|
logger.debug(`[RedisJobStore] Self-healed ${healed} stale job entries for user ${userId}`);
|
|
}
|
|
|
|
return activeIds;
|
|
}
|
|
|
|
async destroy(): Promise<void> {
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval);
|
|
this.cleanupInterval = null;
|
|
}
|
|
/** Shutdown terminals flushed per stream already; whatever remains did not
|
|
* commit, and resolving false lets the owning fence continuations settle. */
|
|
for (const [streamId, pending] of this.pendingAppends) {
|
|
this.pendingAppends.delete(streamId);
|
|
if (pending.timer != null) {
|
|
clearTimeout(pending.timer);
|
|
pending.timer = null;
|
|
}
|
|
for (const settler of pending.settlers) {
|
|
settler.resolve(false);
|
|
}
|
|
}
|
|
// Clear local caches
|
|
this.localGraphCache.clear();
|
|
this.localContentParts.clear();
|
|
this.localCollectedUsageCache.clear();
|
|
// Don't close the Redis connection - it's shared
|
|
logger.info('[RedisJobStore] Destroyed');
|
|
}
|
|
|
|
// ===== Content State Methods =====
|
|
// For Redis, content is primarily reconstructed from chunks.
|
|
// However, we keep a LOCAL graph cache for fast same-instance reconnects.
|
|
|
|
/**
|
|
* Store graph reference in local cache.
|
|
* This enables fast reconnects when client returns to the same instance.
|
|
* Falls back to Redis chunk reconstruction for cross-instance reconnects.
|
|
*
|
|
* @param streamId - The stream identifier
|
|
* @param graph - The graph instance (stored as WeakRef)
|
|
*/
|
|
setGraph(streamId: string, graph: StandardGraph, expectedCreatedAt?: number): void {
|
|
this.setLocalEntry(this.localGraphCache, streamId, {
|
|
createdAt: expectedCreatedAt,
|
|
value: new WeakRef(graph),
|
|
});
|
|
}
|
|
|
|
/** Splice-inserts host-authored parts (steers from `on_steer_applied`,
|
|
* activity labels from `on_activity_label` chunks) into an SDK-graph
|
|
* content view, ascending by recorded index so each host-view position
|
|
* lands exactly where live clients saw it. Label events fire twice per
|
|
* slot (placeholder, then filled); chronological last-wins keeps the
|
|
* resolved label. */
|
|
private async overlayHostSteerParts(
|
|
streamId: string,
|
|
parts: Agents.MessageContentComplex[],
|
|
expectedCreatedAt?: number,
|
|
): Promise<Agents.MessageContentComplex[]> {
|
|
const chunks = await this.getChunks(streamId, expectedCreatedAt);
|
|
if (chunks.length === 0) {
|
|
return parts;
|
|
}
|
|
const steers: Array<{ index: number; part: Agents.MessageContentComplex }> = [];
|
|
const labelsByIndex = new Map<number, Agents.MessageContentComplex>();
|
|
const reasoningStepsByIndex = new Map<number, string>();
|
|
const reasoningAttemptsByIndex = new Map<number, ReasoningAttemptOverlay>();
|
|
const reasoningLabelsByIndex = new Map<number, ReasoningLabelOverlay>();
|
|
let reasoningAttemptHighWater = 0;
|
|
for (const chunk of chunks) {
|
|
const event = chunk as { event?: string; data?: unknown };
|
|
if (event.event === 'on_run_step') {
|
|
const step = event.data as {
|
|
id?: string;
|
|
index?: number;
|
|
stepDetails?: { message_creation?: { content_type?: string } };
|
|
};
|
|
if (
|
|
typeof step.id === 'string' &&
|
|
typeof step.index === 'number' &&
|
|
step.stepDetails?.message_creation?.content_type === ContentTypes.THINK
|
|
) {
|
|
reasoningStepsByIndex.set(step.index, step.id);
|
|
}
|
|
continue;
|
|
}
|
|
if (event.event === 'on_steer_applied') {
|
|
const steerData = event.data as { index?: number; part?: Agents.MessageContentComplex };
|
|
if (typeof steerData.index === 'number' && steerData.part != null) {
|
|
steers.push({ index: steerData.index, part: steerData.part });
|
|
}
|
|
continue;
|
|
}
|
|
if (event.event === 'on_activity_label') {
|
|
const labelData = event.data as { index?: number; part?: Agents.MessageContentComplex };
|
|
if (typeof labelData.index === 'number' && labelData.part != null) {
|
|
labelsByIndex.set(labelData.index, labelData.part);
|
|
}
|
|
continue;
|
|
}
|
|
if (event.event === 'on_reasoning_label_attempt') {
|
|
const attempt = event.data as {
|
|
index?: number;
|
|
stepId?: string;
|
|
attempts?: number;
|
|
submittedChars?: number;
|
|
};
|
|
if (
|
|
typeof attempt.index === 'number' &&
|
|
typeof attempt.stepId === 'string' &&
|
|
typeof attempt.attempts === 'number'
|
|
) {
|
|
reasoningAttemptsByIndex.set(attempt.index, {
|
|
stepId: attempt.stepId,
|
|
attempts: attempt.attempts,
|
|
...(typeof attempt.submittedChars === 'number' && {
|
|
submittedChars: attempt.submittedChars,
|
|
}),
|
|
});
|
|
reasoningAttemptHighWater = Math.max(reasoningAttemptHighWater, attempt.attempts);
|
|
}
|
|
continue;
|
|
}
|
|
if (event.event === 'on_reasoning_label') {
|
|
const labelData = event.data as {
|
|
index?: number;
|
|
stepId?: string;
|
|
revision?: number;
|
|
label?: string;
|
|
status?: 'streaming' | 'complete';
|
|
};
|
|
if (
|
|
typeof labelData.index === 'number' &&
|
|
typeof labelData.stepId === 'string' &&
|
|
typeof labelData.revision === 'number' &&
|
|
typeof labelData.label === 'string'
|
|
) {
|
|
reasoningLabelsByIndex.set(labelData.index, {
|
|
stepId: labelData.stepId,
|
|
revision: labelData.revision,
|
|
label: labelData.label,
|
|
status: labelData.status === 'complete' ? 'complete' : 'streaming',
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
if (
|
|
steers.length === 0 &&
|
|
labelsByIndex.size === 0 &&
|
|
reasoningStepsByIndex.size === 0 &&
|
|
reasoningAttemptHighWater === 0 &&
|
|
reasoningLabelsByIndex.size === 0
|
|
) {
|
|
return parts;
|
|
}
|
|
const inserts = [
|
|
...steers,
|
|
...[...labelsByIndex.entries()].map(([index, part]) => ({ index, part })),
|
|
];
|
|
inserts.sort((a, b) => a.index - b.index);
|
|
const merged = [...parts];
|
|
for (const insert of inserts) {
|
|
merged.splice(Math.min(insert.index, merged.length), 0, insert.part);
|
|
}
|
|
const reasoningIndices = new Set([
|
|
...reasoningStepsByIndex.keys(),
|
|
...reasoningAttemptsByIndex.keys(),
|
|
...reasoningLabelsByIndex.keys(),
|
|
]);
|
|
for (const index of reasoningIndices) {
|
|
const part = merged[index] as ReasoningContentPart | undefined;
|
|
if (part?.type !== ContentTypes.THINK) {
|
|
continue;
|
|
}
|
|
const attempt = reasoningAttemptsByIndex.get(index);
|
|
const label = reasoningLabelsByIndex.get(index);
|
|
const stepId = reasoningStepsByIndex.get(index) ?? attempt?.stepId ?? label?.stepId;
|
|
if (stepId == null) {
|
|
continue;
|
|
}
|
|
const updated: ReasoningContentPart = { ...part };
|
|
if (updated.reasoning_label_step_id != null && updated.reasoning_label_step_id !== stepId) {
|
|
delete updated.reasoning_label;
|
|
delete updated.reasoning_label_revision;
|
|
delete updated.reasoning_label_status;
|
|
delete updated.reasoning_label_submitted_chars;
|
|
}
|
|
updated.reasoning_label_step_id = stepId;
|
|
if (reasoningAttemptHighWater > 0) {
|
|
updated.reasoning_label_attempts = Math.max(
|
|
updated.reasoning_label_attempts ?? 0,
|
|
reasoningAttemptHighWater,
|
|
);
|
|
}
|
|
if (attempt?.stepId === stepId && attempt.submittedChars != null) {
|
|
updated.reasoning_label_submitted_chars = attempt.submittedChars;
|
|
}
|
|
if (label?.stepId === stepId) {
|
|
updated.reasoning_label = label.label;
|
|
updated.reasoning_label_revision = label.revision;
|
|
updated.reasoning_label_status = label.status;
|
|
}
|
|
merged[index] = updated;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Cache the HOST-authored content array (WeakRef; owned by the run closure).
|
|
* This is the authoritative same-instance view: host-only parts (steers)
|
|
* live here but never inside the SDK graph, so preferring it over the graph
|
|
* cache keeps same-instance reconnect/abort/status reads steer-complete.
|
|
*/
|
|
setContentParts(
|
|
streamId: string,
|
|
contentParts: Agents.MessageContentComplex[],
|
|
expectedCreatedAt?: number,
|
|
): void {
|
|
this.setLocalEntry(this.localContentParts, streamId, {
|
|
createdAt: expectedCreatedAt,
|
|
value: new WeakRef(contentParts),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Store collectedUsage reference in local cache.
|
|
* This is used for abort handling to spend tokens for all models.
|
|
* Note: Only available on the generating instance; cross-replica abort uses fallback.
|
|
*/
|
|
setCollectedUsage(
|
|
streamId: string,
|
|
collectedUsage: UsageMetadata[],
|
|
expectedCreatedAt?: number,
|
|
): void {
|
|
this.setLocalEntry(this.localCollectedUsageCache, streamId, {
|
|
createdAt: expectedCreatedAt,
|
|
value: collectedUsage,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get collected usage for a job.
|
|
* Only available if this is the generating instance.
|
|
*/
|
|
getCollectedUsage(streamId: string, expectedCreatedAt?: number): UsageMetadata[] {
|
|
return (
|
|
this.getLocalEntry(this.localCollectedUsageCache, streamId, expectedCreatedAt)?.value ?? []
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get aggregated content - tries local cache first, falls back to Redis reconstruction.
|
|
*
|
|
* Optimization: If this instance has the live graph (same-instance reconnect),
|
|
* we return the content directly without Redis round-trip.
|
|
* For cross-instance reconnects, we reconstruct from Redis Streams.
|
|
*
|
|
* @param streamId - The stream identifier
|
|
* @returns Content parts array or null if not found
|
|
*/
|
|
/**
|
|
* Read from a cached {@link StandardGraph}, tolerating one disposed after a HITL
|
|
* pause. When a paused turn's client is disposed, `disposeClient`
|
|
* (api/server/cleanup.js `graphPropsToClean`) NULLS the graph's internal arrays
|
|
* (`messages`, `contentData`) for GC — but this store still holds a WeakRef to
|
|
* that object. Calling `getContentParts()` (`this.messages.slice()`) or
|
|
* `getRunSteps()` (`[...this.contentData]`) on it then throws
|
|
* ("Cannot read properties of null (reading 'slice')" / "not iterable"), which
|
|
* aborts the resume (#14247). Swallow it, drop the stale entry, and let the
|
|
* caller fall back to durable chunk reconstruction. (The SDK-side null guard in
|
|
* `StandardGraph` is a separate agents fix.)
|
|
*/
|
|
private readCachedGraph<T>(
|
|
streamId: string,
|
|
entry: LocalCacheEntry<WeakRef<StandardGraph>>,
|
|
graph: StandardGraph,
|
|
read: (graph: StandardGraph) => T,
|
|
): T | null {
|
|
try {
|
|
return read(graph);
|
|
} catch (err) {
|
|
logger.debug(
|
|
`[RedisJobStore] Cached graph for ${streamId} is unusable (likely disposed); falling back to reconstruction:`,
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
this.deleteLocalEntry(this.localGraphCache, streamId, undefined, entry);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getContentParts(
|
|
streamId: string,
|
|
expectedCreatedAt?: number,
|
|
): Promise<{
|
|
content: Agents.MessageContentComplex[];
|
|
} | null> {
|
|
// 1. Prefer the HOST content array (same-instance fast path): it already
|
|
// contains host-authored steer parts the SDK graph never sees.
|
|
const hostEntry = this.getLocalEntry(this.localContentParts, streamId, expectedCreatedAt);
|
|
if (hostEntry) {
|
|
const hostParts = hostEntry.value.deref();
|
|
if (hostParts && hostParts.length > 0) {
|
|
return { content: hostParts };
|
|
}
|
|
if (!hostParts) {
|
|
this.deleteLocalEntry(this.localContentParts, streamId, undefined, hostEntry);
|
|
}
|
|
}
|
|
|
|
// 2. Local graph cache (runs wired before setContentParts): the SDK view
|
|
// lacks host-authored steer parts, so overlay them from the chunk log —
|
|
// insert (not assign): the graph array is UNSHIFTED, while recorded steer
|
|
// indices are host-view positions that already account for prior steers.
|
|
const graphEntry = this.getLocalEntry(this.localGraphCache, streamId, expectedCreatedAt);
|
|
if (graphEntry) {
|
|
const graph = graphEntry.value.deref();
|
|
if (graph) {
|
|
const localParts = this.readCachedGraph(streamId, graphEntry, graph, (g) =>
|
|
g.getContentParts(),
|
|
);
|
|
if (localParts && localParts.length > 0) {
|
|
return {
|
|
content: await this.overlayHostSteerParts(streamId, localParts, expectedCreatedAt),
|
|
};
|
|
}
|
|
} else {
|
|
// WeakRef was collected, remove from cache
|
|
this.deleteLocalEntry(this.localGraphCache, streamId, undefined, graphEntry);
|
|
}
|
|
}
|
|
|
|
// 2. Fall back to Redis chunk reconstruction (cross-instance reconnect)
|
|
const chunks = await this.getChunks(streamId, expectedCreatedAt);
|
|
if (chunks.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Use the same content aggregator as live streaming
|
|
const { contentParts, aggregateContent } = createContentAggregator();
|
|
|
|
// Step ID -> content index, rebuilt from the replayed `on_run_step`
|
|
// payloads. Those carry the index the offset wrappers shifted, whereas a
|
|
// closure event was stored unshifted (the wrappers clone only
|
|
// `ON_RUN_STEP`/`ON_AGENT_UPDATE`), so a closure must be resolved by ID
|
|
// rather than by its own index — otherwise a run containing a steer or
|
|
// HITL resume stamps the status onto the wrong slot.
|
|
const replayedStepIndices = new Map<string, number>();
|
|
const reasoningStepsByIndex = new Map<number, string>();
|
|
const reasoningAttemptsByIndex = new Map<number, ReasoningAttemptOverlay>();
|
|
const reasoningLabelsByIndex = new Map<number, ReasoningLabelOverlay>();
|
|
let reasoningAttemptHighWater = 0;
|
|
|
|
// Valid event types for content aggregation
|
|
const validEvents = new Set([
|
|
'on_run_step',
|
|
'on_message_delta',
|
|
'on_reasoning_delta',
|
|
'on_run_step_delta',
|
|
'on_run_step_completed',
|
|
'on_agent_update',
|
|
]);
|
|
|
|
for (const chunk of chunks) {
|
|
const event = chunk as { event?: string; data?: unknown };
|
|
if (!event.event || !event.data) {
|
|
continue;
|
|
}
|
|
|
|
// Steer parts are host-authored (the SDK aggregator doesn't know the
|
|
// event), so splice them at their recorded index — SDK events after the
|
|
// injection were emitted with already-shifted indices, so both sources
|
|
// land disjoint.
|
|
if (event.event === 'on_steer_applied') {
|
|
const steerData = event.data as { index?: number; part?: Agents.MessageContentComplex };
|
|
if (typeof steerData.index === 'number' && steerData.part != null) {
|
|
contentParts[steerData.index] = steerData.part;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Activity-label parts are host-authored like steers and claimed at a
|
|
// fixed index. The event fires twice per slot (counts placeholder,
|
|
// then resolved label); chronological replay makes the last write win.
|
|
if (event.event === 'on_activity_label') {
|
|
const labelData = event.data as { index?: number; part?: Agents.MessageContentComplex };
|
|
if (typeof labelData.index === 'number' && labelData.part != null) {
|
|
contentParts[labelData.index] = labelData.part;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Attempt reservations are durable but intentionally non-rendering.
|
|
// Overlay their run-cumulative high-water mark after replay so later
|
|
// deltas cannot erase the cost cap before a HITL resume.
|
|
if (event.event === 'on_reasoning_label_attempt') {
|
|
const attempt = event.data as {
|
|
index?: number;
|
|
stepId?: string;
|
|
attempts?: number;
|
|
submittedChars?: number;
|
|
};
|
|
if (
|
|
typeof attempt.index === 'number' &&
|
|
typeof attempt.stepId === 'string' &&
|
|
typeof attempt.attempts === 'number'
|
|
) {
|
|
reasoningAttemptsByIndex.set(attempt.index, {
|
|
stepId: attempt.stepId,
|
|
attempts: attempt.attempts,
|
|
...(typeof attempt.submittedChars === 'number' && {
|
|
submittedChars: attempt.submittedChars,
|
|
}),
|
|
});
|
|
reasoningAttemptHighWater = Math.max(reasoningAttemptHighWater, attempt.attempts);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Reasoning labels patch an existing THINK part and never shift indices.
|
|
// Retain the latest update until replay is complete: a later reasoning
|
|
// delta rebuilds the THINK object and would otherwise erase metadata
|
|
// from an earlier label event.
|
|
if (event.event === 'on_reasoning_label') {
|
|
const labelData = event.data as {
|
|
index?: number;
|
|
stepId?: string;
|
|
revision?: number;
|
|
label?: string;
|
|
status?: 'streaming' | 'complete';
|
|
};
|
|
if (
|
|
typeof labelData.index === 'number' &&
|
|
typeof labelData.stepId === 'string' &&
|
|
typeof labelData.revision === 'number' &&
|
|
typeof labelData.label === 'string'
|
|
) {
|
|
reasoningLabelsByIndex.set(labelData.index, {
|
|
stepId: labelData.stepId,
|
|
revision: labelData.revision,
|
|
label: labelData.label,
|
|
status: labelData.status === 'complete' ? 'complete' : 'streaming',
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Step closures are host-authored like steers and labels: the SDK
|
|
// aggregator has no notion of the event, so the terminal status is
|
|
// stamped onto the part the replayed steps already rebuilt. Resolved by
|
|
// step ID against the replayed indices, never by the closure's own
|
|
// index — see `replayedStepIndices`. Chronology guarantees the step's
|
|
// `on_run_step` was replayed first.
|
|
if (event.event === 'on_run_step_closed') {
|
|
const closed = event.data as {
|
|
id?: string;
|
|
status?: Agents.RunStepClosedStatus;
|
|
created_at?: number;
|
|
closed_at?: number;
|
|
};
|
|
const index = closed.id != null ? replayedStepIndices.get(closed.id) : undefined;
|
|
const part = index != null ? contentParts[index] : undefined;
|
|
if (closed.status && part?.type === ContentTypes.TOOL_CALL && part.tool_call) {
|
|
part.tool_call.runStepStatus = closed.status;
|
|
const durationMs = getRunStepDurationMs(closed);
|
|
if (durationMs != null) {
|
|
part.tool_call.runStepDurationMs = durationMs;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!validEvents.has(event.event)) {
|
|
continue;
|
|
}
|
|
|
|
if (event.event === 'on_run_step') {
|
|
const step = event.data as {
|
|
id?: string;
|
|
index?: number;
|
|
stepDetails?: { message_creation?: { content_type?: string } };
|
|
};
|
|
if (step.id != null && typeof step.index === 'number') {
|
|
replayedStepIndices.set(step.id, step.index);
|
|
if (step.stepDetails?.message_creation?.content_type === ContentTypes.THINK) {
|
|
reasoningStepsByIndex.set(step.index, step.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pass event string directly - GraphEvents values are lowercase strings
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
aggregateContent({ event: event.event as any, data: event.data as any });
|
|
}
|
|
|
|
const reasoningIndices = new Set([
|
|
...reasoningStepsByIndex.keys(),
|
|
...reasoningAttemptsByIndex.keys(),
|
|
...reasoningLabelsByIndex.keys(),
|
|
]);
|
|
for (const index of reasoningIndices) {
|
|
const part = contentParts[index] as ReasoningContentPart | undefined;
|
|
if (part?.type !== ContentTypes.THINK) {
|
|
continue;
|
|
}
|
|
const attempt = reasoningAttemptsByIndex.get(index);
|
|
const label = reasoningLabelsByIndex.get(index);
|
|
const stepId = reasoningStepsByIndex.get(index) ?? attempt?.stepId ?? label?.stepId;
|
|
if (stepId == null) {
|
|
continue;
|
|
}
|
|
if (part.reasoning_label_step_id != null && part.reasoning_label_step_id !== stepId) {
|
|
delete part.reasoning_label;
|
|
delete part.reasoning_label_revision;
|
|
delete part.reasoning_label_status;
|
|
delete part.reasoning_label_submitted_chars;
|
|
}
|
|
part.reasoning_label_step_id = stepId;
|
|
if (reasoningAttemptHighWater > 0) {
|
|
part.reasoning_label_attempts = Math.max(
|
|
part.reasoning_label_attempts ?? 0,
|
|
reasoningAttemptHighWater,
|
|
);
|
|
}
|
|
if (attempt?.stepId === stepId && attempt.submittedChars != null) {
|
|
part.reasoning_label_submitted_chars = attempt.submittedChars;
|
|
}
|
|
if (label?.stepId === stepId) {
|
|
part.reasoning_label = label.label;
|
|
part.reasoning_label_revision = label.revision;
|
|
part.reasoning_label_status = label.status;
|
|
}
|
|
}
|
|
|
|
// Filter out undefined entries
|
|
const filtered: Agents.MessageContentComplex[] = [];
|
|
for (const part of contentParts) {
|
|
if (part !== undefined) {
|
|
filtered.push(part);
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: filtered,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get run steps - tries local cache first, falls back to Redis.
|
|
*
|
|
* Optimization: If this instance has the live graph, we get run steps
|
|
* directly without Redis round-trip.
|
|
*
|
|
* @param streamId - The stream identifier
|
|
* @returns Run steps array
|
|
*/
|
|
async getRunSteps(streamId: string, expectedCreatedAt?: number): Promise<Agents.RunStep[]> {
|
|
// 1. Try local graph cache first (fast path for same-instance reconnect)
|
|
const graphEntry = this.getLocalEntry(this.localGraphCache, streamId, expectedCreatedAt);
|
|
if (graphEntry) {
|
|
const graph = graphEntry.value.deref();
|
|
if (graph) {
|
|
const localSteps = this.readCachedGraph(streamId, graphEntry, graph, (g) =>
|
|
g.getRunSteps(),
|
|
);
|
|
if (localSteps && localSteps.length > 0) {
|
|
return toWireRunSteps(localSteps);
|
|
}
|
|
}
|
|
// Note: Don't delete from cache here - graph may still be valid
|
|
// but just not have run steps yet
|
|
}
|
|
|
|
// 2. Fall back to Redis (cross-instance reconnect)
|
|
const data = await this.getRunStepsData(streamId, expectedCreatedAt);
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
try {
|
|
return JSON.parse(data);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async getRunStepsData(
|
|
streamId: string,
|
|
expectedCreatedAt?: number,
|
|
): Promise<string | null> {
|
|
if (expectedCreatedAt == null) {
|
|
return this.redis.get(KEYS.runSteps(streamId));
|
|
}
|
|
const data = await this.redis.eval(
|
|
RUNSTEPS_READ_LUA,
|
|
2,
|
|
KEYS.job(streamId),
|
|
KEYS.runSteps(streamId),
|
|
String(expectedCreatedAt),
|
|
);
|
|
return typeof data === 'string' ? data : null;
|
|
}
|
|
|
|
/**
|
|
* Clear content state for a job.
|
|
* Removes both local cache and Redis data.
|
|
*/
|
|
clearContentState(streamId: string, expectedCreatedAt?: number): void {
|
|
// Clear local caches immediately
|
|
this.clearLocalState(streamId, expectedCreatedAt);
|
|
|
|
// Fire and forget - async cleanup for Redis
|
|
this.clearContentStateAsync(streamId, expectedCreatedAt).catch((err) => {
|
|
logger.error(`[RedisJobStore] Failed to clear content state for ${streamId}:`, err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear content state async.
|
|
*/
|
|
private async clearContentStateAsync(
|
|
streamId: string,
|
|
expectedCreatedAt?: number,
|
|
): Promise<void> {
|
|
await this.redis.eval(
|
|
CONTENT_CLEAR_LUA,
|
|
3,
|
|
KEYS.chunks(streamId),
|
|
KEYS.runSteps(streamId),
|
|
KEYS.job(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
);
|
|
}
|
|
|
|
// ===== Steering Queue Methods =====
|
|
|
|
async enqueueSteer(
|
|
streamId: string,
|
|
item: SteerQueueItem,
|
|
expectedCreatedAt?: number,
|
|
): Promise<number> {
|
|
const result = await this.redis.eval(
|
|
STEER_ENQUEUE_LUA,
|
|
2,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
JSON.stringify(item),
|
|
String(this.runningStorageTtlSeconds()),
|
|
String(STEER_QUEUE_MAX_DEPTH),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
);
|
|
if (typeof result !== 'number') {
|
|
return STEER_ENQUEUE_NOT_RUNNING;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async enqueueSteerVersioned(
|
|
streamId: string,
|
|
item: SteerQueueItem,
|
|
wantsPreempt: boolean,
|
|
expectedCreatedAt?: number,
|
|
): Promise<SteerEnqueueVersionedResult> {
|
|
const result = await this.redis.eval(
|
|
STEER_ENQUEUE_VERSIONED_LUA,
|
|
2,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
JSON.stringify(item),
|
|
String(this.runningStorageTtlSeconds()),
|
|
String(STEER_QUEUE_MAX_DEPTH),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
wantsPreempt ? '1' : '0',
|
|
);
|
|
if (typeof result === 'number') {
|
|
return result;
|
|
}
|
|
if (typeof result !== 'string') {
|
|
return STEER_ENQUEUE_NOT_RUNNING;
|
|
}
|
|
try {
|
|
return JSON.parse(result) as Exclude<SteerEnqueueVersionedResult, number>;
|
|
} catch {
|
|
logger.warn(`[RedisJobStore] Malformed atomic steer enqueue result for ${streamId}`);
|
|
return STEER_ENQUEUE_NOT_RUNNING;
|
|
}
|
|
}
|
|
|
|
async getSteerReceipt(streamId: string, clientSteerId: string): Promise<SteerReceipt | null> {
|
|
const raw = await this.redis.eval(
|
|
STEER_RECEIPT_GET_LUA,
|
|
3,
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
clientSteerId,
|
|
);
|
|
if (typeof raw !== 'string') {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(raw) as SteerReceipt;
|
|
} catch {
|
|
logger.warn(`[RedisJobStore] Dropping malformed steer receipt for ${streamId}`);
|
|
await this.redis.hdel(KEYS.steerReceipts(streamId), clientSteerId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async enqueueSteerWithReceipt(
|
|
streamId: string,
|
|
item: SteerQueueItem,
|
|
receipt: SteerReceiptInput,
|
|
wantsPreempt: boolean,
|
|
expectedCreatedAt?: number,
|
|
): Promise<SteerEnqueueReceiptResult> {
|
|
const result = await this.redis.eval(
|
|
STEER_ENQUEUE_RECEIPT_LUA,
|
|
4,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.steerReceiptOrder(streamId),
|
|
JSON.stringify(item),
|
|
String(this.runningStorageTtlSeconds()),
|
|
String(STEER_QUEUE_MAX_DEPTH),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
receipt.clientSteerId,
|
|
JSON.stringify(receipt),
|
|
wantsPreempt ? '1' : '0',
|
|
String(this.runningStorageTtlSeconds()),
|
|
String(STEER_RECEIPT_MAX_PER_STREAM),
|
|
);
|
|
if (typeof result === 'number') {
|
|
return result;
|
|
}
|
|
if (typeof result !== 'string') {
|
|
return STEER_ENQUEUE_NOT_RUNNING;
|
|
}
|
|
try {
|
|
return JSON.parse(result) as Exclude<SteerEnqueueReceiptResult, number>;
|
|
} catch {
|
|
logger.warn(`[RedisJobStore] Malformed atomic steer receipt for ${streamId}`);
|
|
return STEER_ENQUEUE_NOT_RUNNING;
|
|
}
|
|
}
|
|
|
|
async drainSteers(streamId: string, expectedCreatedAt?: number): Promise<SteerQueueItem[]> {
|
|
const raw = await this.redis.eval(
|
|
STEER_DRAIN_LUA,
|
|
5,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.steerReceiptOrder(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
String(this.runningStorageTtlSeconds()),
|
|
);
|
|
return this.parseSteerItems(raw);
|
|
}
|
|
|
|
async restoreClaimedSteers(
|
|
streamId: string,
|
|
items: SteerQueueItem[],
|
|
expectedCreatedAt?: number,
|
|
): Promise<boolean> {
|
|
if (items.length === 0) {
|
|
return true;
|
|
}
|
|
const restored = await this.redis.eval(
|
|
STEER_RESTORE_CLAIMED_LUA,
|
|
4,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
JSON.stringify(items),
|
|
String(this.runningStorageTtlSeconds()),
|
|
);
|
|
return restored === 1;
|
|
}
|
|
|
|
async closeAndDrainSteers(
|
|
streamId: string,
|
|
expectedCreatedAt?: number,
|
|
): Promise<SteerQueueItem[]> {
|
|
const raw = await this.redis.eval(
|
|
STEER_CLOSE_DRAIN_LUA,
|
|
6,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.steerReceiptOrder(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
String(this.ttl.completed > 0 ? this.ttl.completed : PARKED_RECOVERY_TTL_S),
|
|
);
|
|
if (raw === 'recovery_corrupt') {
|
|
throw new Error('Generation recovery state is corrupt or belongs to another owner');
|
|
}
|
|
return this.parseSteerItems(raw);
|
|
}
|
|
|
|
async peekSteers(streamId: string, expectedCreatedAt?: number): Promise<SteerQueueItem[]> {
|
|
const raw =
|
|
expectedCreatedAt == null
|
|
? await this.redis.lrange(KEYS.steers(streamId), 0, -1)
|
|
: await this.redis.eval(
|
|
STEER_PEEK_LUA,
|
|
2,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
String(expectedCreatedAt),
|
|
);
|
|
return this.parseSteerItems(raw);
|
|
}
|
|
|
|
async peekClaimedSteers(streamId: string, expectedCreatedAt?: number): Promise<SteerQueueItem[]> {
|
|
const raw = await this.redis.eval(
|
|
STEER_PEEK_CLAIMED_LUA,
|
|
2,
|
|
KEYS.job(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
);
|
|
return this.parseSteerItems(raw);
|
|
}
|
|
|
|
async clearSteers(streamId: string): Promise<void> {
|
|
await this.redis.del(KEYS.steers(streamId), KEYS.claimedSteers(streamId));
|
|
}
|
|
|
|
async removeSteer(
|
|
streamId: string,
|
|
steerId: string,
|
|
expectedCreatedAt?: number,
|
|
): Promise<boolean> {
|
|
const removed = (await this.redis.eval(
|
|
STEER_REMOVE_LUA,
|
|
3,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
steerId,
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
)) as number;
|
|
return removed === 1;
|
|
}
|
|
|
|
async armSteer(
|
|
streamId: string,
|
|
steerId: string,
|
|
expectedCreatedAt?: number,
|
|
): Promise<SteerArmOutcome> {
|
|
return (await this.armSteerVersioned(streamId, steerId, expectedCreatedAt)).outcome;
|
|
}
|
|
|
|
async armSteerVersioned(
|
|
streamId: string,
|
|
steerId: string,
|
|
expectedCreatedAt?: number,
|
|
): Promise<SteerArmResult> {
|
|
const result = await this.redis.eval(
|
|
STEER_ARM_LUA,
|
|
3,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
steerId,
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
);
|
|
if (typeof result === 'string') {
|
|
try {
|
|
const item = JSON.parse(result) as SteerQueueItem;
|
|
return { outcome: 'armed', revision: item.preemptRevision, item };
|
|
} catch {
|
|
return { outcome: 'missing' };
|
|
}
|
|
}
|
|
return { outcome: result === -1 ? 'incapable' : 'missing' };
|
|
}
|
|
|
|
async downgradeSteerPreempts(
|
|
streamId: string,
|
|
expectedCreatedAt?: number,
|
|
): Promise<SteerQueueItem[] | null> {
|
|
const changed = await this.redis.eval(
|
|
STEER_DOWNGRADE_PREEMPTS_LUA,
|
|
3,
|
|
KEYS.job(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
);
|
|
if (changed === -1) {
|
|
return null;
|
|
}
|
|
return this.parseSteerItems(changed);
|
|
}
|
|
|
|
async parkSteers(streamId: string, payload: string, expectedCreatedAt?: number): Promise<void> {
|
|
const ttl = this.ttl.completed > 0 ? this.ttl.completed : PARKED_RECOVERY_TTL_S;
|
|
await this.redis.eval(
|
|
PARK_STEERS_LUA,
|
|
2,
|
|
KEYS.job(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
payload,
|
|
String(ttl),
|
|
);
|
|
}
|
|
|
|
async claimParkedSteers(
|
|
streamId: string,
|
|
ownerUserId: string,
|
|
ownerTenantId?: string,
|
|
): Promise<string | undefined> {
|
|
return (await this.claimParkedSteersDetailed(streamId, ownerUserId, ownerTenantId, 2))?.payload;
|
|
}
|
|
|
|
async claimParkedSteersDetailed(
|
|
streamId: string,
|
|
ownerUserId: string,
|
|
ownerTenantId?: string,
|
|
requestedProtocolVersion: 1 | 2 = 1,
|
|
): Promise<ParkedSteerClaim | undefined> {
|
|
const claimed = await this.redis.eval(
|
|
CLAIM_PARKED_LUA,
|
|
2,
|
|
KEYS.parkedSteers(streamId),
|
|
KEYS.job(streamId),
|
|
ownerUserId,
|
|
ownerTenantId ?? '',
|
|
String(requestedProtocolVersion),
|
|
);
|
|
if (
|
|
!Array.isArray(claimed) ||
|
|
typeof claimed[0] !== 'string' ||
|
|
(String(claimed[1]) !== '1' && String(claimed[1]) !== '2')
|
|
) {
|
|
// '' is the non-owner/malformed sentinel (payload left in place).
|
|
return undefined;
|
|
}
|
|
return {
|
|
payload: claimed[0],
|
|
generationProtocolVersion: String(claimed[1]) === '2' ? 2 : 1,
|
|
};
|
|
}
|
|
|
|
async consumeParkedSteer(
|
|
streamId: string,
|
|
steerId: string,
|
|
ownerUserId: string,
|
|
ownerTenantId: string | undefined,
|
|
expectedCreatedAt: number,
|
|
): Promise<boolean> {
|
|
const consumed = await this.redis.eval(
|
|
CONSUME_PARKED_STEER_LUA,
|
|
3,
|
|
KEYS.job(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
String(expectedCreatedAt),
|
|
steerId,
|
|
ownerUserId,
|
|
ownerTenantId ?? '',
|
|
);
|
|
return consumed === 1;
|
|
}
|
|
|
|
async discardSteerLeftover(
|
|
streamId: string,
|
|
clientSteerId: string,
|
|
steerId: string,
|
|
ownerUserId: string,
|
|
ownerTenantId?: string,
|
|
expectedGenerationCreatedAt?: number,
|
|
): Promise<boolean> {
|
|
const discarded = await this.redis.eval(
|
|
DISCARD_STEER_LEFTOVER_LUA,
|
|
3,
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
KEYS.job(streamId),
|
|
clientSteerId,
|
|
steerId,
|
|
ownerUserId,
|
|
ownerTenantId ?? '',
|
|
expectedGenerationCreatedAt != null ? String(expectedGenerationCreatedAt) : '',
|
|
);
|
|
return discarded === 1;
|
|
}
|
|
|
|
/** A malformed entry is dropped (logged) rather than poisoning the drain. */
|
|
private parseSteerItems(raw: unknown): SteerQueueItem[] {
|
|
if (!Array.isArray(raw)) {
|
|
return [];
|
|
}
|
|
const items: SteerQueueItem[] = [];
|
|
for (const entry of raw) {
|
|
if (typeof entry !== 'string') {
|
|
continue;
|
|
}
|
|
try {
|
|
items.push(JSON.parse(entry) as SteerQueueItem);
|
|
} catch {
|
|
logger.warn('[RedisJobStore] Dropping malformed steer queue entry');
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/**
|
|
* Append a streaming chunk to Redis Stream.
|
|
* Uses XADD for efficient append-only storage.
|
|
* Sets TTL on first chunk to ensure cleanup if job crashes.
|
|
*/
|
|
async appendChunk(
|
|
streamId: string,
|
|
event: unknown,
|
|
expectedCreatedAt?: number,
|
|
deliveredSteer?: SteerQueueItem,
|
|
options?: { coalesce?: boolean },
|
|
): Promise<boolean> {
|
|
if (options?.coalesce === true && deliveredSteer == null && this.coalesceWindowMs > 0) {
|
|
return this.enqueueCoalescedAppend(streamId, event, expectedCreatedAt);
|
|
}
|
|
/** The chunk log is replayed in XADD order, so a per-event append (durable
|
|
* control events, steer receipts) is a barrier: pending coalesced deltas
|
|
* must be issued first. Same connection, so issue order is land order. */
|
|
if (this.pendingAppends.has(streamId)) {
|
|
void this.flushCoalescedAppends(streamId);
|
|
}
|
|
const key = KEYS.chunks(streamId);
|
|
const jobKey = KEYS.job(streamId);
|
|
// XADD + derive-and-extend-only EXPIRE in a single atomic eval. Refreshing the TTL on
|
|
// every chunk (vs only once) keeps the key alive through long streams, but it must
|
|
// NEVER shrink an already-longer TTL — a paused (requires_action) job needs this key
|
|
// to live for the whole approval window, and the on_pending_action append (or any
|
|
// chunk that lands after the pause) would otherwise reset it to the short running TTL.
|
|
// The script reads the paused window from the job key, so it bumps to the approval TTL
|
|
// even when the pause's own EXPIRE no-op'd because this key didn't exist yet, while a
|
|
// normally-running run still settles on the short running TTL. Both keys share the
|
|
// {streamId} hash tag, so the multi-key eval stays on one slot under Redis Cluster.
|
|
const appended = await this.redis.eval(
|
|
CHUNK_APPEND_LUA,
|
|
8,
|
|
key,
|
|
jobKey,
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.steerReceiptOrder(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
KEYS.generationEpoch(streamId),
|
|
JSON.stringify(event),
|
|
String(this.runningStorageTtlSeconds()),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
deliveredSteer?.clientSteerId ?? '',
|
|
deliveredSteer != null ? JSON.stringify(deliveredSteer) : '',
|
|
String(Date.now()),
|
|
String(this.parkedRecoveryTtlSeconds()),
|
|
String(GENERATION_EPOCH_GRACE_TTL_S),
|
|
);
|
|
return appended === 1;
|
|
}
|
|
|
|
/**
|
|
* Buffer a coalescable durable append for the current window. The whole batch
|
|
* settles together: `true` on commit, `false` under the generation/status
|
|
* fence, and a rejection on operational failure — mirroring the per-event
|
|
* appendChunk contract each caller's fence continuation already handles.
|
|
*/
|
|
private enqueueCoalescedAppend(
|
|
streamId: string,
|
|
event: unknown,
|
|
expectedCreatedAt?: number,
|
|
): Promise<boolean> {
|
|
let pending = this.pendingAppends.get(streamId);
|
|
if (pending && pending.expectedCreatedAt !== expectedCreatedAt) {
|
|
void this.flushCoalescedAppends(streamId);
|
|
pending = undefined;
|
|
}
|
|
if (!pending) {
|
|
pending = { expectedCreatedAt, events: [], settlers: [], bytes: 0, timer: null };
|
|
this.pendingAppends.set(streamId, pending);
|
|
}
|
|
|
|
const batch = pending;
|
|
const encoded = JSON.stringify(event);
|
|
batch.events.push(encoded);
|
|
batch.bytes += encoded.length;
|
|
const settled = new Promise<boolean>((resolve, reject) => {
|
|
batch.settlers.push({ resolve, reject });
|
|
});
|
|
|
|
if (batch.events.length >= MAX_COALESCED_EVENTS || batch.bytes >= MAX_COALESCED_BYTES) {
|
|
void this.flushCoalescedAppends(streamId);
|
|
} else if (batch.timer == null) {
|
|
batch.timer = setTimeout(() => {
|
|
void this.flushCoalescedAppends(streamId);
|
|
}, this.coalesceWindowMs);
|
|
}
|
|
return settled;
|
|
}
|
|
|
|
private flushCoalescedAppends(streamId: string): Promise<void> {
|
|
const pending = this.pendingAppends.get(streamId);
|
|
if (!pending) {
|
|
return Promise.resolve();
|
|
}
|
|
this.pendingAppends.delete(streamId);
|
|
if (pending.timer != null) {
|
|
clearTimeout(pending.timer);
|
|
pending.timer = null;
|
|
}
|
|
|
|
const { expectedCreatedAt, events, settlers } = pending;
|
|
return this.redis
|
|
.eval(
|
|
CHUNK_APPEND_BATCH_LUA,
|
|
8,
|
|
KEYS.chunks(streamId),
|
|
KEYS.job(streamId),
|
|
KEYS.steerReceipts(streamId),
|
|
KEYS.steerReceiptOrder(streamId),
|
|
KEYS.claimedSteers(streamId),
|
|
KEYS.steers(streamId),
|
|
KEYS.parkedSteers(streamId),
|
|
KEYS.generationEpoch(streamId),
|
|
String(this.runningStorageTtlSeconds()),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
String(Date.now()),
|
|
String(this.parkedRecoveryTtlSeconds()),
|
|
String(GENERATION_EPOCH_GRACE_TTL_S),
|
|
...events,
|
|
)
|
|
.then(
|
|
(appended) => {
|
|
const committed = appended === 1;
|
|
for (const settler of settlers) {
|
|
settler.resolve(committed);
|
|
}
|
|
},
|
|
(err) => {
|
|
for (const settler of settlers) {
|
|
settler.reject(err);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Persist a stream's pending coalesced appends now (pre-transition barrier). */
|
|
async flushPendingAppends(streamId: string): Promise<void> {
|
|
await this.flushCoalescedAppends(streamId);
|
|
}
|
|
|
|
/**
|
|
* Get all chunks from Redis Stream.
|
|
*/
|
|
private async getChunks(streamId: string, expectedCreatedAt?: number): Promise<unknown[]> {
|
|
/** A same-replica snapshot read must observe the appends this process has
|
|
* already accepted, or a resume during an active window reconstructs
|
|
* without the buffered tail. Cross-replica readers keep today's contract:
|
|
* the log may trail live emission by up to one window. */
|
|
await this.flushCoalescedAppends(streamId);
|
|
const rawEntries =
|
|
expectedCreatedAt == null
|
|
? await this.redis.xrange(KEYS.chunks(streamId), '-', '+')
|
|
: await this.redis.eval(
|
|
CHUNKS_READ_LUA,
|
|
2,
|
|
KEYS.job(streamId),
|
|
KEYS.chunks(streamId),
|
|
String(expectedCreatedAt),
|
|
);
|
|
const entries = Array.isArray(rawEntries) ? (rawEntries as Array<[string, string[]]>) : [];
|
|
|
|
return entries
|
|
.map(([, fields]) => {
|
|
const eventIdx = fields.indexOf('event');
|
|
if (eventIdx >= 0 && eventIdx + 1 < fields.length) {
|
|
try {
|
|
return JSON.parse(fields[eventIdx + 1]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Save run steps for resume state. Uses the paused-window TTL script so a run-step save
|
|
* landing at/after a HITL pause extends to the approval window instead of resetting the
|
|
* key to the short running TTL (which would drop the tool timeline on a reload of a
|
|
* still-live approval — mirrors the chunk-stream no-shrink behavior).
|
|
*/
|
|
async saveRunSteps(
|
|
streamId: string,
|
|
runSteps: Agents.RunStep[],
|
|
expectedCreatedAt?: number,
|
|
): Promise<void> {
|
|
await this.redis.eval(
|
|
RUNSTEPS_SAVE_LUA,
|
|
2,
|
|
KEYS.runSteps(streamId),
|
|
KEYS.job(streamId),
|
|
JSON.stringify(runSteps),
|
|
String(this.runningStorageTtlSeconds()),
|
|
expectedCreatedAt != null ? String(expectedCreatedAt) : '',
|
|
);
|
|
}
|
|
|
|
// ===== Consumer Group Methods =====
|
|
// These enable tracking which chunks each client has seen.
|
|
// Based on https://upstash.com/blog/resumable-llm-streams
|
|
|
|
/**
|
|
* Create a consumer group for a stream.
|
|
* Used to track which chunks a client has already received.
|
|
*
|
|
* @param streamId - The stream identifier
|
|
* @param groupName - Unique name for the consumer group (e.g., session ID)
|
|
* @param startFrom - Where to start reading ('0' = from beginning, '$' = only new)
|
|
*/
|
|
async createConsumerGroup(
|
|
streamId: string,
|
|
groupName: string,
|
|
startFrom: '0' | '$' = '0',
|
|
): Promise<void> {
|
|
const key = KEYS.chunks(streamId);
|
|
try {
|
|
await this.redis.xgroup('CREATE', key, groupName, startFrom, 'MKSTREAM');
|
|
logger.debug(`[RedisJobStore] Created consumer group ${groupName} for ${streamId}`);
|
|
} catch (err) {
|
|
// BUSYGROUP error means group already exists - that's fine
|
|
const error = err as Error;
|
|
if (!error.message?.includes('BUSYGROUP')) {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read chunks from a consumer group (only unseen chunks).
|
|
* This is the key to the resumable stream pattern.
|
|
*
|
|
* @param streamId - The stream identifier
|
|
* @param groupName - Consumer group name
|
|
* @param consumerName - Name of the consumer within the group
|
|
* @param count - Maximum number of chunks to read (default: all available)
|
|
* @returns Array of { id, event } where id is the Redis stream entry ID
|
|
*/
|
|
async readChunksFromGroup(
|
|
streamId: string,
|
|
groupName: string,
|
|
consumerName: string = 'consumer-1',
|
|
count?: number,
|
|
): Promise<Array<{ id: string; event: unknown }>> {
|
|
const key = KEYS.chunks(streamId);
|
|
|
|
try {
|
|
// XREADGROUP GROUP groupName consumerName [COUNT count] STREAMS key >
|
|
// The '>' means only read new messages not yet delivered to this consumer
|
|
let result;
|
|
if (count) {
|
|
result = await this.redis.xreadgroup(
|
|
'GROUP',
|
|
groupName,
|
|
consumerName,
|
|
'COUNT',
|
|
count,
|
|
'STREAMS',
|
|
key,
|
|
'>',
|
|
);
|
|
} else {
|
|
result = await this.redis.xreadgroup('GROUP', groupName, consumerName, 'STREAMS', key, '>');
|
|
}
|
|
|
|
if (!result || result.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Result format: [[streamKey, [[id, [field, value, ...]], ...]]]
|
|
const [, messages] = result[0] as [string, Array<[string, string[]]>];
|
|
const chunks: Array<{ id: string; event: unknown }> = [];
|
|
|
|
for (const [id, fields] of messages) {
|
|
const eventIdx = fields.indexOf('event');
|
|
if (eventIdx >= 0 && eventIdx + 1 < fields.length) {
|
|
try {
|
|
chunks.push({
|
|
id,
|
|
event: JSON.parse(fields[eventIdx + 1]),
|
|
});
|
|
} catch {
|
|
// Skip malformed entries
|
|
}
|
|
}
|
|
}
|
|
|
|
return chunks;
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
// NOGROUP error means the group doesn't exist yet
|
|
if (error.message?.includes('NOGROUP')) {
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Acknowledge that chunks have been processed.
|
|
* This tells Redis we've successfully delivered these chunks to the client.
|
|
*
|
|
* @param streamId - The stream identifier
|
|
* @param groupName - Consumer group name
|
|
* @param messageIds - Array of Redis stream entry IDs to acknowledge
|
|
*/
|
|
async acknowledgeChunks(
|
|
streamId: string,
|
|
groupName: string,
|
|
messageIds: string[],
|
|
): Promise<void> {
|
|
if (messageIds.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const key = KEYS.chunks(streamId);
|
|
await this.redis.xack(key, groupName, ...messageIds);
|
|
}
|
|
|
|
/**
|
|
* Delete a consumer group.
|
|
* Called when a client disconnects and won't reconnect.
|
|
*
|
|
* @param streamId - The stream identifier
|
|
* @param groupName - Consumer group name to delete
|
|
*/
|
|
async deleteConsumerGroup(streamId: string, groupName: string): Promise<void> {
|
|
const key = KEYS.chunks(streamId);
|
|
try {
|
|
await this.redis.xgroup('DESTROY', key, groupName);
|
|
logger.debug(`[RedisJobStore] Deleted consumer group ${groupName} for ${streamId}`);
|
|
} catch {
|
|
// Ignore errors - group may not exist
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get pending chunks for a consumer (chunks delivered but not acknowledged).
|
|
* Useful for recovering from crashes.
|
|
*
|
|
* @param streamId - The stream identifier
|
|
* @param groupName - Consumer group name
|
|
* @param consumerName - Consumer name
|
|
*/
|
|
async getPendingChunks(
|
|
streamId: string,
|
|
groupName: string,
|
|
consumerName: string = 'consumer-1',
|
|
): Promise<Array<{ id: string; event: unknown }>> {
|
|
const key = KEYS.chunks(streamId);
|
|
|
|
try {
|
|
// Read pending messages (delivered but not acked) by using '0' instead of '>'
|
|
const result = await this.redis.xreadgroup(
|
|
'GROUP',
|
|
groupName,
|
|
consumerName,
|
|
'STREAMS',
|
|
key,
|
|
'0',
|
|
);
|
|
|
|
if (!result || result.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const [, messages] = result[0] as [string, Array<[string, string[]]>];
|
|
const chunks: Array<{ id: string; event: unknown }> = [];
|
|
|
|
for (const [id, fields] of messages) {
|
|
const eventIdx = fields.indexOf('event');
|
|
if (eventIdx >= 0 && eventIdx + 1 < fields.length) {
|
|
try {
|
|
chunks.push({
|
|
id,
|
|
event: JSON.parse(fields[eventIdx + 1]),
|
|
});
|
|
} catch {
|
|
// Skip malformed entries
|
|
}
|
|
}
|
|
}
|
|
|
|
return chunks;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serialize job data for Redis hash storage.
|
|
* Converts complex types to strings.
|
|
*/
|
|
private serializeJob(job: Partial<SerializableJobData>): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
|
|
for (const [key, value] of Object.entries(job)) {
|
|
if (value === undefined) {
|
|
continue;
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
result[key] = JSON.stringify(value);
|
|
} else if (typeof value === 'boolean') {
|
|
result[key] = value ? '1' : '0';
|
|
} else {
|
|
result[key] = String(value);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Deserialize job data from Redis hash.
|
|
*/
|
|
private deserializeJob(data: Record<string, string>): SerializableJobData {
|
|
const detachedAgentEventTerminalStatus =
|
|
data.detachedAgentEventTerminalStatus === 'complete' ||
|
|
data.detachedAgentEventTerminalStatus === 'aborted' ||
|
|
data.detachedAgentEventTerminalStatus === 'error'
|
|
? data.detachedAgentEventTerminalStatus
|
|
: undefined;
|
|
const job: CreatedJobData = {
|
|
streamId: data.streamId,
|
|
userId: data.userId,
|
|
tenantId: data.tenantId || undefined,
|
|
status: detachedAgentEventTerminalStatus ?? (data.status as JobStatus),
|
|
createdAt: parseInt(data.createdAt, 10),
|
|
generationProtocolVersion: data.generationProtocolVersion === '2' ? 2 : 1,
|
|
checkpointNamespace: data.checkpointNamespace || undefined,
|
|
completedAt: data.completedAt ? parseInt(data.completedAt, 10) : undefined,
|
|
conversationId: data.conversationId || undefined,
|
|
error: data.error || undefined,
|
|
idempotencyClientRequestId: data.idempotencyClientRequestId || undefined,
|
|
recoveredSteerId: data.recoveredSteerId || undefined,
|
|
userMessage: data.userMessage ? JSON.parse(data.userMessage) : undefined,
|
|
responseMessageId: data.responseMessageId || undefined,
|
|
isRegenerate: data.isRegenerate != null ? data.isRegenerate === '1' : undefined,
|
|
mcpRequestBody: data.mcpRequestBody ? JSON.parse(data.mcpRequestBody) : undefined,
|
|
userSubmittedPaths: data.userSubmittedPaths ? JSON.parse(data.userSubmittedPaths) : undefined,
|
|
userSubmittedMessageFieldPaths: data.userSubmittedMessageFieldPaths
|
|
? JSON.parse(data.userSubmittedMessageFieldPaths)
|
|
: undefined,
|
|
createdEventEmitted: data.createdEventEmitted === '1',
|
|
sender: data.sender || undefined,
|
|
syncSent: data.syncSent === '1',
|
|
finalEvent: data.finalEvent || undefined,
|
|
terminalPersistencePending:
|
|
data.terminalPersistencePending != null
|
|
? data.terminalPersistencePending === '1'
|
|
: undefined,
|
|
terminalPersistenceStartedAt: data.terminalPersistenceStartedAt
|
|
? parseInt(data.terminalPersistenceStartedAt, 10)
|
|
: undefined,
|
|
endpoint: data.endpoint || undefined,
|
|
iconURL: data.iconURL || undefined,
|
|
model: data.model || undefined,
|
|
promptTokens: data.promptTokens ? parseInt(data.promptTokens, 10) : undefined,
|
|
agent_id: data.agent_id || undefined,
|
|
isTemporary: data.isTemporary != null ? data.isTemporary === '1' : undefined,
|
|
agentEventDeliveryKey: data.agentEventDeliveryKey || undefined,
|
|
agentEventInvocationKey: data.agentEventInvocationKey || undefined,
|
|
agentEventInvocationGenerationCreatedAt: data.agentEventInvocationGenerationCreatedAt
|
|
? parseInt(data.agentEventInvocationGenerationCreatedAt, 10)
|
|
: undefined,
|
|
agentEventDetachedActionProducerRequired:
|
|
data.agentEventDetachedActionProducerRequired != null
|
|
? data.agentEventDetachedActionProducerRequired === '1'
|
|
: undefined,
|
|
agentEventDetachedTerminalEvidence: data.agentEventDetachedTerminalEvidence
|
|
? JSON.parse(data.agentEventDetachedTerminalEvidence)
|
|
: undefined,
|
|
agentEventBindingId: data.agentEventBindingId || undefined,
|
|
agentEventExpectedAction: data.agentEventExpectedAction
|
|
? JSON.parse(data.agentEventExpectedAction)
|
|
: undefined,
|
|
agentEventSuspension: data.agentEventSuspension
|
|
? JSON.parse(data.agentEventSuspension)
|
|
: undefined,
|
|
agentEventLegacyTurnToken: data.agentEventLegacyTurnToken || undefined,
|
|
scheduleId: data.scheduleId || undefined,
|
|
scheduledFor: data.scheduledFor || undefined,
|
|
scheduleConfigRevision: data.scheduleConfigRevision
|
|
? parseInt(data.scheduleConfigRevision, 10)
|
|
: undefined,
|
|
scheduleManual: data.scheduleManual != null ? data.scheduleManual === '1' : undefined,
|
|
scheduleOutcome:
|
|
data.scheduleOutcome === 'success' ||
|
|
data.scheduleOutcome === 'error' ||
|
|
data.scheduleOutcome === 'interrupted' ||
|
|
data.scheduleOutcome === 'skipped_balance'
|
|
? data.scheduleOutcome
|
|
: undefined,
|
|
scheduleOutcomeError: data.scheduleOutcomeError || undefined,
|
|
preserveForScheduleReconcile:
|
|
data.preserveForScheduleReconcile != null
|
|
? data.preserveForScheduleReconcile === '1'
|
|
: undefined,
|
|
terminalHostActionPending:
|
|
data.terminalHostActionPending === '1' ||
|
|
data.detachedAgentEventTerminalHostActionPending === '1'
|
|
? true
|
|
: undefined,
|
|
detachedAgentEventTerminalHostActionPending:
|
|
data.detachedAgentEventTerminalHostActionPending != null
|
|
? data.detachedAgentEventTerminalHostActionPending === '1'
|
|
: undefined,
|
|
detachedAgentEventTerminalStatus,
|
|
// Deferred tools discovered before a HITL pause; replayed into createRun on resume.
|
|
discoveredTools: data.discoveredTools ? JSON.parse(data.discoveredTools) : undefined,
|
|
activityPhaseSnapshot: data.activityPhaseSnapshot
|
|
? JSON.parse(data.activityPhaseSnapshot)
|
|
: undefined,
|
|
/** The owning replica's seal capability. `serializeJob` writes every
|
|
* boolean generically, but this mapper is explicit — omitting it here
|
|
* drops the flag on every read, so the steer route would compute
|
|
* `preemptArmed: false` and silently degrade interrupt-steer to
|
|
* tool-boundary steering in EVERY Redis deployment. */
|
|
preemptCapable: data.preemptCapable != null ? data.preemptCapable === '1' : undefined,
|
|
/** Same explicit-mapper trap as `preemptCapable`: without this line every
|
|
* Redis read reports the owner quote-incapable, so admission would drop
|
|
* all steer quotes (and their echo) in EVERY Redis deployment. */
|
|
steerQuotesExecutionId: data.steerQuotesExecutionId || undefined,
|
|
providerAbortReady:
|
|
data.providerAbortReady != null ? data.providerAbortReady === '1' : undefined,
|
|
providerExecutionId: data.providerExecutionId || undefined,
|
|
providerExecutionStartedId: data.providerExecutionStartedId || undefined,
|
|
providerDrained: data.providerDrained != null ? data.providerDrained === '1' : undefined,
|
|
titleEvent: data.titleEvent || undefined,
|
|
replayEvents: data.replayEvents || undefined,
|
|
contextUsage: data.contextUsage || undefined,
|
|
tokenUsage: data.tokenUsage || undefined,
|
|
pendingAction: this.parsePendingAction(data.pendingAction),
|
|
resolvedAskUserQuestions: this.parseResolvedAskUserQuestions(data.resolvedAskUserQuestions),
|
|
pendingActionId: data.pendingActionId || undefined,
|
|
lastActiveAt: data.lastActiveAt ? parseInt(data.lastActiveAt, 10) : undefined,
|
|
/** `markActivityLabels` persists this, so it has to be read back:
|
|
* without it every Redis reload leaves the flag undefined and resume
|
|
* skips activity-label gap reconciliation, silently dropping a label
|
|
* that resolved between the snapshot and subscriber attach. */
|
|
activityLabels: data.activityLabels != null ? data.activityLabels === '1' : undefined,
|
|
};
|
|
|
|
if (data.__creationAttemptId) {
|
|
Object.defineProperty(job, 'creationAttemptId', {
|
|
value: data.__creationAttemptId,
|
|
enumerable: false,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
let replacedJobs: ReplacedGeneration[] = [];
|
|
const validReplacementStatuses = new Set([
|
|
'running',
|
|
'requires_action',
|
|
'complete',
|
|
'error',
|
|
'aborted',
|
|
]);
|
|
const scalarEpochRaw = data.__replacedCreatedAt;
|
|
const scalarStatus = data.__replacedStatus;
|
|
if ((scalarEpochRaw != null) !== (scalarStatus != null)) {
|
|
throw new Error('Invalid generation replacement receipt');
|
|
}
|
|
if (data.__replacedGenerations) {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(data.__replacedGenerations);
|
|
} catch {
|
|
throw new Error('Invalid generation replacement receipt');
|
|
}
|
|
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > 32) {
|
|
throw new Error('Invalid generation replacement receipt');
|
|
}
|
|
const seen = new Set<number>();
|
|
let previousEpoch = -1;
|
|
for (const value of parsed) {
|
|
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error('Invalid generation replacement receipt');
|
|
}
|
|
const candidate = value as Record<string, unknown>;
|
|
if (
|
|
!Number.isSafeInteger(candidate.createdAt) ||
|
|
(candidate.createdAt as number) < 0 ||
|
|
(candidate.createdAt as number) <= previousEpoch ||
|
|
(candidate.createdAt as number) >= job.createdAt ||
|
|
!validReplacementStatuses.has(candidate.status as string) ||
|
|
(candidate.conversationId != null && typeof candidate.conversationId !== 'string') ||
|
|
(candidate.providerAbortReady != null &&
|
|
typeof candidate.providerAbortReady !== 'boolean') ||
|
|
(candidate.providerExecutionId != null &&
|
|
(typeof candidate.providerExecutionId !== 'string' ||
|
|
candidate.providerExecutionId.length === 0 ||
|
|
candidate.providerExecutionId.length > 128)) ||
|
|
(candidate.providerDrained != null && typeof candidate.providerDrained !== 'boolean') ||
|
|
(candidate.providerExecutionId != null) !== (candidate.providerDrained != null) ||
|
|
seen.has(candidate.createdAt as number)
|
|
) {
|
|
throw new Error('Invalid generation replacement receipt');
|
|
}
|
|
seen.add(candidate.createdAt as number);
|
|
previousEpoch = candidate.createdAt as number;
|
|
const receipt: ReplacedGeneration = {
|
|
createdAt: candidate.createdAt as number,
|
|
status: candidate.status as JobStatus,
|
|
...(candidate.conversationId != null && {
|
|
conversationId: candidate.conversationId as string,
|
|
}),
|
|
};
|
|
if (candidate.providerAbortReady != null) {
|
|
Object.defineProperty(receipt, 'providerAbortReady', {
|
|
value: candidate.providerAbortReady,
|
|
enumerable: false,
|
|
});
|
|
}
|
|
if (candidate.providerExecutionId != null) {
|
|
Object.defineProperty(receipt, 'providerExecutionId', {
|
|
value: candidate.providerExecutionId,
|
|
enumerable: false,
|
|
});
|
|
Object.defineProperty(receipt, 'providerDrained', {
|
|
value: candidate.providerDrained,
|
|
enumerable: false,
|
|
});
|
|
}
|
|
replacedJobs.push(receipt);
|
|
}
|
|
const scalarEpoch = scalarEpochRaw != null ? Number(scalarEpochRaw) : undefined;
|
|
const latest = replacedJobs[replacedJobs.length - 1];
|
|
if (
|
|
!Number.isSafeInteger(scalarEpoch) ||
|
|
scalarEpoch !== latest.createdAt ||
|
|
scalarStatus !== latest.status ||
|
|
(data.__replacedConversationId || undefined) !== latest.conversationId
|
|
) {
|
|
throw new Error('Invalid generation replacement receipt');
|
|
}
|
|
} else {
|
|
const replacedCreatedAt = scalarEpochRaw != null ? Number(scalarEpochRaw) : undefined;
|
|
const replacedStatus = scalarStatus as JobStatus | undefined;
|
|
if (replacedCreatedAt != null || replacedStatus != null) {
|
|
if (
|
|
replacedCreatedAt == null ||
|
|
replacedStatus == null ||
|
|
!Number.isSafeInteger(replacedCreatedAt) ||
|
|
replacedCreatedAt < 0 ||
|
|
replacedCreatedAt >= job.createdAt ||
|
|
!validReplacementStatuses.has(replacedStatus)
|
|
) {
|
|
throw new Error('Invalid generation replacement receipt');
|
|
}
|
|
replacedJobs = [
|
|
{
|
|
createdAt: replacedCreatedAt,
|
|
status: replacedStatus,
|
|
...(data.__replacedConversationId && {
|
|
conversationId: data.__replacedConversationId,
|
|
}),
|
|
},
|
|
];
|
|
}
|
|
}
|
|
if (replacedJobs.length > 0) {
|
|
Object.defineProperty(job, 'replacedJobs', {
|
|
value: replacedJobs,
|
|
enumerable: false,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(job, 'replacedJob', {
|
|
value: replacedJobs[replacedJobs.length - 1],
|
|
enumerable: false,
|
|
configurable: true,
|
|
});
|
|
}
|
|
return job;
|
|
}
|
|
|
|
/**
|
|
* Parse a persisted `pendingAction`, defending the cold-resume path against
|
|
* malformed or stale records: a corrupt JSON blob or a payload whose shape
|
|
* predates the current SDK contract is dropped (logged) rather than crashing
|
|
* the resume or feeding a bad record to an approval route. Returns undefined
|
|
* when absent/invalid.
|
|
*/
|
|
private parsePendingAction(raw: string | undefined): Agents.PendingAction | undefined {
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as Agents.PendingAction;
|
|
const typeOk =
|
|
typeof parsed?.actionId === 'string' &&
|
|
KNOWN_INTERRUPT_TYPES.has(parsed?.payload?.type as string);
|
|
if (!typeOk) {
|
|
logger.warn('[RedisJobStore] Dropping malformed pendingAction record');
|
|
return undefined;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
logger.warn('[RedisJobStore] Dropping unparseable pendingAction record');
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Parse the accepted ask answer retained across resume ownership transfer. */
|
|
private parseResolvedAskUserQuestions(
|
|
raw: string | undefined,
|
|
): ResolvedAskUserQuestion[] | undefined {
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const value = JSON.parse(raw) as unknown;
|
|
if (!Array.isArray(value)) {
|
|
logger.warn('[RedisJobStore] Dropping malformed resolvedAskUserQuestions record');
|
|
return undefined;
|
|
}
|
|
const parsed = value as ResolvedAskUserQuestion[];
|
|
const valid = parsed.every((answer) => {
|
|
if (answer == null || typeof answer !== 'object' || Array.isArray(answer)) {
|
|
return false;
|
|
}
|
|
const request = answer.request;
|
|
const requestOk =
|
|
typeof request === 'string' ||
|
|
(request != null &&
|
|
typeof request === 'object' &&
|
|
(typeof (request as Agents.AskUserQuestionRequest).question === 'string' ||
|
|
Array.isArray((request as Agents.AskUserQuestionsRequest).questions)));
|
|
return (
|
|
requestOk &&
|
|
typeof answer.output === 'string' &&
|
|
(answer.toolCallId == null || typeof answer.toolCallId === 'string') &&
|
|
(answer.contentIndex == null ||
|
|
(Number.isSafeInteger(answer.contentIndex) && answer.contentIndex >= 0)) &&
|
|
(answer.contentMissing == null || answer.contentMissing === true)
|
|
);
|
|
});
|
|
if (!valid || parsed.length === 0) {
|
|
logger.warn('[RedisJobStore] Dropping malformed resolvedAskUserQuestions record');
|
|
return undefined;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
logger.warn('[RedisJobStore] Dropping unparseable resolvedAskUserQuestions record');
|
|
return undefined;
|
|
}
|
|
}
|
|
}
|