Automation Center Reference Public

Run Lifecycle & Execution

What happens to your automation handler at run time: the eight-step lifecycle from cron tick to audit row, the executor-versus-handler ownership split, idempotency keys, chunk budgets and caps, resumable cursors, and how a run ends.

Guide version: r1 Module version: 0.3.3 Updated: 2026-09-04 Estimated time: 19 min 1 views
Developer Guide

Run Lifecycle & Execution

You have declared a type and written a handler. This page is what happens to it after that: who calls execute_chunk(), how often, with what budget, what is already done for you, and what a run looks like when it is over.

Your handler is a pure decision function that happens to be allowed to write. It is handed a context, a frozen configuration, a cursor and a limit; it returns counts and a next cursor. Everything around that — leases, budgets, retries, caps, aggregation, audit — belongs to Automation_executor. If you find yourself writing scheduling logic in a handler, you are re-implementing something that already exists and will diverge from it.

The eight steps, end to end

One minute of wall clock produces at most one tick per tenant, and that tick does two things: it turns due automations into run rows (Phase A) and it pushes open run rows forward (Phase B). The two phases are deliberately separate. Claiming is cheap, bounded and must always finish; executing is expensive and is allowed to stop halfway. Splitting them means a slow automation can never prevent a fast one from becoming due.

Read the stepper below — each step names its owner, the table it writes, and, more usefully, how it fails. Most of the design lives in that last column: a runtime is defined by what it does when things go wrong, not by the happy path.

Interactive lifecycle stepper — needs JavaScript. The same eight steps are described in the table below.

#StepOwnerWritesWhat happens · how it fails
1TickAdvanced Cron runtimecron_runs Fans the job out per tenant with $CI->db already switched, holds the per-(job, tenant) lock with stale-steal, records the tick. The Automation Center supplies none of this — which is why it ships no lock table and no heartbeat table. Fails: with no cron driver reaching the dispatcher, automations simply never become due. They do not error.
2Self-gateAutomationDispatchJobnothing JobRegistry discovers jobs regardless of module state, so the job checks get_setting('module_automations') itself, then guards on table_exists('automations') because the __primary__ pass reaches installs that never ran the module's migrations. Fails: both are silent skips — correct for a job that runs every minute on every tenant.
3Phase A — claimAutomationDispatchJobautomation_runs (INSERT IGNORE) Selects due automations (active, next_run_at_utc <= now, limit 100). If a run is still open and the overlap policy is not queue_latest, skip. Otherwise create_run() with the idempotency key, the run mode and a frozen config snapshot. Fails: a null return means another process already owns this occurrence — success, not an error.
4Advance the pointerAutomationDispatchJobautomations.next_run_at_utc Computes the next occurrence with App\Cron\Core\CronExpression — the same parser the runtime's own Scheduler uses, so a second parser can never drift from it — over a bounded 366-day minute horizon. Fails: a valid-but-never-matching expression (30 February) returns null past the horizon; the pointer is cleared and the automation moves to needs_review rather than stalling invisibly.
5Phase B — hand offAutomationDispatchJobnothing Loads open runs oldest-first (limit 50) and passes each to Automation_executor::advance($run, $deadline), each inside its own try/catch. Fails: a throwing run is closed EXECUTOR_ERROR so one broken automation cannot strand the rest of the tenant's tick behind it.
6PreflightAutomation_executorautomations.status (on refusal) Re-checks is_available(), loads the rule engine before constructing the handler, resolves handler and condition provider as a pair, re-runs validate_config() against the current definition, reconciles run_mode, and for live runs revalidates the publisher. Fails: each refusal closes the run with its own error code and moves the automation to needs_review. Publisher revalidation fails closed.
7Lease + chunk loopexecutor drives · your handler evaluatesautomation_runs.cursor_json + counts, automation_run_items start_run() takes a 600-second lease, then execute_chunk() is called up to 20 times per tick until the deadline. Each chunk's items[] become run-item rows; its notices{} are merged into reason_counts._notices. Fails: record_item() silently returns false for an outcome outside automations_item_outcomes() — the run then completes with correct counts and an empty Run-detail modal.
8FinishAutomation_executorautomation_runs (final), automations.last_run_*, system_logs × 1 finish_run() sets completed, or completed_with_exceptions when any record failed. In Observe, succeeded lands in would_count; in Live, in approved_count. Exactly one audit row per completed run. Fails: never one audit row per record — the per-record trail is automation_run_items.

