LibreChat/api/server/services/Endpoints/agents/subagentThreadStore.js
Danny Avila 5e3c680761
🪃 feat: Wake Parent Agents on Child Completion (#14975)
* feat: wake parent agents on child completion

* wip: harden child completion wakeup lifecycle

* fix: close the completion-wakeup static failures

Type the durable-claim store fixture, the continue-envelope test helper,
and the terminal message's task metadata so the wakeup suites compile
against the shapes they actually exercise. Replace `Array.prototype.at`,
which the package target library does not provide.

Capture the prepared child thread in a non-optional local before the
provider callback closes over it, and narrow the trigger envelope itself
on `mode === 'continue'` rather than a separately copied mode, so reading
the continue target is sound. Lift the parent-message fallback out of a
nested ternary into a named resolver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: cover the active-predecessor admission fence

The Redis job-creation call gained a thirteenth scalar argument, so the
spec helper reconstructed the HSET pairs one slot early and rebuilt an
invalid job hash; three creation tests failed on that alone.

Give the fence itself direct coverage in both store adapters, which it
had none of despite deciding whether an automatic continuation may
replace a live parent turn. Each proves a running and a requires_action
predecessor are refused with the state a controller needs for a finite
409, that an absent or settled predecessor is admitted, and that an
ordinary user turn without the policy still replaces its predecessor.
The Redis case also asserts a refused continuation leaves the parent's
durable job and chunks untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: harden completion wakeup rollout and claims

* fix: close completion wakeup race windows

* test: keep the child store fixture exact

* fix: close final subagent wakeup gaps

* fix: preserve ambiguous completion claims

* fix: release pre-admission wakeup claims

* fix: stabilize subagent completion recovery

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 12:18:31 -04:00

87 lines
3.4 KiB
JavaScript

const {
cacheConfig,
ioredisClient,
isEnabled,
registerShutdownTask,
duplicateIoRedisClient,
createSubagentThreadTaskStore,
createSubagentCompletionWakeupHandler,
RedisSubagentTaskControlTransport,
} = require('@librechat/api');
const db = require('~/models');
const { enqueueAgentTrigger } = require('../../Agents/triggers');
/** Keep producers off for the first rollout so older trigger workers cannot
* permanently reject the new `continue` envelope. Enable only after every API
* replica runs a release that understands completion wakeups. */
const completionWakeupsEnabled = isEnabled(process.env.ENABLE_SUBAGENT_COMPLETION_WAKEUPS);
/** Durable logical threads use normal LibreChat conversations/messages. Mongo
* fences continuation; optional Redis routing reaches the live owning process. */
const subagentThreadTaskStore = createSubagentThreadTaskStore(
{
acquireSubagentThreadLease: db.acquireSubagentThreadLease,
claimSubagentTaskResult: db.claimSubagentTaskResult,
releaseSubagentTaskResultClaim: db.releaseSubagentTaskResultClaim,
countActiveSubagentThreadLeases: db.countActiveSubagentThreadLeases,
deleteConvos: db.deleteConvos,
deleteMessages: db.deleteMessages,
getConvo: db.getConvo,
getMessages: db.getMessages,
listActiveSubagentThreadLeases: db.listActiveSubagentThreadLeases,
releaseSubagentThreadLease: db.releaseSubagentThreadLease,
reserveSubagentThread: db.reserveSubagentThread,
renewSubagentThreadLease: db.renewSubagentThreadLease,
saveConvo: db.saveConvo,
saveMessage: db.saveMessage,
},
{
isOwnerActive: db.isSubagentOwnerAdmissible,
fenceOwnerAdmission: db.fenceSubagentAdmission,
renewOwnerAdmission: db.renewSubagentAdmission,
releaseOwnerAdmission: db.releaseSubagentAdmission,
...(completionWakeupsEnabled && {
onTaskPrepared: createSubagentCompletionWakeupHandler(enqueueAgentTrigger),
}),
},
);
let taskRoutingConfigured = false;
/** Starts the optional Redis owner directory before HTTP admission opens. */
async function configureSubagentTaskRouting() {
if (taskRoutingConfigured || !cacheConfig.USE_REDIS) {
return;
}
if (ioredisClient == null || typeof ioredisClient.duplicate !== 'function') {
throw new Error('Redis subagent task routing requires a dedicated subscriber connection.');
}
const subscriber = ioredisClient.duplicate();
/** A dedicated publisher without the offline queue: the shared client would hold a
* command issued during a disconnect and deliver it after the caller gave up, so a
* 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 transport = new RedisSubagentTaskControlTransport(publisher, subscriber, {
namespace: cacheConfig.REDIS_KEY_PREFIX,
});
try {
await subagentThreadTaskStore.configureTaskControlTransport(transport);
} catch (error) {
subscriber.disconnect();
publisher.disconnect();
throw error;
}
taskRoutingConfigured = true;
registerShutdownTask(
'subagent task control transport',
async () => {
await subagentThreadTaskStore.destroyTaskControlTransport();
publisher.disconnect();
},
{ priority: 90 },
);
}
module.exports = subagentThreadTaskStore;
module.exports.configureSubagentTaskRouting = configureSubagentTaskRouting;