feat: add agent API and operational analytics
This commit is contained in:
1 parent
720ea0652a
commit
e8b730b592
65 files changed
+2650
-1214
No files matched your search
+73
-2
@@ -291,6 +291,14 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
|
||||
await rateLimiter.throttle(
|
||||
async () => {
|
||||
if (config.browser.dryRun) {
|
||||
const reason = 'Dry run: message prepared but not sent';
|
||||
logger.info(`${reason} for u/${post.author}`);
|
||||
postsRepo.setError(post.id, reason);
|
||||
webServer?.logActivity('skipped', `${reason} to u/${post.author}`, post.author);
|
||||
webServer?.pushLog('info', `🧪 ${reason} to u/${post.author}`);
|
||||
return false;
|
||||
}
|
||||
if (!recipientDeduplicator.tryReserve(post.author)) {
|
||||
const reason = 'User already contacted across accounts';
|
||||
logger.info(`Skipping u/${post.author} - ${reason.toLowerCase()}`);
|
||||
@@ -335,6 +343,11 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
sentAt: Date.now(),
|
||||
strategyId: processed.strategyId,
|
||||
chatRoomId: chat.roomId,
|
||||
experimentId: processed.experimentId,
|
||||
promptVersion: processed.promptVersion,
|
||||
modelId: processed.modelId,
|
||||
selectionReason: processed.selectionReason,
|
||||
sourceType: processed.sourceType,
|
||||
});
|
||||
|
||||
const currentCount = currentHourlySendCount(rateLimiter);
|
||||
@@ -410,14 +423,72 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
||||
}
|
||||
|
||||
async function runConcurrentAccountRuntimes(app: AppContainer): Promise<void> {
|
||||
const { logger, accountRuntimeManager, browserClient, db, webServer } = app;
|
||||
logger.info(`Running ${accountRuntimeManager.list().length} independent Reddit account runtime(s)`);
|
||||
const { config, logger, accountRuntimeManager, browserClient, db, webServer } = app;
|
||||
logger.info(`Running ${accountRuntimeManager.list().length} account runtime(s) with shared post discovery`);
|
||||
await new Promise<void>((resolve) => {
|
||||
let shuttingDown = false;
|
||||
const controller = new AbortController();
|
||||
let nextScannerIndex = 0;
|
||||
|
||||
const discoverAndDispatch = async (): Promise<void> => {
|
||||
const runtimes = accountRuntimeManager.list()
|
||||
.filter((snapshot) => snapshot.state === 'running')
|
||||
.map((snapshot) => accountRuntimeManager.get(snapshot.accountId))
|
||||
.filter((runtime): runtime is NonNullable<typeof runtime> => runtime !== null);
|
||||
if (runtimes.length === 0) return;
|
||||
|
||||
// Rotate the authenticated browser used to read Reddit, while keeping
|
||||
// selection and delivery centralised. A subreddit is therefore fetched
|
||||
// once per cycle, never once per account.
|
||||
const scanner = runtimes[nextScannerIndex % runtimes.length];
|
||||
nextScannerIndex += 1;
|
||||
if (!(await scanner.canAcceptPost())) return;
|
||||
|
||||
const posts = await scanner.resources.monitorService.getUnprocessedPosts();
|
||||
const post = posts[0];
|
||||
if (!post) return;
|
||||
|
||||
const recipients = (await Promise.all(runtimes.map(async (runtime) => ({
|
||||
runtime,
|
||||
available: await runtime.canAcceptPost(),
|
||||
})))).filter((candidate) => candidate.available)
|
||||
.map((candidate) => candidate.runtime)
|
||||
.sort((left, right) => (
|
||||
left.resources.rateLimiter.getStats().messagesThisHour
|
||||
- right.resources.rateLimiter.getStats().messagesThisHour
|
||||
));
|
||||
const recipient = recipients[0];
|
||||
if (!recipient) {
|
||||
logger.debug(`Eligible post ${post.id} found, but no account is currently available`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Assigning eligible post ${post.id} (r/${post.subreddit}) to u/${recipient.session.redditUsername}`);
|
||||
await recipient.dispatchPost(post, controller.signal);
|
||||
};
|
||||
|
||||
const discoveryLoop = (async (): Promise<void> => {
|
||||
while (!controller.signal.aborted) {
|
||||
try {
|
||||
if (webServer?.isRunning() ?? true) await discoverAndDispatch();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (error instanceof RedditRateLimitError) {
|
||||
await sleep(error.retryAfterMs + 1_000, controller.signal).catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
logger.error('Shared post discovery failed:', error);
|
||||
}
|
||||
await sleep(config.reddit.pollIntervalMs, controller.signal).catch(() => undefined);
|
||||
}
|
||||
})();
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
controller.abort();
|
||||
logger.info('Stopping all Reddit account runtimes...');
|
||||
await discoveryLoop;
|
||||
await accountRuntimeManager.stopAll();
|
||||
webServer?.stop();
|
||||
await browserClient.close();
|
||||
|
||||
Reference in new issue
Block a user