Who owns what

This split is the whole reason a new automation type is a few hundred lines instead of a few thousand. Read the left column as "already solved, do not re-solve", and the right column as "the only part that is actually about your domain".

Automation_executor owns Your handler owns
Run state. queued → running → completed / completed_with_exceptions / failed, plus started_at, finished_at and duration_ms. Candidate selection. Which records of yours are in scope for this configuration, ordered deterministically, resumed from the cursor.
Leases. start_run() stamps a 600-second lease with an owner token derived from host and PID. Rule evaluation. Feeding each candidate through Automation_rule_engine and deciding an outcome plus a reason code.
Cursors. Persisted with save_progress() after every chunk, so the next tick resumes exactly where this one stopped. Calling your module's service. The one authoritative mutation path — the same one your own Approve button calls — and only when run_mode === 'live'.
Retries and resumption. Unfinished runs stay open and are picked up on the next tick; the type, config and publisher are all revalidated again first. Config validation. validate_config(), called both on save and again at run time.
Time budgets. The 45-second tick deadline and the 20-chunk-per-tick cap are enforced around your handler, not inside it. Preview. preview(), which must reach the same evaluator as execute_chunk() and mutate nothing.
Aggregation. Count increments, reason-code rollups across chunks and ticks, and notice merging into reason_counts._notices. Returning the chunk contract. processed, succeeded, skipped, failed, next_cursor, has_more, reason_counts, and optionally items[] and notices{}.
Caps. batch_size, max_items_per_run, and the hard first-live-run limit. Nothing else. Not run status, not leases, not audit rows, not the schedule, not the caps.
Per-record persistence and audit. record_item() for every item you return, and exactly one set_system_logs() row per completed run.
The one audit row is the executor's, not yours. It records that a run happened and with what counts. It is not the business audit trail for the records you changed — that belongs to your module and must be written by your mutation service, exactly as it is when a human clicks the button. A 263-record run leaves one row here and 263 rows there.

Idempotency — at three levels

A minute-resolution scheduler that runs on every tenant every minute will be invoked twice for the same moment eventually: a slow tick, a restart, a manual trigger, a clock adjustment. The design answer is not to prevent that; it is to make the second attempt a no-op at every level.

One occurrence, one run row

automation_runs.idempotency_key carries a UNIQUE index (uniq_automation_runs_idem) and create_run() writes through INSERT IGNORE, then re-selects. Concurrency is handled by the database, not by a SELECT-then-INSERT that has a race in the middle. The key itself is deliberately narrow:

// modules/Automations/helpers/automations_helper.php

/**
 * The key that makes one due occurrence exactly one Run, however many times the
 * dispatcher is invoked for that minute.
 *
 * The configuration revision is DELIBERATELY EXCLUDED: one scheduled occurrence
 * must stay one occurrence even when the automation is edited after it became due.
 * Including it would let an edit mid-tick manufacture a second run of the same
 * moment (plan §9).
 */
function automations_run_idempotency_key($automation_uuid, $scheduled_for_utc, $trigger_kind = 'schedule') {
    return hash('sha256', $automation_uuid . '|' . $scheduled_for_utc . '|' . $trigger_kind);
}

That exclusion is the interesting decision, and it is worth stating plainly because it looks like an oversight: the revision is left out on purpose. If the key included it, an operator saving an edit while a tick was in flight would mint a second key for the same minute, and the same occurrence would run twice against two configurations. One scheduled moment is one occurrence, whatever anyone does to the rule afterwards.

The run row is written before the pointer moves

