diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index e145d7ed17..05422c462b 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -20,6 +20,7 @@ import { import { InMemoryEventTransport } from './implementations/InMemoryEventTransport'; import { InMemoryJobStore } from './implementations/InMemoryJobStore'; import { filterPersistableAbortContent } from './abortContent'; +import { isPendingActionStale } from './interfaces/IJobStore'; import { ApprovalLifecycle } from './ApprovalLifecycle'; /** Error surfaced to any client still attached when a stale/hung job is reaped. */ @@ -1478,6 +1479,12 @@ class GenerationJobManagerClass { replayEvents, collectedUsage, contextUsage, + // Carry the live pending approval in the resume contract so a reloading / + // cross-replica client can rebuild the prompt from resumeState. + pendingAction: + jobData.status === 'requires_action' && !isPendingActionStale(jobData) + ? jobData.pendingAction + : undefined, }; } diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index b82f3ab393..8c12203fe6 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -83,6 +83,14 @@ const DEFAULT_TTL = { 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, }; /** @@ -117,6 +125,8 @@ export interface RedisJobStoreOptions { 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; } export class RedisJobStore implements IJobStore { @@ -152,6 +162,7 @@ export class RedisJobStore implements IJobStore { 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; @@ -351,19 +362,21 @@ export class RedisJobStore implements IJobStore { } /** - * Live-key TTL (seconds) for a paused job. Defaults to the running TTL but - * extends to cover a pendingAction whose `expiresAt` is farther out, plus a - * grace margin so a decision arriving right at the deadline can still resume. - * Without this, a long approval window (e.g. 1h) on the default 20m stream - * TTL would let Redis evict the paused job mid-window. + * 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.running; + return this.ttl.requiresAction; } const secondsUntilExpiry = Math.ceil((exp - Date.now()) / 1000) + 60; - return Math.max(this.ttl.running, secondsUntilExpiry); + return Math.max(this.ttl.requiresAction, secondsUntilExpiry); } /** The membership set a status belongs to; terminal statuses have none. */ diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 5943cce96b..6318dd651f 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -246,6 +246,12 @@ export namespace Agents { collectedUsage?: TTokenUsageEvent[]; /** Latest context window snapshot; restores the usage gauge on resume */ contextUsage?: TContextUsageEvent; + /** + * Live pending approval when the run is paused for human review. Carried in + * the resume contract (not just /chat/status) so a reloading or + * cross-replica client can rebuild and render the prompt from `resumeState`. + */ + pendingAction?: PendingAction; } /** * Represents a run step delta i.e. any changed fields on a run step during