Skip to main content

Scheduled & background jobs

In one line: five cron jobs run with no human in the loop — chase stuck tickets (escalation), nudge vendors and tenants (completion-feedback), auto-cancel un-picked visit menus (slot-timeout), and email the daily and weekly briefs.

What it is

Five jobs are registered with the agent (src/index.ts:570). They loop over Business Central companies (or just the primary "Tribeca" company for the briefs) and read maintenance tickets:

  • Escalation check — every 4h. Finds stuck tickets (approval stalled, vendor silent, SLA breaches, stale, expired quotes, cost overruns), records a BC escalation, emails ops/admin, and pings affected tenants in-app. On a second vendor no-response it auto-unassigns the vendor and resets the ticket to "reported".
  • Completion-feedback — hourly. Three time-anchored emails: nudge the vendor at the visit time, chase the vendor for proof after the visit, and ask the tenant for feedback after completion.
  • Slot-pick-timeout — hourly at :30. Auto-cancels tickets where the tenant got a slot menu but never picked within the timeout (default 12h). No-ops if slot scheduling is off.
  • Daily workload report — 09:00 Europe/London. Emails an HTML daily brief to the admin(s).
  • Weekly workload report — Friday 17:00 Europe/London. Emails an HTML Mon–Fri brief.

Under the hood — config that drives these jobs lives in Configuration: APPROVER_EMAIL (recipients), *_DELAY_HOURS, SLOT_PICK_TIMEOUT_HOURS, the *_FORCE bypasses, and VISIT_TIMEZONE.

Reference — jobs

Job (name)ScheduleWhat it doesIdempotency guardEmails whomSource
escalation-check0 */4 * * * (every 4h)7 checks across all companies: approval pending >24h; vendor no-response >48h + auto-reassign on 2nd; emergency not in-progress >1h; high-urgency not contacted >4h; stale > staleTicketDays; expired quotes; cost overruns > costVarianceAlertPct. Writes BC escalation records + escalatedAt + audit.escalatedAt stamp (one-shot) + hasDuplicateEscalation() (skips same open type)admin/ops (getEscalationEmails); affected tenants in-appescalation.job.ts:33-36
completion-feedback0 * * * * (hourly)Per company: vendor "time to start" at quote.visitDateTimeIso; vendor proof-chase VENDOR_COMPLETION_REMINDER_DELAY_HOURS after visit while proof missing; tenant feedback TENANT_FEEDBACK_DELAY_HOURS after completedAt.startReminderSentAt/completionReminderSentAt/feedbackTenantSentAt (written only on success)vendor + tenant (in-thread)completion-feedback.job.ts:37-40
slot-pick-timeout30 * * * * (hourly at :30)When slot scheduling on, scans pending_tenant_confirmation+pending_approval; for a SENT menu with no selectedSlot past timeout, cancels via systemCancelTicket. Held/read-back menus never time out.slotMenuSentAt/selectedSlot check; runs off-cycle (:30) from feedback (:00)tenant (cancellation copy)slot-timeout.job.ts:22-25
daily-workload-report0 9 * * *, tz getAgentTimezone() (Europe/London)Builds daily stats for the primary company only (ADMIN_DEFAULT_COMPANY_ID/Tribeca — looping all companies avoided due to a global ticket-cache bug). Status/urgency breakdown, new/closed 24h, avg days-to-close, pending-approval €, stale POs, flagged vendors, escalations, photo-save-failures. HTML + plain fallback.Claim-at-start in Data daily_report_sent keyed on reportDayKey; fail-open; release-on-failure; DAILY_BRIEF_FORCE=1 bypassadmin(s) (getAdminEmails = APPROVER_EMAIL); optional in-app to MANAGER_USER_IDdaily-report.job.ts:30-37
weekly-workload-report0 17 * * 5, tz getAgentTimezone() (Fri 17:00)Same scoping. Mon–Fri wrap: opened/closed this week, open now, awaiting approval, approved €. HTML + plain fallback.Claim-at-start in Data weekly_report_sent keyed on reportWeekKey; release-on-failure; WEEKLY_BRIEF_FORCE=1 bypassadmin(s) (getAdminEmails)weekly-report.job.ts:23-27

Registration: imported src/index.ts:24-28, listed in jobs: [...] at src/index.ts:570.

Gotchas & failure modes

  1. Cron re-fire duplicate sends. The platform re-fires a cron when a run overruns its ~2-min ack window (real BC fetches push past it). Daily/weekly briefs dedup via claim-at-start. The escalation and completion-feedback jobs have no day-level claim — they rely on per-ticket stamps, fine unless a re-fire lands before the stamp is written. (Daily brief was sent 3× on 2026-06-17 before the guard shipped.)
  2. Backlog-flush risk. Completion-feedback fires hourly and gates only on per-ticket stamps, so a data-fix that suddenly makes many tickets "due" flushes a burst of catch-up emails at once (~24 observed 2026-06-16, test inbox).
  3. ⚠️ Timezone assumption unverified. Both briefs pass timezone: getAgentTimezone() and rely on the platform honoring IANA zones. If ignored, the daily brief fires 09:00 UTC (= 10:00 BST in summer) and weekly 17:00 UTC Friday. Source flags "VERIFY actual fire time on first run."
  4. Briefs are scoped to ONE company by design (ADMIN_DEFAULT_COMPANY_ID/Tribeca) — a workaround for a global ticket-location cache bug. To include a second company, that cache must be made per-company first. The escalation and completion-feedback jobs DO loop all companies and could hit the same cache issue.
  5. Send-payload size cap. The daily-brief HTML log table can exceed the send-message size limit (157 open tickets → HTTP 400). Mitigated by DAILY_BRIEF_MAX_LOG_ROWS / WEEKLY_BRIEF_MAX_LOG_ROWS.