📡 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:
Danny Avila 2026-08-22 09:45:27 -04:00 committed by GitHub
parent 3ebef4c84e
commit d3e70159ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 4125 additions and 110 deletions

View file

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

View file

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