mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
📡 feat: Stream Detached Subagent Activity (#15111)
* feat: stream detached subagent activity * fix: annotate activity stream limits * fix: isolate subagent activity imports * fix: harden detached subagent activity lifecycle * test: cover synchronous activity transport failure * test: include required subagent activity identity * fix: identify and reconnect subagent activity events * fix: bound subagent activity lifecycles * fix: close subagent activity handoff races * fix: bind and synchronize activity subscriptions * fix: detect fresh activity attachment * fix: complete activity synchronization handoff * fix: bind activity sync and failure circuits * fix: expose subscription-bound synchronization * fix: fence activity reconnect publications * test: make detached timeout settlement deterministic * fix: fence Redis activity attachments * fix: close failed activity streams * perf: reuse fenced activity frontier * style: sort subagent thread imports * fix: preserve queued subagent activity * test: type activity publication counter * fix: disconnect subagent activity subscriber * fix: close background activity lifecycle gaps * fix: preserve streamed activity spacing * fix: preserve bounded live subagent activity * fix: merge durable subagent activity safely * fix: model detached activity coverage * fix: type detached activity inputs * fix: order overlapping subagent activity * chore: sort activity test imports * fix: buffer subagent activity handoff gaps * fix: flush activity after parent close * fix: advance closed activity suffixes * fix: preserve detached activity ordering * fix: close detached activity delivery races * fix: bound shared Redis subscriber readiness * fix: expire shared Redis subscription readiness * fix: clean up late Redis subscriptions * fix: preserve late Redis subscription fallback
This commit is contained in:
parent
3ebef4c84e
commit
d3e70159ca
33 changed files with 4125 additions and 110 deletions
|
|
@ -7,6 +7,8 @@ const {
|
|||
createSubagentThreadTaskStore,
|
||||
createSubagentCompletionWakeupHandler,
|
||||
RedisSubagentTaskControlTransport,
|
||||
RedisEventTransport,
|
||||
SubagentActivityStream,
|
||||
} = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
const { enqueueAgentTrigger } = require('../../Agents/triggers');
|
||||
|
|
@ -46,6 +48,12 @@ const subagentThreadTaskStore = createSubagentThreadTaskStore(
|
|||
},
|
||||
);
|
||||
|
||||
registerShutdownTask(
|
||||
'subagent activity streams prepare',
|
||||
() => subagentThreadTaskStore.prepareActivityForShutdown(),
|
||||
{ phase: 'pre-drain', priority: 100 },
|
||||
);
|
||||
|
||||
let taskRoutingConfigured = false;
|
||||
|
||||
/** Starts the optional Redis owner directory before HTTP admission opens. */
|
||||
|
|
@ -62,14 +70,21 @@ async function configureSubagentTaskRouting() {
|
|||
* steer the caller was told had failed could still reach the child. Failing fast
|
||||
* turns that into the honest `unavailable` the caller already handles. */
|
||||
const publisher = duplicateIoRedisClient(ioredisClient, { enableOfflineQueue: false });
|
||||
const activitySubscriber = ioredisClient.duplicate();
|
||||
const activityPublisher = duplicateIoRedisClient(ioredisClient, { enableOfflineQueue: false });
|
||||
const transport = new RedisSubagentTaskControlTransport(publisher, subscriber, {
|
||||
namespace: cacheConfig.REDIS_KEY_PREFIX,
|
||||
});
|
||||
try {
|
||||
await subagentThreadTaskStore.configureTaskControlTransport(transport);
|
||||
subagentThreadTaskStore.configureActivityStream(
|
||||
new SubagentActivityStream(new RedisEventTransport(activityPublisher, activitySubscriber)),
|
||||
);
|
||||
} catch (error) {
|
||||
subscriber.disconnect();
|
||||
publisher.disconnect();
|
||||
activitySubscriber.disconnect();
|
||||
activityPublisher.disconnect();
|
||||
throw error;
|
||||
}
|
||||
taskRoutingConfigured = true;
|
||||
|
|
@ -77,7 +92,10 @@ async function configureSubagentTaskRouting() {
|
|||
'subagent task control transport',
|
||||
async () => {
|
||||
await subagentThreadTaskStore.destroyTaskControlTransport();
|
||||
subagentThreadTaskStore.destroyActivityStream();
|
||||
publisher.disconnect();
|
||||
activitySubscriber.disconnect();
|
||||
activityPublisher.disconnect();
|
||||
},
|
||||
{ priority: 90 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
const mockTaskStore = {
|
||||
configureTaskControlTransport: jest.fn().mockResolvedValue(undefined),
|
||||
configureActivityStream: jest.fn(),
|
||||
prepareActivityForShutdown: jest.fn(),
|
||||
destroyTaskControlTransport: jest.fn().mockResolvedValue(undefined),
|
||||
destroyActivityStream: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
cacheConfig: { USE_REDIS: true, REDIS_KEY_PREFIX: 'test:' },
|
||||
ioredisClient: { duplicate: jest.fn() },
|
||||
isEnabled: jest.fn(() => false),
|
||||
registerShutdownTask: jest.fn(),
|
||||
duplicateIoRedisClient: jest.fn(),
|
||||
createSubagentThreadTaskStore: jest.fn(() => mockTaskStore),
|
||||
createSubagentCompletionWakeupHandler: jest.fn(),
|
||||
RedisSubagentTaskControlTransport: jest.fn(),
|
||||
RedisEventTransport: jest.fn(),
|
||||
SubagentActivityStream: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
acquireSubagentThreadLease: jest.fn(),
|
||||
claimSubagentTaskResult: jest.fn(),
|
||||
releaseSubagentTaskResultClaim: jest.fn(),
|
||||
countActiveSubagentThreadLeases: jest.fn(),
|
||||
deleteConvos: jest.fn(),
|
||||
deleteMessages: jest.fn(),
|
||||
getConvo: jest.fn(),
|
||||
getMessages: jest.fn(),
|
||||
listActiveSubagentThreadLeases: jest.fn(),
|
||||
releaseSubagentThreadLease: jest.fn(),
|
||||
reserveSubagentThread: jest.fn(),
|
||||
renewSubagentThreadLease: jest.fn(),
|
||||
saveConvo: jest.fn(),
|
||||
saveMessage: jest.fn(),
|
||||
isSubagentOwnerAdmissible: jest.fn(),
|
||||
fenceSubagentAdmission: jest.fn(),
|
||||
renewSubagentAdmission: jest.fn(),
|
||||
releaseSubagentAdmission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../Agents/triggers', () => ({
|
||||
enqueueAgentTrigger: jest.fn(),
|
||||
}));
|
||||
|
||||
const { ioredisClient, registerShutdownTask, duplicateIoRedisClient } = require('@librechat/api');
|
||||
const { configureSubagentTaskRouting } = require('./subagentThreadStore');
|
||||
const activityPrepareRegistration = registerShutdownTask.mock.calls.find(
|
||||
([name]) => name === 'subagent activity streams prepare',
|
||||
);
|
||||
|
||||
describe('subagent thread Redis lifecycle', () => {
|
||||
it('closes activity SSE before drain and disconnects its subscriber after drain', async () => {
|
||||
const taskSubscriber = { disconnect: jest.fn() };
|
||||
const activitySubscriber = { disconnect: jest.fn() };
|
||||
const taskPublisher = { disconnect: jest.fn() };
|
||||
const activityPublisher = { disconnect: jest.fn() };
|
||||
ioredisClient.duplicate
|
||||
.mockReturnValueOnce(taskSubscriber)
|
||||
.mockReturnValueOnce(activitySubscriber);
|
||||
duplicateIoRedisClient
|
||||
.mockReturnValueOnce(taskPublisher)
|
||||
.mockReturnValueOnce(activityPublisher);
|
||||
|
||||
await configureSubagentTaskRouting();
|
||||
|
||||
expect(activityPrepareRegistration).toEqual([
|
||||
'subagent activity streams prepare',
|
||||
expect.any(Function),
|
||||
{ phase: 'pre-drain', priority: 100 },
|
||||
]);
|
||||
expect(registerShutdownTask).toHaveBeenCalledWith(
|
||||
'subagent task control transport',
|
||||
expect.any(Function),
|
||||
{ priority: 90 },
|
||||
);
|
||||
const prepare = activityPrepareRegistration[1];
|
||||
prepare();
|
||||
expect(mockTaskStore.prepareActivityForShutdown).toHaveBeenCalledTimes(1);
|
||||
|
||||
const shutdown = registerShutdownTask.mock.calls.find(
|
||||
([name]) => name === 'subagent task control transport',
|
||||
)[1];
|
||||
await shutdown();
|
||||
|
||||
expect(mockTaskStore.destroyTaskControlTransport).toHaveBeenCalledTimes(1);
|
||||
expect(mockTaskStore.destroyActivityStream).toHaveBeenCalledTimes(1);
|
||||
expect(taskPublisher.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(activitySubscriber.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(activityPublisher.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue