perf: Reduce Agent Chat Startup Latency (#14423)

* perf: reduce agent chat startup latency

* test: align Redis stream readiness assertions

* perf: overlap remaining agent startup work

* perf: persist initial agent job metadata atomically

* test: add agent startup latency benchmark

* fix: harden resumable agent stream lifecycle

* fix: isolate replacement stream lifecycles

* fix: preserve terminal stream epochs
This commit is contained in:
Danny Avila 2026-07-25 07:58:20 -04:00 committed by GitHub
parent cd215150cc
commit 73699b5c25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 12134 additions and 1744 deletions

View file

@ -201,13 +201,9 @@ beforeAll(async () => {
GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
GenerationJobManager.initialize();
GenerationJobManager.setApprovalExpiredHandler(async (conversationId) => {
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
});
}, 60000);
afterAll(async () => {
GenerationJobManager.setApprovalExpiredHandler(null);
await GenerationJobManager.destroy();
await mongoose.disconnect();
await mongoServer.stop();

View file

@ -147,14 +147,9 @@ beforeAll(async () => {
GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
GenerationJobManager.initialize();
// Mirrors api/server/index.js: expiry prunes the paused run's durable checkpoint.
GenerationJobManager.setApprovalExpiredHandler(async (conversationId) => {
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
});
}, 60000);
afterAll(async () => {
GenerationJobManager.setApprovalExpiredHandler(null);
await GenerationJobManager.destroy();
await mongoose.disconnect();
await mongoServer.stop();
@ -313,7 +308,7 @@ describe('HITL checkpoint lifecycle (full wiring)', () => {
expect(job).toBeDefined();
});
test('an abandoned pause is pruned eagerly on approval EXPIRY (not left to the TTL)', async () => {
test('an abandoned pause expires without deleting a replacement-scoped checkpoint', async () => {
const conversationId = `e2e-expiry-${Date.now()}`;
const run = await buildHitlRun({
saver,
@ -336,12 +331,15 @@ describe('HITL checkpoint lifecycle (full wiring)', () => {
});
await GenerationJobManager.approvals.pause(conversationId, pendingAction);
// The sweeper/stale-submit path: expiry fires the registered checkpoint prune.
// Expiry finalizes the stream, while checkpoint cleanup remains TTL-scoped. A
// thread-wide eager delete can race a replacement run on the same conversation.
expect(await GenerationJobManager.expireApproval(conversationId, pendingAction.actionId)).toBe(
true,
);
expect(await GenerationJobManager.getJobStatus(conversationId)).toBe('aborted');
expect((await checkpointCounts(conversationId)).checkpoints).toBeGreaterThan(0);
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
});
});

View file

@ -16,6 +16,10 @@ const mockGenerationJobManager = {
claimGeneration: jest.fn(),
releaseGeneration: jest.fn(),
hasJob: jest.fn(),
steering: {
closeAndDrain: jest.fn(),
park: jest.fn(),
},
};
const mockCheckAndIncrementPendingRequest = jest.fn();
@ -32,6 +36,14 @@ const mockFilterPersistableAbortContent = jest.fn((content) =>
const mockGetConvo = jest.fn();
const mockGetMessages = jest.fn();
const mockSaveMessage = jest.fn();
const mockStartupTelemetry = {
mark: jest.fn(),
setStreamId: jest.fn(),
recordGenerationEvent: jest.fn(),
end: jest.fn(),
};
const mockGetAgentStartupTelemetry = jest.fn(() => mockStartupTelemetry);
const mockAcceptAgentStartupTelemetry = jest.fn();
let mockMCPContexts = new WeakMap();
const mockCreateMCPRequestContext = jest.fn(() => ({
@ -94,6 +106,7 @@ jest.mock('@librechat/api', () => ({
getViolationInfo: (...args) => mockGetViolationInfo(...args),
buildMessageFiles: jest.fn(() => []),
resolveTitleTiming: jest.fn(() => 'immediate'),
resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor,
GenerationJobManager: mockGenerationJobManager,
getReferencedQuotes: jest.fn((quotes) => {
if (!Array.isArray(quotes)) {
@ -112,6 +125,8 @@ jest.mock('@librechat/api', () => ({
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
sanitizeMessageForTransmit: jest.fn((message) => message),
checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
getAgentStartupTelemetry: (...args) => mockGetAgentStartupTelemetry(...args),
acceptAgentStartupTelemetry: (...args) => mockAcceptAgentStartupTelemetry(...args),
isUnpersistedPreliminaryParent: async ({
userId,
conversationId,
@ -155,6 +170,7 @@ jest.mock('~/models', () => ({
}));
const AgentController = require('../request');
const { disposeClient: mockDisposeClient } = require('~/server/cleanup');
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
function createResumableResponse() {
@ -199,6 +215,8 @@ describe('ResumableAgentController resume metadata', () => {
mockGenerationJobManager.claimGeneration.mockResolvedValue({ claimed: true });
mockGenerationJobManager.releaseGeneration.mockResolvedValue(undefined);
mockGenerationJobManager.hasJob.mockResolvedValue(true);
mockGenerationJobManager.steering.closeAndDrain.mockResolvedValue([]);
mockGenerationJobManager.steering.park.mockResolvedValue(undefined);
mockSaveMessage.mockResolvedValue({});
});
@ -279,10 +297,17 @@ describe('ResumableAgentController resume metadata', () => {
conversationId,
'user-123',
conversationId,
expect.objectContaining({
startupTelemetry: mockStartupTelemetry,
initialMetadata: expect.objectContaining({
conversationId,
endpoint: 'agents',
}),
}),
);
});
it('stores the in-flight turn before MCP initialization can emit OAuth', async () => {
it('creates the job with the in-flight turn before MCP initialization can emit OAuth', async () => {
const conversationId = 'conversation-123';
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
const req = {
@ -292,6 +317,7 @@ describe('ResumableAgentController resume metadata', () => {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
isTemporary: true,
endpointOption: {
endpoint: 'agents',
iconURL: 'https://example.com/spec-icon.png',
@ -310,25 +336,98 @@ describe('ResumableAgentController resume metadata', () => {
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith(
conversationId,
expect.objectContaining({
conversationId,
endpoint: 'agents',
iconURL: 'https://example.com/spec-icon.png',
model: 'gpt-3.5-turbo',
responseMessageId: 'follow-up-user_',
userMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
'user-123',
conversationId,
{
startupTelemetry: mockStartupTelemetry,
initialMetadata: {
conversationId,
text: 'Check Google Workspace availability.',
endpoint: 'agents',
iconURL: 'https://example.com/spec-icon.png',
model: 'gpt-3.5-turbo',
agent_id: undefined,
isTemporary: true,
responseMessageId: 'follow-up-user_',
userMessage: {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
text: 'Check Google Workspace availability.',
},
},
}),
},
);
expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
expect(mockGenerationJobManager.createJob.mock.invocationCallOrder[0]).toBeLessThan(
initializeClient.mock.invocationCallOrder[0],
);
expect(mockGenerationJobManager.updateMetadata).not.toHaveBeenCalled();
const startupMilestones = mockStartupTelemetry.mark.mock.calls.map(([milestone]) => milestone);
expect(startupMilestones.slice(0, 2)).toEqual(['request_admitted', 'job_created']);
expect(new Set(startupMilestones.slice(2))).toEqual(
new Set(['conversation_resolved', 'metadata_persisted']),
);
expect(mockAcceptAgentStartupTelemetry).toHaveBeenCalledWith(req, conversationId);
expect(mockStartupTelemetry.end).toHaveBeenCalledWith('error', expect.any(Error));
});
it('prefetches conversation state before admission and joins it with job metadata', async () => {
let resolveConversation;
let signalMetadataStarted;
const conversationPromise = new Promise((resolve) => {
resolveConversation = resolve;
});
const metadataStarted = new Promise((resolve) => {
signalMetadataStarted = resolve;
});
mockGetConvo.mockReturnValue(conversationPromise);
mockGenerationJobManager.createJob.mockImplementation(() => {
signalMetadataStarted();
return Promise.resolve({
createdAt: 1000,
readyPromise: Promise.resolve(),
abortController: new AbortController(),
emitter: { on: jest.fn() },
});
});
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after startup reads'));
const conversationId = 'conversation-123';
const req = {
user: { id: 'user-123' },
body: {
text: 'Run independent startup work together.',
messageId: 'user-message',
parentMessageId: 'parent-message',
conversationId,
endpointOption: {
endpoint: 'agents',
modelOptions: { model: 'gpt-4.1' },
},
},
config: {},
};
const res = createResumableResponse();
const controllerPromise = AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGetConvo).toHaveBeenCalledWith('user-123', conversationId);
await metadataStarted;
await nextTick();
expect(mockGetConvo.mock.invocationCallOrder[0]).toBeLessThan(
mockCheckAndIncrementPendingRequest.mock.invocationCallOrder[0],
);
expect(res.json).toHaveBeenCalledWith({
streamId: conversationId,
conversationId,
status: 'started',
});
expect(initializeClient).not.toHaveBeenCalled();
resolveConversation({ createdAt: '2026-06-07T00:00:00.000Z' });
await controllerPromise;
expect(initializeClient).toHaveBeenCalledTimes(1);
});
it('keeps request-scoped MCP connections until resumable initialization finishes', async () => {
@ -382,6 +481,7 @@ describe('ResumableAgentController resume metadata', () => {
messageId: 'follow-up-user',
parentMessageId: 'original-response',
conversationId,
isTemporary: true,
endpointOption: {
endpoint: 'agents',
spec: 'agent-spec',
@ -413,11 +513,17 @@ describe('ResumableAgentController resume metadata', () => {
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith(
conversationId,
'user-123',
conversationId,
expect.objectContaining({
iconURL: 'https://example.com/preset-icon.png',
model: 'agent_resume_spec',
initialMetadata: expect.objectContaining({
iconURL: 'https://example.com/preset-icon.png',
model: 'agent_resume_spec',
agent_id: 'agent_resume_spec',
isTemporary: true,
}),
}),
);
});
@ -461,11 +567,15 @@ describe('ResumableAgentController resume metadata', () => {
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith(
conversationId,
'user-123',
conversationId,
expect.objectContaining({
iconURL: 'anthropic',
model: 'gpt-4.1',
initialMetadata: expect.objectContaining({
iconURL: 'anthropic',
model: 'gpt-4.1',
}),
}),
);
});
@ -662,6 +772,7 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled();
expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
expect(initializeClient).not.toHaveBeenCalled();
expect(mockStartupTelemetry.end).toHaveBeenCalledWith('deduplicated');
});
it('resumes when the job is missing but the claim is old (original completed and was cleaned up)', async () => {
@ -758,6 +869,32 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled();
});
it('does not finalize an unscoped generation when job creation rejects before returning', async () => {
mockGenerationJobManager.claimGeneration.mockResolvedValue({ claimed: true });
mockGenerationJobManager.createJob.mockRejectedValue(new Error('create failed before return'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Fail before receiving a job epoch.',
messageId: 'user-msg',
clientRequestId: 'req-abc',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
const res = createResumableResponse();
await AgentController(req, res, jest.fn(), jest.fn(), null);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'create failed before return' });
expect(mockGenerationJobManager.emitError).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith('user-123', 'req-abc');
expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123');
});
it('finalizes the failed job before releasing the idempotency claim', async () => {
mockGenerationJobManager.claimGeneration.mockResolvedValue({ claimed: true });
const initializeClient = jest.fn().mockRejectedValue(new Error('init boom after res.json'));
@ -779,6 +916,7 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
'conversation-123',
expect.any(String),
1000,
);
expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith('user-123', 'req-abc');
// completeJob must finalize the failed job BEFORE the claim is released, or a racing
@ -812,6 +950,150 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123');
});
it('still finalizes and releases when streaming the initialization error fails', async () => {
mockGenerationJobManager.claimGeneration.mockResolvedValue({ claimed: true });
mockGenerationJobManager.emitError.mockRejectedValue(new Error('publish failed'));
const initializeClient = jest.fn().mockRejectedValue(new Error('init boom after res.json'));
const req = {
user: { id: 'user-123' },
body: {
text: 'Start fails while Redis publish is degraded.',
messageId: 'user-msg',
clientRequestId: 'req-abc',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
const res = createResumableResponse();
await AgentController(req, res, jest.fn(), initializeClient, null);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
'conversation-123',
'init boom after res.json',
1000,
);
expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith('user-123', 'req-abc');
expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123');
expect(mockStartupTelemetry.end).toHaveBeenCalledWith('error', expect.any(Error));
});
it('finalizes and disposes a client aborted during initialization before releasing the slot', async () => {
const abortController = new AbortController();
let resolveCompletion;
let signalCompletionStarted;
const completionStarted = new Promise((resolve) => {
signalCompletionStarted = resolve;
});
mockGenerationJobManager.createJob.mockResolvedValue({
createdAt: 1000,
readyPromise: Promise.resolve(),
abortController,
emitter: { on: jest.fn() },
});
mockGenerationJobManager.completeJob.mockImplementation(() => {
signalCompletionStarted();
return new Promise((resolve) => {
resolveCompletion = resolve;
});
});
const client = { options: {} };
const initializeClient = jest.fn(async ({ signal }) => {
expect(signal).toBe(abortController.signal);
abortController.abort();
return { client };
});
const conversationId = 'conversation-123';
const req = {
user: { id: 'user-123' },
body: {
text: 'Stop during initialization.',
messageId: 'user-msg',
conversationId,
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
const res = createResumableResponse();
const controllerPromise = AgentController(req, res, jest.fn(), initializeClient, null);
await completionStarted;
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
conversationId,
'Request aborted during initialization',
1000,
);
expect(mockDecrementPendingRequest).not.toHaveBeenCalled();
expect(mockDisposeClient).not.toHaveBeenCalled();
resolveCompletion();
await controllerPromise;
expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123');
expect(mockDisposeClient).toHaveBeenCalledTimes(1);
expect(mockDisposeClient).toHaveBeenCalledWith(client);
expect(mockStartupTelemetry.end).toHaveBeenCalledWith('aborted');
});
it('awaits background error finalization before releasing the slot and always disposes', async () => {
const generationError = new Error('generation failed');
let rejectCompletion;
let signalCompletionStarted;
const completionStarted = new Promise((resolve) => {
signalCompletionStarted = resolve;
});
mockGenerationJobManager.emitError.mockRejectedValue(new Error('publish failed'));
mockGenerationJobManager.completeJob.mockImplementation(() => {
signalCompletionStarted();
return new Promise((_, reject) => {
rejectCompletion = reject;
});
});
const client = {
options: {},
sendMessage: jest.fn().mockRejectedValue(generationError),
};
const initializeClient = jest.fn().mockResolvedValue({ client });
const req = {
user: { id: 'user-123' },
body: {
text: 'Fail after initialization.',
messageId: 'user-msg',
conversationId: 'conversation-123',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
};
const res = createResumableResponse();
await AgentController(req, res, jest.fn(), initializeClient, null);
await completionStarted;
expect(mockGenerationJobManager.emitError).toHaveBeenCalledWith(
'conversation-123',
generationError.message,
1000,
);
expect(mockDecrementPendingRequest).not.toHaveBeenCalled();
expect(mockDisposeClient).not.toHaveBeenCalled();
rejectCompletion(new Error('store failed'));
await nextTick();
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
'conversation-123',
generationError.message,
1000,
);
expect(mockGenerationJobManager.completeJob.mock.invocationCallOrder[0]).toBeLessThan(
mockDecrementPendingRequest.mock.invocationCallOrder[0],
);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123');
expect(mockDisposeClient).toHaveBeenCalledWith(client);
});
it('proceeds to create the job when it wins the idempotency claim', async () => {
mockGenerationJobManager.claimGeneration.mockResolvedValue({ claimed: true });
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
@ -842,6 +1124,13 @@ describe('ResumableAgentController resume metadata', () => {
'conversation-123',
'user-123',
'conversation-123',
expect.objectContaining({
startupTelemetry: mockStartupTelemetry,
initialMetadata: expect.objectContaining({
conversationId: 'conversation-123',
endpoint: 'agents',
}),
}),
);
});
@ -869,6 +1158,7 @@ describe('ResumableAgentController resume metadata', () => {
expect(res.status).toHaveBeenCalledWith(429);
expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith('user-123', 'req-abc');
expect(mockStartupTelemetry.end).toHaveBeenCalledWith('rejected');
});
it('does not release a claim it never won when a fail-open duplicate hits the limiter', async () => {

View file

@ -59,6 +59,7 @@ const mockGenerationJobManager = {
};
const mockDeleteAgentCheckpoint = jest.fn();
const mockCaptureAgentCheckpointGeneration = jest.fn();
const mockDecrementPendingRequest = jest.fn();
const mockCheckAndIncrementPendingRequest = jest.fn();
@ -77,6 +78,7 @@ jest.mock('@librechat/data-schemas', () => ({
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
GenerationJobManager: mockGenerationJobManager,
captureAgentCheckpointGeneration: (...args) => mockCaptureAgentCheckpointGeneration(...args),
deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args),
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
@ -109,6 +111,7 @@ function makeToolApprovalJob(overrides = {}) {
const pendingOverrides = metaOverrides.pendingAction ?? {};
return {
status: 'requires_action',
createdAt: 1000,
abortController: new AbortController(),
...overrides,
metadata: {
@ -179,11 +182,19 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true });
mockDecrementPendingRequest.mockResolvedValue(undefined);
mockDeleteAgentCheckpoint.mockResolvedValue(undefined);
mockCaptureAgentCheckpointGeneration.mockResolvedValue({
threadId: CONVO_ID,
checkpointIds: ['checkpoint-old'],
});
mockCleanupMCPRequestContextForReq.mockResolvedValue(undefined);
mockSaveMessage.mockResolvedValue(undefined);
mockGetConvo.mockResolvedValue(null);
mockGetMessages.mockResolvedValue([]);
mockJobStore.getJob.mockResolvedValue({ tokenUsage: null, contextUsage: null });
mockJobStore.getJob.mockResolvedValue({
createdAt: 1000,
tokenUsage: null,
contextUsage: null,
});
mockJobStore.updateJob.mockResolvedValue(undefined);
mockGenerationJobManager.getResumeState.mockResolvedValue({ aggregatedContent: [] });
mockGenerationJobManager.emitDone.mockResolvedValue(undefined);
@ -555,6 +566,21 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('consumes a checkpoint-snapshot rejection on the 429 early-return path', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: false });
mockCaptureAgentCheckpointGeneration.mockRejectedValue(new Error('mongo down'));
const res = await post(approveBody());
await flush();
expect(res.status).toBe(429);
expect(mockLogger.warn).toHaveBeenCalledWith(
'[ResumeAgentController] Failed to capture checkpoint generation',
expect.any(Error),
);
});
it('409 and releases the slot when the action was already claimed (single-winner)', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockGenerationJobManager.approvals.resolve.mockResolvedValue(false);
@ -589,7 +615,13 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
conversationId: CONVO_ID,
status: 'resuming',
});
expect(mockCaptureAgentCheckpointGeneration).toHaveBeenCalledWith(CONVO_ID, {
type: 'mongo',
});
expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledWith(CONVO_ID, ACTION_ID);
expect(mockCaptureAgentCheckpointGeneration.mock.invocationCallOrder[0]).toBeLessThan(
mockGenerationJobManager.approvals.resolve.mock.invocationCallOrder[0],
);
await settled;
await flush();
});
@ -732,12 +764,31 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
});
expect(typeof finalEvent.title).toBe('string');
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(CONVO_ID, { type: 'mongo' });
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID, undefined, 1000);
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(
CONVO_ID,
{ type: 'mongo' },
{ threadId: CONVO_ID, checkpointIds: ['checkpoint-old'] },
);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
expect(mockDisposeClient).toHaveBeenCalledTimes(1);
});
it('degrades a failed checkpoint snapshot to scoped no-op cleanup', async () => {
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockCaptureAgentCheckpointGeneration.mockRejectedValue(new Error('mongo down'));
await post(approveBody());
await settled;
await flush();
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(
CONVO_ID,
{ type: 'mongo' },
{ threadId: CONVO_ID, checkpointIds: [] },
);
});
it('skips finalization (no save/emitDone/complete) when the job was replaced mid-resume', async () => {
// The paused job has createdAt 1000; a concurrent request reused this conversationId,
// so the live job now has a different createdAt — finalizing would clobber the newer
@ -941,7 +992,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect(client.resumeCompletion).toHaveBeenCalledWith(
expect.objectContaining({ resumeValue: { answer: 'call it report.pdf' } }),
);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID, undefined, 1000);
});
it('generates a title for a first-turn pause before completing the stream', async () => {
@ -956,7 +1007,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect(mockAddTitle).toHaveBeenCalledTimes(1);
// Title is emitted (and the job completed) — order matters but both must happen.
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID, undefined, 1000);
});
it('still finalizes the turn when first-turn title generation throws', async () => {
@ -973,8 +1024,12 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect(mockLogger.error).toHaveBeenCalled();
expect(mockSaveMessage).toHaveBeenCalledTimes(1);
expect(mockGenerationJobManager.emitDone).toHaveBeenCalledWith(CONVO_ID, expect.any(Object));
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID);
expect(mockGenerationJobManager.emitDone).toHaveBeenCalledWith(
CONVO_ID,
expect.any(Object),
1000,
);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID, undefined, 1000);
});
});
@ -1095,9 +1150,13 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
await settled;
await flush();
expect(mockGenerationJobManager.emitError).toHaveBeenCalledWith(CONVO_ID, 'boom');
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID, 'boom');
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(CONVO_ID, { type: 'mongo' });
expect(mockGenerationJobManager.emitError).toHaveBeenCalledWith(CONVO_ID, 'boom', 1000);
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(CONVO_ID, 'boom', 1000);
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith(
CONVO_ID,
{ type: 'mongo' },
{ threadId: CONVO_ID, checkpointIds: ['checkpoint-old'] },
);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
expect(mockSaveMessage).not.toHaveBeenCalled();
});
@ -1121,6 +1180,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect(mockJobStore.updateJob).toHaveBeenCalledWith(
CONVO_ID,
expect.objectContaining({ status: 'error', error: 'Resume failed' }),
1000,
);
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(USER_ID);
});

View file

@ -123,6 +123,8 @@ class AgentClient extends BaseClient {
/** @deprecated @type {true} - Is a Chat Completion Request */
this.isChatCompletion = true;
/** @type {number | undefined} */
this.jobCreatedAt = options.jobCreatedAt;
/** @type {AgentRun} */
this.run;
@ -429,7 +431,37 @@ class AgentClient extends BaseClient {
}))
: []),
];
/**
* Memory authorization/loading and MCP config resolution do not depend on
* attachment hydration or prompt formatting. Start them before that work,
* but keep the existing context-application barrier below.
*
* Attach a rejection observer immediately because these operations may
* settle while request attachments are still being prepared. Awaiting the
* original promise later still propagates either error.
*/
const earlySharedContextPromise = Promise.all([
this.useMemory(),
resolveConfigServers(this.options.req),
]);
void earlySharedContextPromise.catch(() => {});
const sharedRunAttachmentIds = new Set();
/** @type {ReturnType<typeof buildAgentScopedContext>} */
let agentScopedContextPromise;
const startAgentScopedContext = () => {
const contextPromise = buildAgentScopedContext({
agentIds: allAgents.map(({ agentId }) => agentId),
attachmentsByAgentId: this.options.agentContextAttachmentsByAgentId,
sharedRunAttachmentIds,
req: this.options.req,
tokenCountFn: (text) => countTokens(text),
});
void contextPromise.catch(() => {});
return contextPromise;
};
if (this.options.attachments) {
const attachments = await this.options.attachments;
const latestMessage = orderedMessages[orderedMessages.length - 1];
@ -438,6 +470,9 @@ class AgentClient extends BaseClient {
sharedRunAttachmentIds.add(fileId);
}
/** Agent-scoped extraction only depends on the shared attachment IDs. */
agentScopedContextPromise = startAgentScopedContext();
if (this.message_file_map) {
this.message_file_map[latestMessage.messageId] = attachments;
} else {
@ -446,10 +481,14 @@ class AgentClient extends BaseClient {
};
}
await this.addFileContextToMessage(latestMessage, attachments);
const files = await this.processAttachments(latestMessage, attachments);
const [, files] = await Promise.all([
this.addFileContextToMessage(latestMessage, attachments),
this.processAttachments(latestMessage, attachments),
]);
this.options.attachments = files;
} else {
agentScopedContextPromise = startAgentScopedContext();
}
/** Note: Bedrock uses legacy RAG API handling */
@ -655,19 +694,21 @@ class AgentClient extends BaseClient {
* Memory context is handled separately and applied per-agent based on config.
*/
const sharedRunContextParts = [];
const [augmentedPrompt, [memories, configServers], agentScopedContext] = await Promise.all([
this.contextHandlers?.createContext(),
earlySharedContextPromise,
agentScopedContextPromise,
]);
/** Augmented prompt from RAG/context handlers */
if (this.contextHandlers) {
this.augmentedPrompt = await this.contextHandlers.createContext();
if (this.augmentedPrompt) {
sharedRunContextParts.push(this.augmentedPrompt);
}
this.augmentedPrompt = augmentedPrompt;
if (this.augmentedPrompt) {
sharedRunContextParts.push(this.augmentedPrompt);
}
/** Memory context (user preferences/memories). Keyed context (with memory
* keys + token metadata) is reserved for agents that can call
* `delete_memory`; everyone else gets the unkeyed values only. */
const memories = await this.useMemory();
/** Partition the loaded memories belong to (the primary agent's). */
const loadedMemoryAgentId = getMemoryAgentId(this.options.agent);
const buildMemoryContext = (text) =>
@ -700,14 +741,6 @@ class AgentClient extends BaseClient {
const sharedRunContext = sharedRunContextParts.join('\n\n');
const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory);
const agentScopedContext = await buildAgentScopedContext({
agentIds: allAgents.map(({ agentId }) => agentId),
attachmentsByAgentId: this.options.agentContextAttachmentsByAgentId,
sharedRunAttachmentIds,
req: this.options.req,
tokenCountFn: (text) => countTokens(text),
});
/** Preserve prompt token counts for graph formatting and pruning. */
this.indexTokenCountMap = indexTokenCountMap;
@ -741,8 +774,6 @@ class AgentClient extends BaseClient {
const ephemeralAgent = this.options.req.body.ephemeralAgent;
const mcpManager = getMCPManager();
const configServers = await resolveConfigServers(this.options.req);
await Promise.all(
allAgents.map(async ({ agent, agentId }) => {
const agentRunContextParts = [sharedRunContext];
@ -1471,9 +1502,13 @@ class AgentClient extends BaseClient {
if (Array.isArray(runMessages) && runMessages.length > 0) {
const discovered = extractDiscoveredToolsFromHistory(runMessages);
if (discovered.size > 0) {
await GenerationJobManager.updateMetadata(streamId, {
discoveredTools: Array.from(discovered),
});
await GenerationJobManager.updateMetadata(
streamId,
{
discoveredTools: Array.from(discovered),
},
this.jobCreatedAt,
);
}
}
} catch (err) {
@ -1762,7 +1797,29 @@ class AgentClient extends BaseClient {
}
const streamId = this.options.req?._resumableStreamId;
run = await createRun({
// HITL: clear any checkpoint orphaned by a prior paused turn in this
// conversation (one that expired or was aborted while paused) so this fresh
// turn starts clean instead of rehydrating a stale interrupt — thread_id is
// the stable conversationId. No-op when HITL is off or nothing is orphaned.
// Deliberately UNCONDITIONAL per HITL turn: any cheaper gate (job metadata,
// a Redis flag) can go stale across replicas/restarts and skip the prune
// exactly when an orphan exists, while these are two indexed, usually-empty
// deleteMany ops — correctness over a micro-optimization.
// The gate mirrors createRun's checkpointer condition: the approval policy
// OR an ask_user_question-capable agent (which attaches a checkpointer
// WITHOUT the approval policy) — an ask pause abandoned via job replacement
// or Stop would otherwise rehydrate here and silently duplicate context.
//
// Start the prune alongside graph construction. The all-settled barrier
// below still guarantees it completes before the graph is exposed or run.
const shouldPruneCheckpoint =
streamId &&
(isHITLEnabled(agentsEConfig?.toolApproval) || agents.some(agentRequestsAskUserQuestion));
const checkpointPrunePromise = shouldPruneCheckpoint
? deleteAgentCheckpoint(this.conversationId, agentsEConfig?.checkpointer)
: Promise.resolve();
const createRunPromise = createRun({
agents,
messages,
// This controller implements the full HITL pause/resume lifecycle (handleRunInterrupt
@ -1802,11 +1859,25 @@ class AgentClient extends BaseClient {
this.collectedUsage,
this.buildSubagentUsageEmitter(appConfig),
),
}).then((createdRun) => {
if (!createdRun) {
throw new Error('Failed to create run');
}
this.options.startupTelemetry?.mark('run_created');
return createdRun;
});
if (!run) {
throw new Error('Failed to create run');
const [createRunResult, checkpointPruneResult] = await Promise.allSettled([
createRunPromise,
checkpointPrunePromise,
]);
if (createRunResult.status === 'rejected') {
throw createRunResult.reason;
}
if (checkpointPruneResult.status === 'rejected') {
throw checkpointPruneResult.reason;
}
run = createRunResult.value;
this.run = run;
if (this._resolveRun) {
@ -1815,7 +1886,7 @@ class AgentClient extends BaseClient {
}
if (streamId && run.Graph) {
GenerationJobManager.setGraph(streamId, run.Graph);
GenerationJobManager.setGraph(streamId, run.Graph, this.jobCreatedAt);
}
if (userMCPAuthMap != null) {
@ -1825,25 +1896,7 @@ class AgentClient extends BaseClient {
/** @deprecated Agent Chain */
config.configurable.last_agent_id = agents[agents.length - 1].id;
// HITL: clear any checkpoint orphaned by a prior paused turn in this
// conversation (one that expired or was aborted while paused) so this fresh
// turn starts clean instead of rehydrating a stale interrupt — thread_id is
// the stable conversationId. No-op when HITL is off or nothing is orphaned.
// Deliberately UNCONDITIONAL per HITL turn: any cheaper gate (job metadata,
// a Redis flag) can go stale across replicas/restarts and skip the prune
// exactly when an orphan exists, while these are two indexed, usually-empty
// deleteMany ops — correctness over a micro-optimization.
// The gate mirrors createRun's checkpointer condition: the approval policy
// OR an ask_user_question-capable agent (which attaches a checkpointer
// WITHOUT the approval policy) — an ask pause abandoned via job replacement
// or Stop would otherwise rehydrate here and silently duplicate context.
if (
streamId &&
(isHITLEnabled(agentsEConfig?.toolApproval) || agents.some(agentRequestsAskUserQuestion))
) {
await deleteAgentCheckpoint(this.conversationId, agentsEConfig?.checkpointer);
}
this.options.startupTelemetry?.mark('stream_processing_started');
await run.processStream({ messages }, config, {
callbacks: {
[Callback.TOOL_ERROR]: logToolError,
@ -1858,6 +1911,7 @@ class AgentClient extends BaseClient {
config.signal = null;
};
this.options.startupTelemetry?.mark('run_input_prepared');
await runAgents(initialMessages);
/**
@ -2162,7 +2216,7 @@ class AgentClient extends BaseClient {
// introspection fall back to the durable chunk reconstruction, which is complete.
// `setContentParts` still points the in-memory store at the seeded client content.
if (streamId && this.contentParts) {
GenerationJobManager.setContentParts(streamId, this.contentParts);
GenerationJobManager.setContentParts(streamId, this.contentParts, this.jobCreatedAt);
}
// Carry the user's MCP auth into the rebuilt run so an approved MCP tool executes

View file

@ -1,6 +1,28 @@
const mockCreateRun = jest.fn();
const mockDeleteAgentCheckpoint = jest.fn();
const mockIsHITLEnabled = jest.fn().mockReturnValue(false);
const mockBuildAgentScopedContext = jest.fn((...args) =>
jest.requireActual('@librechat/api').buildAgentScopedContext(...args),
);
const mockFormatAgentMessages = jest.fn(() => ({
messages: [],
indexTokenCountMap: {},
summary: undefined,
boundaryTokenAdjustment: undefined,
}));
const { Providers } = require('@librechat/agents');
const { Constants, ContentTypes, EModelEndpoint } = require('librechat-data-provider');
const AgentClient = require('./client');
const { resolveConfigServers } = require('~/server/services/MCP');
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
@ -8,14 +30,20 @@ jest.mock('@librechat/agents', () => ({
handleLLMEnd: jest.fn(),
collected: [],
}),
formatAgentMessages: (...args) => mockFormatAgentMessages(...args),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args),
checkAccess: jest.fn(),
createRun: (...args) => mockCreateRun(...args),
countFormattedMessageTokens: jest.fn(() => 42),
countTokens: jest.fn((text) => Math.ceil(String(text ?? '').length / 4)),
createTokenCounter: jest.fn(() => jest.fn(() => 0)),
deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args),
initializeAgent: jest.fn(),
isHITLEnabled: (...args) => mockIsHITLEnabled(...args),
createMemoryProcessor: jest.fn(),
isMemoryAgentEnabled: jest.fn((config) => {
if (!config || config.disabled === true) return false;
@ -24,6 +52,7 @@ jest.mock('@librechat/api', () => ({
return Boolean(agent.id || (agent.provider && agent.model));
}),
loadAgent: jest.fn(),
maybePrewarmCodeSandbox: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
@ -74,6 +103,94 @@ describe('AgentClient - applyHideSequentialOutputsFilter', () => {
});
});
describe('AgentClient - startup telemetry', () => {
it('overlaps run creation with checkpoint pruning and joins both before stream processing', async () => {
let releaseCheckpoint;
let checkpointStarted;
const runCreation = deferred();
const checkpointStartedPromise = new Promise((resolve) => {
checkpointStarted = resolve;
});
const checkpointPromise = new Promise((resolve) => {
releaseCheckpoint = resolve;
});
const processStream = jest.fn().mockResolvedValue();
const run = {
Graph: null,
processStream,
getCalibrationRatio: jest.fn(() => 0),
};
const startupTelemetry = {
mark: jest.fn(),
setStreamId: jest.fn(),
recordGenerationEvent: jest.fn(),
end: jest.fn(),
};
mockCreateRun.mockReturnValue(runCreation.promise);
mockIsHITLEnabled.mockReturnValue(true);
mockDeleteAgentCheckpoint.mockImplementation(() => {
checkpointStarted();
return checkpointPromise;
});
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: {},
config: { endpoints: { [EModelEndpoint.agents]: { toolApproval: {} } } },
_resumableStreamId: 'conversation-123',
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
hide_sequential_outputs: false,
},
endpointTokenConfig: {},
eventHandlers: {},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
startupTelemetry,
});
client.conversationId = 'conversation-123';
client.responseMessageId = 'response-123';
client.parentMessageId = 'parent-123';
client.recordCollectedUsage = jest.fn().mockResolvedValue();
const completionPromise = client.chatCompletion({ payload: [] });
await checkpointStartedPromise;
expect(mockCreateRun).toHaveBeenCalledTimes(1);
expect(mockDeleteAgentCheckpoint).toHaveBeenCalledWith('conversation-123', undefined);
expect(startupTelemetry.mark.mock.calls.map(([milestone]) => milestone)).toEqual([
'run_input_prepared',
]);
expect(processStream).not.toHaveBeenCalled();
runCreation.resolve(run);
await Promise.resolve();
expect(startupTelemetry.mark.mock.calls.map(([milestone]) => milestone)).toEqual([
'run_input_prepared',
'run_created',
]);
expect(processStream).not.toHaveBeenCalled();
releaseCheckpoint();
await completionPromise;
expect(startupTelemetry.mark.mock.calls.map(([milestone]) => milestone)).toEqual([
'run_input_prepared',
'run_created',
'stream_processing_started',
]);
expect(processStream).toHaveBeenCalledTimes(1);
});
});
describe('AgentClient - titleConvo', () => {
let client;
let mockRun;
@ -1423,6 +1540,112 @@ describe('AgentClient - titleConvo', () => {
client.maxContextTokens = 4096;
});
it('loads RAG, memory, attachment, and MCP context without serial waits', async () => {
const ragContext = deferred();
const memoryContext = deferred();
const mcpConfig = deferred();
client.contextHandlers = {
createContext: jest.fn(() => ragContext.promise),
};
client.useMemory = jest.fn(() => memoryContext.promise);
resolveConfigServers.mockReturnValueOnce(mcpConfig.promise);
const buildPromise = client.buildMessages(
[
{
messageId: 'msg-1',
parentMessageId: null,
sender: 'User',
text: 'Load all context.',
isCreatedByUser: true,
},
],
null,
{},
);
expect(client.contextHandlers.createContext).toHaveBeenCalledTimes(1);
expect(client.useMemory).toHaveBeenCalledTimes(1);
expect(resolveConfigServers).toHaveBeenCalledWith(mockReq);
ragContext.resolve('Retrieved context');
memoryContext.resolve(undefined);
mcpConfig.resolve({});
await buildPromise;
expect(client.augmentedPrompt).toBe('Retrieved context');
expect(client.options.agent.additional_instructions).toContain('Retrieved context');
});
it('starts independent context and current-file work at their earliest dependency barriers', async () => {
const requestAttachments = deferred();
const memoryContext = deferred();
const mcpConfig = deferred();
const agentScopedContext = deferred();
const fileContext = deferred();
const providerAttachments = deferred();
const requestFile = {
file_id: 'request-file',
filename: 'request.txt',
source: 'text',
type: 'text/plain',
};
client.options.attachments = requestAttachments.promise;
client.useMemory = jest.fn(() => memoryContext.promise);
resolveConfigServers.mockReturnValueOnce(mcpConfig.promise);
mockBuildAgentScopedContext.mockReturnValueOnce(agentScopedContext.promise);
client.addFileContextToMessage = jest.fn(() => fileContext.promise);
client.processAttachments = jest.fn(() => providerAttachments.promise);
const buildPromise = client.buildMessages(
[
{
messageId: 'msg-early-context',
parentMessageId: null,
sender: 'User',
text: 'Load the request file.',
isCreatedByUser: true,
},
],
'msg-early-context',
{},
);
expect(client.useMemory).toHaveBeenCalledTimes(1);
expect(resolveConfigServers).toHaveBeenCalledWith(mockReq);
expect(mockBuildAgentScopedContext).not.toHaveBeenCalled();
expect(client.addFileContextToMessage).not.toHaveBeenCalled();
expect(client.processAttachments).not.toHaveBeenCalled();
requestAttachments.resolve([requestFile]);
await Promise.resolve();
expect(mockBuildAgentScopedContext).toHaveBeenCalledTimes(1);
const scopedContextArgs = mockBuildAgentScopedContext.mock.calls[0][0];
expect([...scopedContextArgs.sharedRunAttachmentIds]).toEqual(['request-file']);
expect(client.addFileContextToMessage).toHaveBeenCalledWith(
expect.objectContaining({ messageId: 'msg-early-context' }),
[requestFile],
);
expect(client.processAttachments).toHaveBeenCalledWith(
expect.objectContaining({ messageId: 'msg-early-context' }),
[requestFile],
);
providerAttachments.resolve([requestFile]);
await Promise.resolve();
expect(client.options.attachments).toBe(requestAttachments.promise);
fileContext.resolve();
memoryContext.resolve(undefined);
mcpConfig.resolve({});
agentScopedContext.resolve(new Map());
await buildPromise;
expect(client.options.attachments).toEqual([requestFile]);
});
it('should await MCP instructions and not include [object Promise] in agent instructions', async () => {
// Set specific return value for this test
mockFormatInstructions.mockResolvedValue(

View file

@ -13,6 +13,9 @@ const {
sanitizeMessageForTransmit,
checkAndIncrementPendingRequest,
isUnpersistedPreliminaryParent,
resolveConversationAnchor,
getAgentStartupTelemetry,
acceptAgentStartupTelemetry,
} = require('@librechat/api');
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const {
@ -41,44 +44,24 @@ function createCloseHandler(abortController) {
};
}
function toValidISOString(value) {
if (value == null) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
async function resolveConversationCreatedAt({ userId, conversationId, isNewConvo }) {
if (isNewConvo) {
return { createdAt: new Date().toISOString(), conversation: undefined };
}
try {
const conversation = await getConvo(userId, conversationId);
return {
conversation,
createdAt: toValidISOString(conversation?.createdAt) ?? new Date().toISOString(),
};
} catch (error) {
logger.warn('[AgentController] Failed to resolve conversation timestamp anchor', {
conversationId,
error: error?.message ?? error,
});
return { createdAt: new Date().toISOString(), conversation: undefined };
}
}
async function attachConversationCreatedAt(req, { userId, conversationId, isNewConvo }) {
req.body.conversationId = conversationId;
const resolved = await resolveConversationCreatedAt({
userId,
conversationId,
isNewConvo,
function resolveConversationCreatedAt({ userId, conversationId, isNewConvo }) {
return resolveConversationAnchor({
isNewConversation: isNewConvo,
loadConversation: () => getConvo(userId, conversationId),
onLoadError: (error) => {
logger.warn('[AgentController] Failed to resolve conversation timestamp anchor', {
conversationId,
error: error.message,
});
},
});
}
async function attachConversationCreatedAt(req, conversationId, conversationAnchorPromise) {
req.body.conversationId = conversationId;
const resolved = await conversationAnchorPromise;
req.conversationCreatedAt = resolved.createdAt;
if (!isNewConvo && resolved.conversation !== undefined) {
if (resolved.conversation !== undefined) {
req.resolvedConversation = resolved.conversation ?? null;
}
}
@ -212,6 +195,7 @@ function rejectPreliminaryParentMessageId(res) {
* Returns streamId immediately, client subscribes separately via SSE.
*/
const ResumableAgentController = async (req, res, next, initializeClient, addTitle) => {
const startupTelemetry = getAgentStartupTelemetry(req);
const {
text,
isRegenerate,
@ -225,6 +209,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
} = req.body;
const userId = req.user.id;
const isNewConvo = !reqConversationId || reqConversationId === 'new';
const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId;
const conversationAnchorPromise = resolveConversationCreatedAt({
userId,
conversationId,
isNewConvo,
});
if (
await isUnpersistedPreliminaryParent({
@ -234,6 +225,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
getMessages,
})
) {
startupTelemetry?.end('rejected');
return rejectPreliminaryParentMessageId(res);
}
@ -245,8 +237,6 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// Generate conversationId upfront if not provided - streamId === conversationId always
// Treat "new" as a placeholder that needs a real UUID (frontend may send "new" for new convos)
const isNewConvo = !reqConversationId || reqConversationId === 'new';
const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId;
const streamId = conversationId;
req.body.conversationId = conversationId;
@ -296,6 +286,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
err,
);
res.set('Retry-After', '1');
startupTelemetry?.end('deduplicated');
return res.status(503).json({
code: 'SERVER_NOT_READY',
error: 'Generation is still starting. Please retry shortly.',
@ -308,6 +299,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// the winner goes on to generate and bill with no UI attached — ask the client to
// retry via the readiness path instead.
res.set('Retry-After', '1');
startupTelemetry?.end('deduplicated');
return res.status(503).json({
code: 'SERVER_NOT_READY',
error: 'Generation is still starting. Please retry shortly.',
@ -322,6 +314,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
clientRequestId,
streamId: existingStreamId,
});
startupTelemetry?.end('deduplicated');
return res.json({
streamId: existingStreamId,
conversationId: claim.existing.conversationId,
@ -337,10 +330,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
}
const violationInfo = getViolationInfo(pendingRequests, limit);
await logViolation(req, res, ViolationTypes.CONCURRENT, violationInfo, violationInfo.score);
startupTelemetry?.end('rejected');
return res.status(429).json(violationInfo);
}
startupTelemetry?.mark('request_admitted');
let client = null;
let jobCreatedAt;
try {
logger.debug(`[ResumableAgentController] Creating job`, {
@ -350,8 +346,31 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
userId,
});
const job = await GenerationJobManager.createJob(streamId, userId, conversationId);
const jobCreatedAt = job.createdAt; // Capture creation time to detect job replacement
const endpointIconURL = getEndpointIconURL(req, endpointOption);
const responseModel = getAgentResponseModel(req, endpointOption);
const preliminaryUserMessage = getPreliminaryUserMessage(req.body, conversationId);
const preliminaryResponseMessageId = getPreliminaryResponseMessageId(req.body);
const job = await GenerationJobManager.createJob(streamId, userId, conversationId, {
startupTelemetry,
initialMetadata: {
conversationId,
endpoint: endpointOption.endpoint,
iconURL: endpointIconURL,
model: responseModel,
// Persist the originating agent so a HITL resume can refuse to rebuild this
// paused run on a different agent (see resume.js).
agent_id: endpointOption.agent_id ?? req.body?.agent_id,
// Persist temporary-chat state so a HITL resume keeps the resumed response
// non-persisted instead of trusting the resume request to re-send the flag.
isTemporary: req.body?.isTemporary,
responseMessageId: preliminaryResponseMessageId,
userMessage: preliminaryUserMessage,
},
});
startupTelemetry?.mark('job_created');
acceptAgentStartupTelemetry(req, streamId);
startupTelemetry?.mark('metadata_persisted');
jobCreatedAt = job.createdAt; // Capture creation time to detect job replacement
req._resumableStreamId = streamId;
getMCPRequestContext(req, undefined, { cleanupOnResponse: false });
@ -359,26 +378,9 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// This is critical: tool loading (MCP OAuth) may emit events that the client needs to receive
res.json({ streamId, conversationId, status: 'started' });
await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo });
const endpointIconURL = getEndpointIconURL(req, endpointOption);
const responseModel = getAgentResponseModel(req, endpointOption);
const preliminaryUserMessage = getPreliminaryUserMessage(req.body, conversationId);
const preliminaryResponseMessageId = getPreliminaryResponseMessageId(req.body);
await GenerationJobManager.updateMetadata(streamId, {
conversationId,
endpoint: endpointOption.endpoint,
iconURL: endpointIconURL,
model: responseModel,
// Persist the originating agent so a HITL resume can refuse to rebuild this
// paused run on a different agent (see resume.js).
agent_id: endpointOption.agent_id ?? req.body?.agent_id,
// Persist temporary-chat state so a HITL resume keeps the resumed response
// non-persisted instead of trusting the resume request to re-send the flag.
isTemporary: req.body?.isTemporary,
responseMessageId: preliminaryResponseMessageId,
userMessage: preliminaryUserMessage,
});
await attachConversationCreatedAt(req, conversationId, conversationAnchorPromise).then(() =>
startupTelemetry?.mark('conversation_resolved'),
);
// Note: We no longer use res.on('close') to abort since we send JSON immediately.
// The response closes normally after res.json(), which is not an abort condition.
@ -463,15 +465,34 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
endpointOption,
// Use the job's abort controller signal - allows abort via GenerationJobManager.abortJob()
signal: job.abortController.signal,
jobCreatedAt,
});
startupTelemetry?.mark('client_initialized');
client = result.client;
if (job.abortController.signal.aborted) {
GenerationJobManager.completeJob(streamId, 'Request aborted during initialization');
await finishResumableRequest(req, userId);
await GenerationJobManager.completeJob(
streamId,
'Request aborted during initialization',
jobCreatedAt,
).catch((completeErr) => {
logger.warn(
'[ResumableAgentController] completeJob failed after initialization abort',
completeErr,
);
});
startupTelemetry?.end('aborted');
try {
await finishResumableRequest(req, userId);
} finally {
if (client) {
disposeClient(client);
}
client = null;
}
return;
}
client = result.client;
// Tag the client with THIS generation's identity so HITL terminal side-effects
// (pause CAS, checkpoint prune) can tell whether a newer request has since replaced
// this job on the same conversationId before acting on it.
@ -485,12 +506,18 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
});
if (client?.sender) {
GenerationJobManager.updateMetadata(streamId, { sender: client.sender });
void GenerationJobManager.updateMetadata(
streamId,
{ sender: client.sender },
jobCreatedAt,
).catch((err) => {
logger.warn('[ResumableAgentController] Failed to persist response sender', err);
});
}
// Store reference to client's contentParts - graph will be set when run is created
if (client?.contentParts) {
GenerationJobManager.setContentParts(streamId, client.contentParts);
GenerationJobManager.setContentParts(streamId, client.contentParts, jobCreatedAt);
}
let userMessage;
@ -502,24 +529,33 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// conversationId is pre-generated, no need to update from callback
};
// Start background generation - readyPromise resolves immediately now
// (sync mechanism handles late subscribers)
const startGeneration = async () => {
try {
// Short timeout as safety net - promise should already be resolved
await Promise.race([job.readyPromise, new Promise((resolve) => setTimeout(resolve, 100))]);
} catch (waitError) {
logger.warn(
`[ResumableAgentController] Error waiting for subscriber: ${waitError.message}`,
);
let immediateTitlePromise = null;
let backgroundClientCleanupScheduled = false;
const disposeBackgroundClient = () => {
if (backgroundClientCleanupScheduled) {
return;
}
backgroundClientCleanupScheduled = true;
if (immediateTitlePromise) {
immediateTitlePromise.finally(() => {
if (client) {
disposeClient(client);
}
});
} else if (client) {
disposeClient(client);
}
};
// Start background generation immediately. The stream layer buffers and persists events
// until an SSE subscriber attaches, so generation no longer waits on subscriber readiness.
const startGeneration = async () => {
/** Immediate-mode title generation runs in parallel with the response, so
* the conversation row may not exist when the title resolves. `convoReady`
* resolves once the response (and thus the conversation) has been saved,
* gating the title's `saveConvo`. Declared here so both the success tail
* and the catch block can settle it and gate `disposeClient` on the title. */
let immediateTitlePromise = null;
let titleEventPromise = null;
let acceptsTitleEvents = true;
let resolveConvoReady;
@ -574,31 +610,37 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
userMessage = userMsg;
// Store userMessage and responseMessageId upfront for resume capability
GenerationJobManager.updateMetadata(streamId, {
responseMessageId: respMsgId,
userMessage: {
messageId: userMsg.messageId,
parentMessageId: userMsg.parentMessageId,
conversationId: userMsg.conversationId,
text: userMsg.text,
quotes: userMsg.quotes,
// Persist the turn's uploaded files here (authoritative job metadata) so a
// HITL resume sources them from the job, not the user DB row — which the
// approval prompt can race (the row save may still be in flight when a fast
// /resume reads it). Without this an approved tool run can rebuild without the
// paused turn's files.
...(Array.isArray(req.body?.files) &&
req.body.files.length > 0 && { files: req.body.files }),
// Skill selections aren't on `userMsg` yet at onStart (BaseClient adds them
// later), so source them from the request — otherwise this update overwrites
// the preliminary metadata and a HITL-resumed turn loses its skill pills.
...(Array.isArray(req.body?.manualSkills) &&
req.body.manualSkills.length > 0 && { manualSkills: req.body.manualSkills }),
...(Array.isArray(req.body?.alwaysAppliedSkills) &&
req.body.alwaysAppliedSkills.length > 0 && {
alwaysAppliedSkills: req.body.alwaysAppliedSkills,
}),
GenerationJobManager.updateMetadata(
streamId,
{
responseMessageId: respMsgId,
userMessage: {
messageId: userMsg.messageId,
parentMessageId: userMsg.parentMessageId,
conversationId: userMsg.conversationId,
text: userMsg.text,
quotes: userMsg.quotes,
// Persist the turn's uploaded files here (authoritative job metadata) so a
// HITL resume sources them from the job, not the user DB row — which the
// approval prompt can race (the row save may still be in flight when a fast
// /resume reads it). Without this an approved tool run can rebuild without the
// paused turn's files.
...(Array.isArray(req.body?.files) &&
req.body.files.length > 0 && { files: req.body.files }),
// Skill selections aren't on `userMsg` yet at onStart (BaseClient adds them
// later), so source them from the request — otherwise this update overwrites
// the preliminary metadata and a HITL-resumed turn loses its skill pills.
...(Array.isArray(req.body?.manualSkills) &&
req.body.manualSkills.length > 0 && { manualSkills: req.body.manualSkills }),
...(Array.isArray(req.body?.alwaysAppliedSkills) &&
req.body.alwaysAppliedSkills.length > 0 && {
alwaysAppliedSkills: req.body.alwaysAppliedSkills,
}),
},
},
jobCreatedAt,
).catch((err) => {
logger.error('[ResumableAgentController] Failed to persist start metadata', err);
});
GenerationJobManager.emitChunk(streamId, {
@ -621,6 +663,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
}),
},
streamId,
}).catch((err) => {
logger.error('[ResumableAgentController] Failed to queue created event', err);
});
};
@ -757,6 +801,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
logger.debug(
`[ResumableAgentController] Turn paused for approval; awaiting resume: ${streamId}`,
);
startupTelemetry?.end('paused');
return;
}
@ -834,6 +879,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
resolveConvoReady();
// Still decrement pending request since we incremented at start
await finishResumableRequest(req, userId);
startupTelemetry?.end('replaced');
if (immediateTitlePromise) {
immediateTitlePromise.finally(() => {
if (client) {
@ -881,10 +927,15 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// Parked BEFORE the final event: a client with no live subscriber
// recovers these via /chat/status (claim-on-read) within the
// recovery TTL — the SSE copy alone is transient.
await GenerationJobManager.steering.park(streamId, pendingSteers, {
userId,
tenantId: req.user?.tenantId,
});
await GenerationJobManager.steering.park(
streamId,
pendingSteers,
{
userId,
tenantId: req.user?.tenantId,
},
jobCreatedAt,
);
}
} catch (err) {
logger.warn(`[ResumableAgentController] Failed to drain leftover steers`, err);
@ -908,8 +959,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
conversationId: conversation?.conversationId,
});
await GenerationJobManager.emitDone(streamId, finalEvent);
GenerationJobManager.completeJob(streamId);
await GenerationJobManager.emitDone(streamId, finalEvent, jobCreatedAt);
startupTelemetry?.end('completed_without_delta');
void GenerationJobManager.completeJob(streamId, undefined, jobCreatedAt).catch((err) => {
logger.warn('[ResumableAgentController] Failed to finalize completed job', err);
});
await finishResumableRequest(req, userId);
} else {
const finalEvent = {
@ -929,8 +983,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
conversationId: conversation?.conversationId,
});
await GenerationJobManager.emitDone(streamId, finalEvent);
GenerationJobManager.completeJob(streamId, 'Request aborted');
await GenerationJobManager.emitDone(streamId, finalEvent, jobCreatedAt);
startupTelemetry?.end('aborted');
void GenerationJobManager.completeJob(streamId, 'Request aborted', jobCreatedAt).catch(
(err) => {
logger.warn('[ResumableAgentController] Failed to finalize aborted job', err);
},
);
await finishResumableRequest(req, userId);
}
@ -982,6 +1041,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
if (wasAborted) {
logger.debug(`[ResumableAgentController] Generation aborted for ${streamId}`);
startupTelemetry?.end('aborted');
// abortJob already handled emitDone and completeJob
} else {
logger.error(`[ResumableAgentController] Generation error for ${streamId}:`, error);
@ -1002,6 +1062,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
streamId,
erroredLeftovers.map(toPendingSteer),
{ userId, tenantId: req.user?.tenantId },
jobCreatedAt,
);
}
} catch (drainErr) {
@ -1010,21 +1071,34 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
drainErr,
);
}
await GenerationJobManager.emitError(streamId, error.message || 'Generation failed');
GenerationJobManager.completeJob(streamId, error.message);
try {
await GenerationJobManager.emitError(
streamId,
error.message || 'Generation failed',
jobCreatedAt,
);
} catch (notificationError) {
logger.warn(
'[ResumableAgentController] Failed to notify client of generation error',
notificationError,
);
} finally {
startupTelemetry?.end('error', error);
}
await GenerationJobManager.completeJob(streamId, error.message, jobCreatedAt).catch(
(completeErr) => {
logger.warn(
'[ResumableAgentController] completeJob failed during generation-error cleanup',
completeErr,
);
},
);
}
await finishResumableRequest(req, userId);
// Defer disposal until any immediate title settles (it holds the run/req).
if (immediateTitlePromise) {
immediateTitlePromise.finally(() => {
if (client) {
disposeClient(client);
}
});
} else if (client) {
disposeClient(client);
try {
await finishResumableRequest(req, userId);
} finally {
disposeBackgroundClient();
}
// Don't continue to title generation after error/abort
@ -1037,30 +1111,59 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
logger.error(
`[ResumableAgentController] Unhandled error in background generation: ${err.message}`,
);
GenerationJobManager.completeJob(streamId, err.message);
await finishResumableRequest(req, userId);
startupTelemetry?.end('error', err);
await GenerationJobManager.completeJob(streamId, err.message, jobCreatedAt).catch(
(completeErr) => {
logger.warn(
'[ResumableAgentController] completeJob failed during background-error cleanup',
completeErr,
);
},
);
try {
await finishResumableRequest(req, userId);
} finally {
disposeBackgroundClient();
}
});
} catch (error) {
logger.error('[ResumableAgentController] Initialization error:', error);
if (!res.headersSent) {
res.status(500).json({ error: error.message || 'Failed to start generation' });
} else {
// JSON already sent, emit error to stream so client can receive it
await GenerationJobManager.emitError(streamId, error.message || 'Failed to start generation');
try {
if (!res.headersSent) {
res.status(500).json({ error: error.message || 'Failed to start generation' });
} else if (jobCreatedAt != null) {
// JSON already sent, emit error to stream so client can receive it
await GenerationJobManager.emitError(
streamId,
error.message || 'Failed to start generation',
jobCreatedAt,
);
}
} catch (notificationError) {
logger.warn(
'[ResumableAgentController] Failed to notify client of initialization error',
notificationError,
);
} finally {
startupTelemetry?.end('error', error);
}
// Finalize THIS failed job before releasing the idempotency claim. Releasing first would
// let the client's retry win the same key and createJob() the same streamId while we are
// still here — and completeJob() is not guarded by the original createdAt, so it would
// abort/error that replacement. A completeJob() rejection (store hiccup) must NOT skip the
// still here. The generation guard is defense-in-depth around that ordering. A
// completeJob() rejection (store hiccup) must NOT skip the
// release + pending-request decrement below, or the retry stays wedged behind the claim
// and the concurrency slot leaks — so swallow its error. (A failed completeJob did not
// finalize anything, so releasing afterward can't let it abort a later replacement.)
await GenerationJobManager.completeJob(streamId, error.message).catch((completeErr) => {
logger.warn(
'[ResumableAgentController] completeJob failed during init-error cleanup',
completeErr,
if (jobCreatedAt != null) {
await GenerationJobManager.completeJob(streamId, error.message, jobCreatedAt).catch(
(completeErr) => {
logger.warn(
'[ResumableAgentController] completeJob failed during init-error cleanup',
completeErr,
);
},
);
});
}
if (ownsIdempotencyClaim) {
await GenerationJobManager.releaseGeneration(userId, clientRequestId).catch(() => {});
}
@ -1107,6 +1210,7 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
let userMessageId;
let responseMessageId;
let client = null;
let jobCreatedAt;
let cleanupHandlers = [];
// Match the same logic used for conversationId generation above
@ -1135,9 +1239,9 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
responseMessageId = data[key];
} else if (key === 'promptTokens') {
// Update job metadata with prompt tokens for abort handling
GenerationJobManager.updateMetadata(streamId, { promptTokens: data[key] });
GenerationJobManager.updateMetadata(streamId, { promptTokens: data[key] }, jobCreatedAt);
} else if (key === 'sender') {
GenerationJobManager.updateMetadata(streamId, { sender: data[key] });
GenerationJobManager.updateMetadata(streamId, { sender: data[key] }, jobCreatedAt);
}
// conversationId is pre-generated, no need to update from callback
}
@ -1159,9 +1263,9 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
}
// Complete the job in GenerationJobManager
if (streamId) {
if (jobCreatedAt != null) {
logger.debug('[AgentController] Completing job in GenerationJobManager');
await GenerationJobManager.completeJob(streamId);
await GenerationJobManager.completeJob(streamId, undefined, jobCreatedAt);
}
// Dispose client properly
@ -1225,18 +1329,24 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
// Create job in GenerationJobManager for abort handling
// streamId === conversationId (pre-generated above)
const job = await GenerationJobManager.createJob(streamId, userId, conversationId);
jobCreatedAt = job.createdAt;
client.jobCreatedAt = jobCreatedAt;
// Store endpoint metadata for abort handling
GenerationJobManager.updateMetadata(streamId, {
endpoint: endpointOption.endpoint,
iconURL: getEndpointIconURL(req, endpointOption),
model: getAgentResponseModel(req, endpointOption),
sender: client?.sender,
});
GenerationJobManager.updateMetadata(
streamId,
{
endpoint: endpointOption.endpoint,
iconURL: getEndpointIconURL(req, endpointOption),
model: getAgentResponseModel(req, endpointOption),
sender: client?.sender,
},
jobCreatedAt,
);
// Store content parts reference for abort
if (client?.contentParts) {
GenerationJobManager.setContentParts(streamId, client.contentParts);
GenerationJobManager.setContentParts(streamId, client.contentParts, jobCreatedAt);
}
const closeHandler = createCloseHandler(job.abortController);
@ -1259,16 +1369,20 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle
responseMessageId = respMsgId;
// Store metadata for abort handling (conversationId is pre-generated)
GenerationJobManager.updateMetadata(streamId, {
responseMessageId: respMsgId,
userMessage: {
messageId: userMsg.messageId,
parentMessageId: userMsg.parentMessageId,
conversationId,
text: userMsg.text,
quotes: userMsg.quotes,
GenerationJobManager.updateMetadata(
streamId,
{
responseMessageId: respMsgId,
userMessage: {
messageId: userMsg.messageId,
parentMessageId: userMsg.parentMessageId,
conversationId,
text: userMsg.text,
quotes: userMsg.quotes,
},
},
});
jobCreatedAt,
);
};
const messageOptions = {

View file

@ -10,6 +10,7 @@ const {
findDisallowedDecisions,
findIncompleteDecisions,
computeAgentRequestFingerprint,
captureAgentCheckpointGeneration,
deleteAgentCheckpoint,
buildAbortedResponseMetadata,
sanitizeMessageForTransmit,
@ -194,7 +195,15 @@ function resolveResumeValue(pendingAction, body) {
* job, and prune the checkpoint. Mirrors the abort route's save shape but for a
* successful finish. Best-effort title generation for a first-turn pause.
*/
async function finalizeResumedTurn({ req, client, job, streamId, conversationId, addTitle }) {
async function finalizeResumedTurn({
req,
client,
job,
streamId,
conversationId,
addTitle,
checkpointGeneration,
}) {
const userId = req.user.id;
const checkpointerCfg = req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
const meta = job.metadata ?? {};
@ -361,10 +370,15 @@ async function finalizeResumedTurn({ req, client, job, streamId, conversationId,
// via /chat/status within the recovery TTL). NOTE: `job` is the manager
// facade — owner fields live under `metadata` (a bare `job.userId` is
// undefined and would make the parked payload unclaimable).
await GenerationJobManager.steering.park(streamId, pendingSteers, {
userId: job.metadata?.userId,
tenantId: job.metadata?.tenantId,
});
await GenerationJobManager.steering.park(
streamId,
pendingSteers,
{
userId: job.metadata?.userId,
tenantId: job.metadata?.tenantId,
},
job.createdAt,
);
}
} catch (drainErr) {
logger.warn('[ResumeAgentController] Failed to drain leftover steers', drainErr);
@ -391,15 +405,15 @@ async function finalizeResumedTurn({ req, client, job, streamId, conversationId,
...(pendingSteers && { pendingSteers }),
};
await GenerationJobManager.emitDone(streamId, finalEvent);
await GenerationJobManager.emitDone(streamId, finalEvent, job.createdAt);
// Awaited (not fire-and-forget) so the job's terminal write lands before the
// checkpoint prune, and so a failure here doesn't race the controller's error path.
try {
await GenerationJobManager.completeJob(streamId);
await GenerationJobManager.completeJob(streamId, undefined, job.createdAt);
} catch (completeErr) {
logger.error('[ResumeAgentController] Failed to complete resumed turn', completeErr);
}
await deleteAgentCheckpoint(conversationId, checkpointerCfg);
await deleteAgentCheckpoint(conversationId, checkpointerCfg, checkpointGeneration);
}
/**
@ -510,6 +524,23 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
});
}
// Snapshot the exact durable checkpoint ids before the atomic resume claim. The
// claim is the linearization point: a replacement that already owns this stream
// makes it fail, while one that starts afterward writes fresh ids outside the
// snapshot. Terminal cleanup can therefore delete this generation without a
// check-then-delete race against a later pause on the same conversation.
//
// Start the indexed read alongside the independent concurrency check so the
// generation guard adds minimal time to the resume ACK path.
const checkpointerCfg = req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
const checkpointGenerationPromise = captureAgentCheckpointGeneration(
conversationId,
checkpointerCfg,
).catch((err) => {
logger.warn('[ResumeAgentController] Failed to capture checkpoint generation', err);
return { threadId: conversationId, checkpointIds: [] };
});
// Count the resume against the concurrency limit. The original turn released its slot
// when it paused, so resuming must re-acquire one — otherwise pausing several turns
// and resuming them at once would bypass LIMIT_CONCURRENT_MESSAGES.
@ -527,7 +558,9 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
// would leak the concurrency slot until the counter TTL expires — spuriously 429'ing
// the user when they retry the still-paused approval. Release the slot on that path too.
let claimed;
let checkpointGeneration;
try {
checkpointGeneration = await checkpointGenerationPromise;
claimed = await GenerationJobManager.approvals.resolve(streamId, pendingAction.actionId);
} catch (err) {
await decrementPendingRequest(userId);
@ -626,6 +659,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
res,
endpointOption: req.body.endpointOption,
signal: job.abortController.signal,
jobCreatedAt: job.createdAt,
});
client = result.client;
@ -655,7 +689,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
);
}
if (client.contentParts) {
GenerationJobManager.setContentParts(streamId, client.contentParts);
GenerationJobManager.setContentParts(streamId, client.contentParts, job.createdAt);
}
await client.resumeCompletion({
@ -690,7 +724,15 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
return;
}
await finalizeResumedTurn({ req, client, job, streamId, conversationId, addTitle });
await finalizeResumedTurn({
req,
client,
job,
streamId,
conversationId,
addTitle,
checkpointGeneration,
});
} catch (err) {
logger.error('[ResumeAgentController] Resume failed', err);
// Job-replacement guard (mirrors finalizeResumedTurn's success-path guard): if a
@ -720,30 +762,47 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
);
if (leftoverSteers.length > 0) {
// Facade shape: owner fields are under `metadata` (see finalize).
await GenerationJobManager.steering.park(streamId, leftoverSteers.map(toPendingSteer), {
userId: job.metadata?.userId,
tenantId: job.metadata?.tenantId,
});
await GenerationJobManager.steering.park(
streamId,
leftoverSteers.map(toPendingSteer),
{
userId: job.metadata?.userId,
tenantId: job.metadata?.tenantId,
},
job.createdAt,
);
}
} catch (drainErr) {
logger.warn('[ResumeAgentController] Failed to drain steers on resume failure', drainErr);
}
try {
await GenerationJobManager.emitError(streamId, err?.message ?? 'Resume failed');
await GenerationJobManager.emitError(
streamId,
err?.message ?? 'Resume failed',
job.createdAt,
);
} catch (emitErr) {
logger.error('[ResumeAgentController] Failed to emit resume error', emitErr);
}
try {
await GenerationJobManager.completeJob(streamId, err?.message ?? 'Resume failed');
await GenerationJobManager.completeJob(
streamId,
err?.message ?? 'Resume failed',
job.createdAt,
);
} catch (completeErr) {
logger.error('[ResumeAgentController] Failed to finalize failed resume', completeErr);
// Last resort: force a terminal state so the job isn't orphaned in `running`.
await GenerationJobManager.getJobStore()
.updateJob(streamId, {
status: 'error',
completedAt: Date.now(),
error: 'Resume failed',
})
.updateJob(
streamId,
{
status: 'error',
completedAt: Date.now(),
error: 'Resume failed',
},
job.createdAt,
)
.catch((updErr) =>
logger.error('[ResumeAgentController] Fallback job finalize failed', updErr),
);
@ -751,6 +810,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
await deleteAgentCheckpoint(
conversationId,
req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer,
checkpointGeneration,
);
}
} finally {