🛡️ fix: Codex final round — paused-job TTL + pendingAction in resume contract

Two of three findings on e7d9cf21b6 (third deferred to Slice B):

- K2 paused-job TTL: a paused (requires_action) job no longer inherits the
  20-minute running TTL — it uses a dedicated requires_action backstop
  (default 24h, configurable) so a no-expiry approval (the buildPendingAction
  default), which the API treats as live, isn't evicted by Redis mid-window.
  A longer pendingAction.expiresAt still extends beyond the backstop.
- K3 resume contract: pendingAction is now carried on the typed ResumeState
  (data-provider) and populated by getResumeState for a live paused job, so a
  reloading / cross-replica client can rebuild the prompt from resumeState
  (the contract useResumeOnLoad actually reads), not just a loose status field.

Deferred (Slice B): K1 — emit a terminal SSE event on expiry so already-
subscribed clients close. Requires the manager/eventTransport layer (the
store-level lifecycle and cleanup loops have no transport access) and has no
live subscriber until the Slice B subscribe/resume path exists; tracked there.

tsc + lint clean; policy + type-contract specs pass.
This commit is contained in:
Danny Avila 2026-06-16 15:55:13 -04:00
parent e7d9cf21b6
commit c27a2b20d0
3 changed files with 33 additions and 7 deletions

View file

@ -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,
};
}

View file

@ -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. */

View file

@ -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