How DeskcommCRM Schedules, Retries, and Observes Follow-ups Using Workers and Event-Log Consumers
DeskcommCRM orchestrates follow-up delivery through a dedicated followup_turn job type that moves from the cron_jobs table through a lightweight cron worker into the agent worker queue, with deterministic retry logic handled by the anti-ban guard's rescheduleReentry mechanism.
The follow-up system in DeskcommCRM relies on a decoupled pipeline that separates time-based scheduling from heavy agent execution. By leveraging the cron_jobs table and specialized workers, the platform ensures reliable delivery of promised responses while handling temporary blocks through idempotent retry mechanisms.
Scheduling Follow-ups via the Cron Jobs Table
When a tool such as crm_schedule_followup creates a follow-up promise, the engine writes a row into the cron_jobs table with kind = 'at' and job_kind = 'followup_turn'. This row stores the promise data—including reason, promise, promised_at, and a service_boundary pointing to the original conversation—effectively creating a future-dated trigger for the follow-up logic.
The scheduleCronJob Helper
The scheduleCronJob helper abstracts the database insert and guarantees idempotency when creating these entries. This utility is called directly from the follow-up engine and manages the payload construction that will later be consumed by the workers.
await scheduleCronJob(pool, tenantId, {
leadId,
spec: { kind: 'at', at: promisedAt },
jobKind: 'followup_turn',
payload: {
reason,
promise,
promised_at: promisedAt.toISOString(),
followup_enrollment_id: enrollmentId,
// …other fields
},
});
The Cron Worker Bridge
A lightweight HTTP handler at app/api/v1/cron/followup-flow-worker/route.ts serves as the bridge between the scheduling layer and the execution queue. When the scheduled at time arrives, this cron worker enqueues the followup_turn job into the main job_queue, decoupling the time-based trigger from the heavy agent runtime.
This design prevents the cron system from blocking on long-running agent operations. The cron worker performs only the enqueue operation, ensuring that the actual follow-up logic executes within the robust agent-worker environment.
Event-Log Consumer and Agent Worker Processing
The agent worker daemon registers a specific handler for followup_turn jobs in workers/agent-worker/main.ts using handlers.set("followup_turn", …). This registration enables the event-log consumer to route follow-up jobs to the appropriate processing logic implemented in lib/agent-engine/agent/followup-turn.ts.
Handler Registration in the Agent Worker
The handler registration occurs during worker initialization, binding the createFollowupTurnHandler function to the followup_turn job type:
handlers.set('followup_turn', createFollowupTurnHandler(turnDeps));
The createFollowupTurnHandler function fetches the target conversation, validates that the channel is not archived, and delegates execution to the shared runAgentTurn logic.
Flow-Driven vs Classic Turn Execution
Within the handler, the system checks for the presence of followup_enrollment_id in the payload. When this identifier exists, the turn executes via runFlowDrivenTurn, enabling structured conversation flows. Otherwise, the system follows the classic "template or agent" execution path, allowing for dynamic response generation based on the original conversation context.
Retry Logic and Anti-Ban Protection
During the outbound send phase within sendFixedOutbound, the anti-ban guard monitors the message dispatch. If the guard vetoes the message with chain.status === 'vetoed', specific retry logic activates based on the veto reason code.
The Veto Mechanism in sendFixedOutbound
When the veto reason is outside_window, the system determines that the current time falls outside acceptable messaging windows (such as compliance hours or rate limits). Rather than failing the job permanently, the system captures the veto and prepares for deferred execution.
Idempotent Rescheduling with rescheduleReentry
The rescheduleReentry function (implemented around lines 1080-1086 in the source) creates a one-shot cron_jobs entry of kind at with the same payload plus a reschedule_of marker. This ensures idempotent retry even if the worker crashes mid-operation:
if (chain.status === 'vetoed' && chain.code === 'outside_window') {
await rescheduleReentry(pool, {
tenantId,
leadId,
jobId: job.id,
at: chain.nextAllowedAt,
payload: job.payload,
});
}
The new cron_jobs row preserves the original payload while updating the execution time to chain.nextAllowedAt, allowing the cron worker to pick up the job again when appropriate.
Observation and Completion Tracking
After a successful send—or after classification and timing plan adjustments—the handler invokes the bridge callback completeFollowupTurn located in lib/followup/turn-bridge.ts. This callback records the final result in the followup_enrollments table and removes the job from the queue.
The event-log consumer (the same agent-worker process) can observe these state transitions through the database updates, enabling monitoring and debugging via queries to the cron_jobs table (facilitated by utilities in lib/followup/retorno-crm.ts). This creates a complete audit trail from initial scheduling through final delivery or failure.
Summary
- Scheduling: Follow-ups are inserted into
cron_jobsasfollowup_turnentries withkind = 'at'using thescheduleCronJobhelper, ensuring idempotent creation. - Cron Worker: The
app/api/v1/cron/followup-flow-worker/route.tsendpoint moves scheduled entries into the activejob_queuewithout blocking on execution. - Processing: The agent worker in
workers/agent-worker/main.tshandlesfollowup_turnjobs viacreateFollowupTurnHandler, supporting both flow-driven and classic execution modes. - Retry: Anti-ban vetoes trigger
rescheduleReentryto create newcron_jobsentries withreschedule_ofmarkers, enabling deterministic, crash-safe retries. - Observation: Completion states are recorded via
completeFollowupTurninlib/followup/turn-bridge.ts, with thefollowup_enrollmentstable serving as the source of truth for follow-up outcomes.
Frequently Asked Questions
What happens if the cron worker fails to enqueue a follow-up job?
The cron_jobs table retains the scheduled entry until successfully processed or manually cleared. Since the cron worker only moves jobs from cron_jobs to job_queue without deleting the original scheduling record until confirmation, a failed HTTP request or worker crash allows the next cron tick to retry the enqueue operation without data loss.
How does the system prevent duplicate follow-up sends?
Idempotency is enforced at multiple layers. The scheduleCronJob helper ensures duplicate scheduling calls do not create multiple cron_jobs entries. Additionally, the rescheduleReentry function includes a reschedule_of marker in the payload, allowing the system to track retry chains and prevent double-sending even if the anti-ban guard triggers multiple vetoes.
What is the difference between flow-driven and classic follow-up execution?
Flow-driven execution occurs when the payload contains a followup_enrollment_id, triggering the runFlowDrivenTurn logic that follows predefined conversation flows. Classic execution follows the standard "template or agent" path used for ad-hoc responses, providing flexibility when no structured enrollment exists.
Where can I query the current status of pending follow-ups?
The lib/followup/retorno-crm.ts module provides utilities to query the cron_jobs table specifically for follow-up entries. This enables monitoring scripts and debugging tools to observe pending followup_turn jobs, their scheduled times, and any reschedule_of markers indicating retry chains.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →