PR #3535
Sections
Review

fix(office): don't fire paused or archived routines on cron, manual run, or webhook

main ← feature/paused-office-routin-e02de3 30 files +1680 −140 PR #3535 ↗

Paused and archived routines no longer dispatch runs on cron ticks, manual runs, or webhook fires; suppressed cron slots advance without recording a fire.

Why this PR exists

A paused or archived routine still fires on cron, manual run, and webhook. Pause does not stop work.

Who is affected: Operators who pause or archive Office routines to stop agent work.

Result: Only active routines fire. Paused and archived routines stay quiet on every path.

What it does

Impact at a glance

Breaking changes

Affected users / triggerBeforeAfterRequired actionSource
Callers that run a paused, archived, or unrecognized-status routine manually or by webhookThe request dispatches a run and returns 200.The request returns 409 with error_code routine_not_firing and the observed status.Set the routine status to active before firing, or handle the 409 refusal.apps/backend/internal/office/routines/handler.go
Operators who rely on paused routines firing on their cron scheduleDue cron slots dispatch runs while the routine is paused.Due cron slots advance past now with no run row, no wakeup, and no last_fired_at stamp.Resume the routine to active to receive fires again; missed slots during pause are not replayed.apps/backend/internal/office/routines/service.go

User experience

Entry pointBeforeAfterSource
Office Routines listA paused routine still shows a next-fire countdown.A non-firing routine shows Off and hides the next-fire countdown.apps/web/app/office/routines/routine-row.tsx
Office Routine detail, Run now actionRun now on a paused routine shows a generic failure.Run now on a paused routine shows localized copy naming the status, and the schedule card shows no next fire.apps/web/app/office/routines/[id]/routine-detail-view.tsx

Architecture, end to end

Every fire path reads the routine and checks CanFire before it claims, dispatches, or renders.

flowchart TD
  Cron[TickScheduledTriggers] --> Read1[GetRoutineFromConfig]
  Manual[FireManual] --> Gate[RoutineStatus.CanFire]
  Webhook[fireWebhookTrigger] --> Gate
  Read1 --> Gate
  Gate -- fires --> Claim[ClaimTrigger and dispatch run]
  Gate -- suppressed --> Advance[AdvanceTriggerWithoutFiring]
  Gate -- refused --> Refuse[HTTP 409 routine_not_firing]
  Refuse --> UI[Web UI localized toast]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Backend
Web UI
Firing allowlist on RoutineStatusapps/backend/internal/office/models/enums.go ↗
func (s RoutineStatus) CanFire() bool
Click for details →

The type gates every fire path with one byte-exact allowlist check.

Allowlist
type RoutineStatus string

const (
	RoutineStatusActive   RoutineStatus = "active"
	RoutineStatusPaused   RoutineStatus = "paused"
	RoutineStatusArchived RoutineStatus = "archived"
)

func (s RoutineStatus) CanFire() bool {
	return s == RoutineStatusActive || s == ""
}
func (s *RoutineService) processCronTrigger(ctx context.Context, trigger *RoutineTrigger, now time.Time) error
Click for details →

The cron worker reads the routine first and suppresses non-firing slots without claiming.

Gate before claim
routine, err := s.GetRoutineFromConfig(ctx, trigger.RoutineID)
if err != nil {
	return nil
}
if !models.RoutineStatus(routine.Status).CanFire() {
	return s.suppressCronSlot(ctx, trigger, routine, now)
}
claimed, err := s.repo.ClaimTrigger(ctx, trigger.ID, *trigger.NextRunAt)
Suppression advance
next, err := s.computeSuppressionCursor(trigger, now)
if err != nil {
	return nil
}
advanced, err := s.repo.AdvanceTriggerWithoutFiring(ctx, trigger.ID, *trigger.NextRunAt, next)
if !advanced {
	return nil
}
s.logger.Info("routine slot suppressed",
	zap.String("routine_id", routine.ID),
	zap.String("status", routine.Status))
func (r *Repository) AdvanceTriggerWithoutFiring(ctx context.Context, triggerID string, oldNextRunAt, newNextRunAt time.Time) (bool, error)
Click for details →

The write moves next_run_at by compare-and-set and never touches last_fired_at.

Suppression write
func (r *Repository) AdvanceTriggerWithoutFiring(
	ctx context.Context, triggerID string, oldNextRunAt, newNextRunAt time.Time,
) (bool, error) {
	res, err := r.db.ExecContext(ctx, r.db.Rebind(`
		UPDATE office_routine_triggers
		SET next_run_at = ?, updated_at = ?
		WHERE id = ? AND next_run_at = ?
	`), newNextRunAt, time.Now().UTC(), triggerID, oldNextRunAt)
	if err != nil {
		return false, err
	}
	rows, err := res.RowsAffected()
	return rows > 0, err
}
Manual and webhook refusal with 409apps/backend/internal/office/routines/handler.go ↗
func (h *Handler) runRoutine(c *gin.Context)
Click for details →

Both routes refuse non-firing routines with one shared 409 body.

Manual refusal
run, err := h.svc.FireManual(c.Request.Context(), c.Param("id"), req.Variables)
if err != nil {
	var notFiring *RoutineNotFiringError
	if errors.As(err, &notFiring) {
		c.JSON(http.StatusConflict, routineNotFiringBody(notFiring.Status))
		return
	}
	writeDispatchError(c, err)
	return
}
Webhook gate and body
if !models.RoutineStatus(routine.Status).CanFire() {
	c.JSON(http.StatusConflict, routineNotFiringBody(routine.Status))
	return
}

func routineNotFiringBody(status string) gin.H {
	return gin.H{
		"error":      fmt.Sprintf("routine cannot fire: status is %q", status),
		"error_code": RoutineNotFiringErrorCode,
		"status":     status,
	}
}
Web UI mirror and refusal copyapps/web/app/office/lib/routine-status.ts ↗
export function isRoutineFiring(status: string): boolean
Click for details →

The UI hides next-fire times and maps the 409 code to localized copy.

Firing check
export function isRoutineFiring(status: string): boolean {
  return status === "active" || status === "";
}
Refusal message
export const ROUTINE_NOT_FIRING_ERROR_CODE = "routine_not_firing";

export function routineNotFiringMessage(error: unknown, t: Translate, fallbackKey: string): string {
  if (isRoutineNotFiringError(error)) {
    return t("office:routineNotFiring", { status: refusedStatus(error, t) });
  }
  if (error instanceof Error && error.message.trim()) return error.message;
  return t(fallbackKey);
}
Row gating
const isActive = isRoutineFiring(routine.status);
const nextFire = isActive ? nextFireText(t, triggers) : "";
Read the changes as a list

Firing allowlist on RoutineStatus

apps/backend/internal/office/models/enums.go

The type gates every fire path with one byte-exact allowlist check.

Allowlist
type RoutineStatus string

const (
	RoutineStatusActive   RoutineStatus = "active"
	RoutineStatusPaused   RoutineStatus = "paused"
	RoutineStatusArchived RoutineStatus = "archived"
)

func (s RoutineStatus) CanFire() bool {
	return s == RoutineStatusActive || s == ""
}

Cron gate and suppression path

apps/backend/internal/office/routines/service.go

The cron worker reads the routine first and suppresses non-firing slots without claiming.

Gate before claim
routine, err := s.GetRoutineFromConfig(ctx, trigger.RoutineID)
if err != nil {
	return nil
}
if !models.RoutineStatus(routine.Status).CanFire() {
	return s.suppressCronSlot(ctx, trigger, routine, now)
}
claimed, err := s.repo.ClaimTrigger(ctx, trigger.ID, *trigger.NextRunAt)
Suppression advance
next, err := s.computeSuppressionCursor(trigger, now)
if err != nil {
	return nil
}
advanced, err := s.repo.AdvanceTriggerWithoutFiring(ctx, trigger.ID, *trigger.NextRunAt, next)
if !advanced {
	return nil
}
s.logger.Info("routine slot suppressed",
	zap.String("routine_id", routine.ID),
	zap.String("status", routine.Status))

Cursor advance without fire evidence

apps/backend/internal/office/repository/sqlite/routines.go

The write moves next_run_at by compare-and-set and never touches last_fired_at.

Suppression write
func (r *Repository) AdvanceTriggerWithoutFiring(
	ctx context.Context, triggerID string, oldNextRunAt, newNextRunAt time.Time,
) (bool, error) {
	res, err := r.db.ExecContext(ctx, r.db.Rebind(`
		UPDATE office_routine_triggers
		SET next_run_at = ?, updated_at = ?
		WHERE id = ? AND next_run_at = ?
	`), newNextRunAt, time.Now().UTC(), triggerID, oldNextRunAt)
	if err != nil {
		return false, err
	}
	rows, err := res.RowsAffected()
	return rows > 0, err
}

Manual and webhook refusal with 409

apps/backend/internal/office/routines/handler.go

Both routes refuse non-firing routines with one shared 409 body.

Manual refusal
run, err := h.svc.FireManual(c.Request.Context(), c.Param("id"), req.Variables)
if err != nil {
	var notFiring *RoutineNotFiringError
	if errors.As(err, &notFiring) {
		c.JSON(http.StatusConflict, routineNotFiringBody(notFiring.Status))
		return
	}
	writeDispatchError(c, err)
	return
}
Webhook gate and body
if !models.RoutineStatus(routine.Status).CanFire() {
	c.JSON(http.StatusConflict, routineNotFiringBody(routine.Status))
	return
}

func routineNotFiringBody(status string) gin.H {
	return gin.H{
		"error":      fmt.Sprintf("routine cannot fire: status is %q", status),
		"error_code": RoutineNotFiringErrorCode,
		"status":     status,
	}
}

Web UI mirror and refusal copy

apps/web/app/office/lib/routine-status.ts

The UI hides next-fire times and maps the 409 code to localized copy.

Firing check
export function isRoutineFiring(status: string): boolean {
  return status === "active" || status === "";
}
Refusal message
export const ROUTINE_NOT_FIRING_ERROR_CODE = "routine_not_firing";

export function routineNotFiringMessage(error: unknown, t: Translate, fallbackKey: string): string {
  if (isRoutineNotFiringError(error)) {
    return t("office:routineNotFiring", { status: refusedStatus(error, t) });
  }
  if (error instanceof Error && error.message.trim()) return error.message;
  return t(fallbackKey);
}
Row gating
const isActive = isRoutineFiring(routine.status);
const nextFire = isActive ? nextFireText(t, triggers) : "";

Risk

4 / 10 Medium
1 low5 medium10 high

Why this score

  • All three fire paths change behavior, so a gate mistake stops scheduled work.
  • Suppression writes move the cron cursor, but the compare-and-set bounds race effects.
  • New unit, race, handler, and UI tests cover firing, suppression, resume, and refusal.

Trade-offs and review notes

Where to look first

  1. Confirm CanFire allowlist semantics in enums.go, including empty string firing.
  2. Confirm cron read-before-claim and suppressCronSlot cursor logic in service.go.
  3. Confirm AdvanceTriggerWithoutFiring leaves last_fired_at nil in sqlite/routines.go.
  4. Confirm manual and webhook 409 bodies share routineNotFiringBody in handler.go.
  5. Confirm the web UI hides next fire and maps the 409 code in routine-status.ts and routine-not-firing.ts.