refactor(cron): streamline origin handling in system events and heartbeat delivery (#7)

- Removed redundant session delivery context updates in `buildGatewayCronService`.
- Enhanced `enqueueSystemEvent` to include optional origin context for routing replies from cron jobs.
- Updated `runHeartbeatOnce` to apply origin context from pending system events, ensuring replies are directed back to the correct source.
- Introduced `peekSystemEventEntries` for accessing system event entries with origin context.
This commit is contained in:
Chris Liu 2026-01-28 21:54:30 +08:00 committed by GitHub
parent 174fa8c9cb
commit fccca9915b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 67 additions and 52 deletions

View File

@ -1,24 +1,17 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { CliDeps } from "../cli/deps.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveAgentMainSessionKey,
resolveStorePath,
updateSessionStore,
} from "../config/sessions.js";
import { resolveAgentMainSessionKey } from "../config/sessions.js";
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js";
import { CronService } from "../cron/service.js";
import { resolveCronStorePath } from "../cron/store.js";
import type { CronDeliveryMode, CronOrigin } from "../cron/types.js";
import { runHeartbeatOnce } from "../infra/heartbeat-runner.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
export type GatewayCronState = {
cron: CronService;
@ -59,47 +52,10 @@ export function buildGatewayCronService(params: {
cfg: runtimeConfig,
agentId,
});
// If origin is provided and deliveryMode is "origin" (default), update the
// session's delivery context so the heartbeat routes replies to the origin.
const origin = opts?.origin;
// Pass origin through to the system event - heartbeat runner will use it for routing
const deliveryMode = opts?.deliveryMode ?? "origin";
if (origin && deliveryMode === "origin" && (origin.channel || origin.to)) {
const storePath = resolveStorePath(runtimeConfig.session?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
if (entry) {
const deliveryFields = normalizeSessionDeliveryFields({
deliveryContext: {
channel: origin.channel,
to: origin.to,
accountId: origin.accountId,
threadId: origin.threadId,
},
});
// Update session entry with origin delivery context
const updatedEntry = {
...entry,
updatedAt: Date.now(),
deliveryContext: deliveryFields.deliveryContext,
lastChannel: deliveryFields.lastChannel ?? entry.lastChannel,
lastTo: deliveryFields.lastTo ?? entry.lastTo,
lastAccountId: deliveryFields.lastAccountId ?? entry.lastAccountId,
lastThreadId: deliveryFields.lastThreadId ?? entry.lastThreadId,
};
store[sessionKey] = updatedEntry;
void updateSessionStore(storePath, (s) => {
s[sessionKey] = updatedEntry;
}).catch((err) => {
cronLogger.warn(
{ err: String(err), sessionKey },
"cron: failed to update session delivery context for origin routing",
);
});
}
}
enqueueSystemEvent(text, { sessionKey });
const origin = deliveryMode === "origin" ? opts?.origin : undefined;
enqueueSystemEvent(text, { sessionKey, origin });
},
requestHeartbeatNow,
runHeartbeatOnce: async (opts) => {

View File

@ -34,8 +34,9 @@ import {
updateSessionStore,
} from "../config/sessions.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import type { CronOrigin } from "../cron/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { peekSystemEventEntries, peekSystemEvents } from "../infra/system-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
@ -51,6 +52,7 @@ import {
} from "./heartbeat-wake.js";
import type { OutboundSendDeps } from "./outbound/deliver.js";
import { deliverOutboundPayloads } from "./outbound/deliver.js";
import type { OutboundTarget } from "./outbound/targets.js";
import {
resolveHeartbeatDeliveryTarget,
resolveHeartbeatSenderContext,
@ -97,6 +99,36 @@ const EXEC_EVENT_PROMPT =
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
"If it failed, explain what went wrong.";
/**
* Apply cron job origin context to override heartbeat delivery target.
* This routes replies back to where the cron job was created.
*/
function applyOriginToDelivery(
delivery: OutboundTarget,
origin: CronOrigin,
_cfg: MoltbotConfig,
): OutboundTarget {
// Only override if origin has usable channel/to
if (!origin.channel && !origin.to) {
return delivery;
}
const channel = origin.channel ?? delivery.channel;
const to = origin.to ?? delivery.to;
// If we still don't have a valid channel, keep the original delivery
if (channel === "none" || !channel) {
return delivery;
}
return {
...delivery,
channel,
to,
accountId: origin.accountId ?? delivery.accountId,
};
}
function resolveActiveHoursTimezone(cfg: MoltbotConfig, raw?: string): string {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "user") {
@ -480,7 +512,16 @@ export async function runHeartbeatOnce(opts: {
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
const previousUpdatedAt = entry?.updatedAt;
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
let delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
// Check if any pending system events have origin context (from cron jobs).
// If so, override delivery target to route replies back to the origin.
const pendingEventEntries = peekSystemEventEntries(sessionKey);
const originFromEvent = pendingEventEntries.find((e) => e.origin)?.origin;
if (originFromEvent && (originFromEvent.channel || originFromEvent.to)) {
delivery = applyOriginToDelivery(delivery, originFromEvent, cfg);
}
const visibility =
delivery.channel !== "none"
? resolveHeartbeatVisibility({

View File

@ -2,7 +2,14 @@
// prefixed to the next prompt. We intentionally avoid persistence to keep
// events ephemeral. Events are session-scoped and require an explicit key.
export type SystemEvent = { text: string; ts: number };
import type { CronOrigin } from "../cron/types.js";
export type SystemEvent = {
text: string;
ts: number;
/** Origin context for routing replies (from cron jobs) */
origin?: CronOrigin;
};
const MAX_EVENTS = 20;
@ -17,6 +24,8 @@ const queues = new Map<string, SessionQueue>();
type SystemEventOptions = {
sessionKey: string;
contextKey?: string | null;
/** Origin context for routing replies (from cron jobs) */
origin?: CronOrigin;
};
function requireSessionKey(key?: string | null): string {
@ -62,7 +71,11 @@ export function enqueueSystemEvent(text: string, options: SystemEventOptions) {
entry.lastContextKey = normalizeContextKey(options?.contextKey);
if (entry.lastText === cleaned) return; // skip consecutive duplicates
entry.lastText = cleaned;
entry.queue.push({ text: cleaned, ts: Date.now() });
const event: SystemEvent = { text: cleaned, ts: Date.now() };
if (options?.origin) {
event.origin = options.origin;
}
entry.queue.push(event);
if (entry.queue.length > MAX_EVENTS) entry.queue.shift();
}
@ -87,6 +100,11 @@ export function peekSystemEvents(sessionKey: string): string[] {
return queues.get(key)?.queue.map((e) => e.text) ?? [];
}
export function peekSystemEventEntries(sessionKey: string): SystemEvent[] {
const key = requireSessionKey(sessionKey);
return queues.get(key)?.queue.slice() ?? [];
}
export function hasSystemEvents(sessionKey: string) {
const key = requireSessionKey(sessionKey);
return (queues.get(key)?.queue.length ?? 0) > 0;