Phase A inserts the run and then advances next_run_at_utc. That order is the only crash-safe one. Crash between the two and the same occurrence is re-claimed next tick, where the unique key makes the retry harmless. Advance first and a crash silently loses the occurrence — the worse failure, because nothing anywhere records that it should have happened.

The pointer is advanced whether or not this process created the run. If the row already existed, the occurrence is accounted for and the pointer must still move, or the automation would be re-claimed on every tick forever.

One record, one decision per run

automation_run_items carries UNIQUE(run_id, subject_type, subject_id) and record_item() also uses INSERT IGNORE. A resumed chunk that re-observes a record it already decided writes nothing the second time. Your handler therefore does not need to remember what it has seen within a run — but it does need a cursor that does not re-offer records unboundedly, or you will simply spend the budget re-deciding.


Budgets, caps and the real numbers

Every one of these is a real constant in the source, not a guideline. They exist so that a single automation cannot monopolise a tick, a bad clock cannot flood the queue, and a mistake that survived Observe costs a reviewable handful of records rather than a thousand.

LimitValueWhereWhy that number
Tick time budget45 sAutomationDispatchJob::TIME_BUDGET_SECONDSHeadroom inside a one-minute schedule for the runtime's own bookkeeping. The job's own timeoutSeconds() is 50.
Claims per tenant per tick100MAX_CLAIMS_PER_TICKA clock jump backwards must not make thousands of automations due at once.
Open runs advanced per tick50Phase B query limitBounds the fan-out; oldest first, so nothing starves.
Chunks per run per tick20Automation_executor::MAX_CHUNKS_PER_TICKCaps one automation's share even when it is fast and the deadline is far off.
Lease600 sstart_run($run_id, $owner, 600)Ten minutes — comfortably longer than any tick, so a healthy run never trips it.
Records per chunk250 (default)batch_size in configPassed to your handler as $limit, narrowed by whatever budget remains.
Records per run1000 (default)max_items_per_run in configAccumulates across ticks — a resumed run counts what earlier ticks already did.
First live run25FIRST_LIVE_RUN_LIMITSee below. Overridable per automation via safety.first_live_run_limit.
Cron search horizon366 daysnextOccurrence()Every expression that can ever match does so within a year; one that cannot is a config error.
The first live run is capped hard, and the reasoning is not "be careful". An Observe run proves the rule selects the right records. It cannot prove the approvals are right, because nothing was approved. The first run that actually changes records is therefore the one whose results nobody has ever seen — so it is bounded to the automation’s First live run limit (the builder ships 50; FIRST_LIVE_RUN_LIMIT = 25 is only the fallback for a stored value that is not positive). Once one live run has completed (has_completed_live_run()), the normal cap applies.

Unfinished is not failed

This is the single most important thing to internalise about the loop, because the instinct to "clean up" a run that is still running after a tick is exactly wrong. When the deadline passes or the chunk cap is reached, the executor returns status: 'running' and simply stops. The run keeps its cursor, its counts and its lease, and the next tick continues it.

// modules/Automations/libraries/Automation_executor.php — run_chunks()

while ($chunks < self::MAX_CHUNKS_PER_TICK) {
    if (time() >= $deadline) {
        break;   // out of budget: stay running, resume next tick
    }

    $remaining = $max_items - ($already + $processed_now);
    if ($remaining <= 0) { $has_more = false; break; }

    $limit = min($batch_size, $remaining);
    $chunk = $handler->execute_chunk($context, $config, $cursor, $limit);

    // ... record items, merge reason counts and notices ...

    $cursor = get_array_value($chunk, 'next_cursor');
    $this->CI->Automation_runs_model->save_progress($run_id, $cursor, $counts);

    $has_more = (bool) get_array_value($chunk, 'has_more');
    if (!$has_more) { break; }
}

if ($has_more) {
    // Unfinished is not failed. Leave it running with its cursor.
    return array('status' => 'running', 'processed' => $processed_now, 'chunks' => $chunks);
}

Note the ordering inside the loop: the cursor is saved after the items are recorded. A crash between the two re-offers the same records, and the unique item index absorbs them. Saving the cursor first would skip records that were never decided.

Known gap, named: Automation_runs_model::get_stale_running() exists — it finds runs left running by a worker whose lease has expired — but nothing calls it yet. Today it does not matter: the Phase B query selects status IN ('queued','running') without consulting the lease, so an abandoned run is simply advanced again on the next tick, and the cron runtime's per-(job, tenant) lock already prevents two executors from touching one tenant concurrently. The lease is therefore defence-in-depth for a future out-of-band run path, not load-bearing today. Do not delete it, and do not assume it is enforced.


Cursors — make them absolute and JSON-serializable

Your next_cursor round-trips through the database as automation_runs.cursor_json between ticks, and is handed back to you verbatim. Two constraints follow, and both are hard.

{ }
JSON-serializable only

Scalars and arrays. No objects, no closures, no DB handles, no result resources. It is json_encode()d on the way out and json_decode(..., true)d on the way back, so anything else arrives as something you did not send — or as nothing at all.

Absolute, never an offset

Use a monotonic key such as after_id, not a page number. Records are inserted, approved and soft-deleted while your run is paused between ticks; an OFFSET silently skips or repeats rows when the underlying set shifts under it.

The shipped attendance handler is the reference shape — it reads $cursor['after_id'], orders by id ascending, and returns array('after_id' => $last_id) with has_more = count($candidates) >= $limit. That last expression is worth copying too: it costs one extra empty chunk at the end of a run, and in exchange it never claims "done" while records remain.

A cursor of null means "start from the beginning". Return it only on the very first chunk's input, never as a next_cursor — returning null mid-run restarts the scan, and with the unique item index absorbing the duplicates you would burn the entire budget re-deciding the same first page forever.

Preview and live must be one evaluator

The interface says it outright: "A preview that can disagree with the live run it predicts is worse than no preview at all." A wrong preview does not merely fail to help — it manufactures confidence. So preview() and execute_chunk() are required to select candidates and apply rules through the same code path, differing only in whether they mutate.

The shipped handler implements this as one private method with an $apply flag: preview() passes false, and execute_chunk() passes $context->run_mode === 'live'. That single boolean is the entire difference, which is also what makes Observe mode trustworthy — Observe is not a simulation of the live path, it is the live path with the write suppressed.

Parity is not yet complete for attendance — plan §20.1, and it is documented rather than hidden. Attendance_auto_decision_handler::preview() re-implements the payroll-lock, rolling-window and overlap checks instead of calling the service that owns them, because in transition_attendance_status() those checks are interleaved with the mutation and cannot be invoked alone. The system stays safe — the service's refusal always overrides the handler's verdict, so nothing is written that the service would have rejected — but preview and live can disagree about a record right up to the moment of the write. The named fix is to extract a side-effect-free evaluate_attendance_transition() from those five pure read blocks and have the mutation call it. Owner: HumanResource. Highest-value item on the debt list.

If you are writing a new handler, take the lesson rather than the workaround: expose an eligibility predicate in your own module first, and have both preview and the mutation call it.

The promotion gate, and why it is checked twice

Going Live is not a mode toggle; it is a decision with four preconditions, all enforced in Automations::_live_refusal() before the automation's mode can change.

The automations_go_live permission. Separate from automations_publish on purpose: someone who may schedule a rule that only reports is not thereby authorised to let it change payroll inputs unattended.
The type declares capabilities.live. The type decides whether live application exists at all. A handler that cannot apply must never be promoted, whatever the operator's permissions.
A completed run exists. An automation that has never completed a run has never been shown to be correct. The Observe run costs nothing but time.
That run's config_hash matches the current configuration. The subtle one. Preview, then widen the scope, then go live — and the run that "proved" the rule was observing a different rule against a different set of records.

Those four are a publish-time gate. Authority, however, is also revalidated at run time, and only for live runs:

