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.
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.
| # | Step | Owner | Writes | What happens · how it fails |
|---|---|---|---|---|
| 1 | Tick | Advanced Cron runtime | cron_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. |
| 2 | Self-gate | AutomationDispatchJob | nothing | 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. |
| 3 | Phase A — claim | AutomationDispatchJob | automation_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. |
| 4 | Advance the pointer | AutomationDispatchJob | automations.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. |
| 5 | Phase B — hand off | AutomationDispatchJob | nothing | 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. |
| 6 | Preflight | Automation_executor | automations.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. |
| 7 | Lease + chunk loop | executor drives · your handler evaluates | automation_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. |
| 8 | Finish | Automation_executor | automation_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. |
— |
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.
| Limit | Value | Where | Why that number |
|---|---|---|---|
| Tick time budget | 45 s | AutomationDispatchJob::TIME_BUDGET_SECONDS | Headroom inside a one-minute schedule for the runtime's own bookkeeping. The job's own timeoutSeconds() is 50. |
| Claims per tenant per tick | 100 | MAX_CLAIMS_PER_TICK | A clock jump backwards must not make thousands of automations due at once. |
| Open runs advanced per tick | 50 | Phase B query limit | Bounds the fan-out; oldest first, so nothing starves. |
| Chunks per run per tick | 20 | Automation_executor::MAX_CHUNKS_PER_TICK | Caps one automation's share even when it is fast and the deadline is far off. |
| Lease | 600 s | start_run($run_id, $owner, 600) | Ten minutes — comfortably longer than any tick, so a healthy run never trips it. |
| Records per chunk | 250 (default) | batch_size in config | Passed to your handler as $limit, narrowed by whatever budget remains. |
| Records per run | 1000 (default) | max_items_per_run in config | Accumulates across ticks — a resumed run counts what earlier ticks already did. |
| First live run | 25 | FIRST_LIVE_RUN_LIMIT | See below. Overridable per automation via safety.first_live_run_limit. |
| Cron search horizon | 366 days | nextOccurrence() | Every expression that can ever match does so within a year; one that cannot is a config error. |
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.
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.
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.
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.
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.
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.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.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:
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 status | Set when | Meaning |
|---|---|---|
| completed | No record failed | The 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_exceptions | failed > 0 | The run finished, but at least one record errored. The reason codes tell you which. |
| failed | A preflight refusal or a handler throw | Carries 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_out | Never, today | Declared 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.
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
has_more. Return true whenever records might remain.
Returning false early ends the run silently with work undone.$limit. It is already narrowed by the remaining
per-run budget, so returning more than asked breaks the cap.$context->run_mode === 'live'. One evaluator,
one boolean.HANDLER_ERROR — not return an empty chunk that looks like a
clean run with nothing to do.automations_item_outcomes(). Anything else is
dropped by record_item() without an error, leaving correct counts and an
empty detail view.