An automation acts with the authority of whoever promoted it, and a schedule outlives people. The publisher leaves, is deactivated, or has the permission withdrawn — and without a run-time check the rule keeps approving records indefinitely on authority that no longer exists. Checking at run time rather than only at publish time is the difference between a grant and a standing delegation.

It fails closed: a publisher who is missing, deleted, inactive or no longer holds automations_go_live denies the run. The automation moves to needs_review and the run closes failed / PUBLISHER_UNAUTHORIZED. "Cannot tell, so proceed" would mean a deleted user's automation keeps changing records — the exact failure this guards.

Observe runs are deliberately not gated. They change nothing, and stopping a report because its author left the company would remove the very visibility someone needs in order to notice that.
user_has_permit() ignores its $userid argument. It evaluates the logged-in user — which, inside a cron process, is nobody. The publisher check uses user_can($publisher_id, 'automations_go_live'), which really does read the stored permissions of an arbitrary user. If you write any other "can this other person do X" check anywhere in this module, use user_can().

Run-mode reconciliation — the current mode governs

A run is stamped with the automation's mode when it is claimed, alongside the config snapshot. But at execution time the automation's current mode is what governs, and the run row is corrected to match.

That direction looks inconsistent with the frozen config snapshot, so it is worth being explicit about why it is the opposite way round: it only ever moves toward safety. An operator who demotes a live automation to Observe expects records to stop changing now, including for runs already queued. A demotion that took effect one tick later would be useless in precisely the situation it exists for. Promotion mid-queue is honoured equally, but that path is gated by _live_refusal() before it can happen at all.

The correction is written back to the run row rather than merely applied in memory, so that history reports what the run actually did — not what it was queued to do. A run listed as dry_run genuinely wrote nothing.


How a run ends

A run closes when a chunk reports has_more = false, or when the accumulated item count reaches max_items_per_run. Then:

Final statusSet whenMeaning
completedNo record failedThe run finished its candidate set. Skips do not degrade this — a skip is the designed outcome, a record deliberately left for a person.
completed_with_exceptionsfailed > 0The run finished, but at least one record errored. The reason codes tell you which.
failedA preflight refusal or a handler throwCarries an error_code and a summary truncated to 1000 characters — a code and a short summary, never a raw driver error.
skipped_overlap · cancelled · timed_outNever, todayDeclared in automations_run_statuses() and rendered with labels, but the Milestone 3 executor writes none of them. Overlap is handled by not creating a run at all. Reserved for the M4 operations UI.

Closing a run does four things in order: finish_run() writes the status, finished_at, duration_ms and the rolled-up reason counts while clearing the lease; set_last_run() updates the automation's summary columns; the audit row is written; and the outcome is returned to the dispatcher, which logs one line per run into the cron output.

The counts split by mode, and the distinction is deliberate: in Observe a positive outcome is a prediction, so it is counted as would_count — never as approved_count, which must only ever mean a record was actually changed. If you are reading a dashboard and see approved_count, something really happened.

The audit write cannot fail the run it describes. set_system_logs() is wrapped in try/catch and a failure is logged and swallowed. A run that changed 200 records correctly must not be reported as failed because a log table was full.

Runtime checklist for a new handler

Deterministic ordering. Order candidates by a monotonic key and return it as the cursor. Without a stable order, chunking repeats and skips rows.
Resumable from any cursor. Assume every chunk may be the first one after a restart. Hold no state on the handler instance — it is constructed fresh each tick.
Honest has_more. Return true whenever records might remain. Returning false early ends the run silently with work undone.
Respect $limit. It is already narrowed by the remaining per-run budget, so returning more than asked breaks the cap.
Mutate only when $context->run_mode === 'live'. One evaluator, one boolean.
Throw for a genuine refusal. A handler that cannot act should throw, and be recorded as HANDLER_ERROR — not return an empty chunk that looks like a clean run with nothing to do.
Emit outcomes from automations_item_outcomes(). Anything else is dropped by record_item() without an error, leaving correct counts and an empty detail view.
Write your own business audit. The executor's single row records the run. The record-level trail is your module's job, through your own mutation service.
Was this guide helpful?

Report a content problem