v0.12.2 — Hardening & hygiene
Hardening & hygiene — close the vetted improve-laravel audit findings (against 66b8f42).
Added
- Isolated serialization round-trip coverage for every
Streaming/Events/*event (#221). The 12 streaming event classes'toArray()/fromArray()were only exercised end-to-end via the crash-replay feature tests, where a silently dropped field (e.g. an error event losingrecoverableor itsexception_class) would surface only on a real replay. A newtests/Unit/Streaming/EventSerializationRoundTripTest.phpasserts, per class over representative null/empty/full payloads, thattoArray()→SwarmStreamEvent::fromArray()→toArray()round-trips identically and that every field — including the base-classinvocation_id— survives. A directory-enumeration guard fails the suite if a newsrc/Streaming/Events/*class is added without a round-trip payload case. Coverage only — no event serialization shape changed.
Changed
-
Queued swarm runs are now attempted once by default, regardless of the worker's
--triesflag (#237). Behavior change.InvokeSwarmandBroadcastSwarmpreviously declared no$triesproperty, silently inheriting the worker's global--tries. A common pattern ofqueue:work --tries=3(needed for durable advance jobs) therefore blind-retried non-durable queued runs three times — each retry restarts the entire swarm from step 0, re-dispatching tools and re-spending LLM tokens, because these jobs hold no checkpoint. Swarm now asserts the safe default: both jobs expose atries()method returningswarm.queue.tries(default1), so worker-wide retry settings no longer silently apply. Opt back in by settingSWARM_QUEUE_TRIES=3(orswarm.queue.triesin config) if your swarms are idempotent and the token cost of a full restart is acceptable.swarm.queue.timeout/SWARM_QUEUE_TIMEOUTnow takes effect: the value is wired through the$timeoutproperty (which is what Laravel's queue payload builder reads — it never calls atimeout()method), so an explicit ceiling can be set without affecting legitimately long LLM runs. Previously the configuration key was silently inert.ResumeQueuedHierarchicalSwarmis reclassified fromConfiguresQueuedSwarmJobtoConfiguresDurableAdvanceJob: it is lease-guarded durable coordination (DurableRunStore + continuation lease, re-dispatched by the durable recovery sweep), not a fire-and-forget queued run — the tries=1 "blind retry re-spends tokens" rationale does not apply. Contrast: durable advance jobs (AdvanceDurableSwarm/AdvanceDurableBranch) safely retry from the last persisted checkpoint and continue to derive their tries/timeout/backoff from the separateswarm.durable.job.*keys. -
swarm:historyand multi-runswarm:statusno longer over-hydrate steps to render the Steps column (#236). The list query path (DatabaseRunHistoryStore::query(), which backsSwarmHistory::latest()and theforSwarm()/withStatus()filters) fully reconstructed every listed run's steps — per-run normalized-step loads plus per-step cipher decryption — only to callcount()on the result. The list path now projects each row through a new leanmapRecordLean()that skips step/context/output decryption entirely and computes the step count directly: the size of the union of the legacy-JSON step indices and the normalizedswarm_run_stepsindices, reproducing exactly what full hydration would have counted (legacy-only runs, normalized-only runs, and overlapping/disjoint indices all match). The normalized indices for every listed run are batch-loaded in a single(run_id, step_index)query, so the list path is now a fixed two queries regardless of run count instead of scaling per run. Operators see identical output — the Steps column is unchanged.find()and stream-replay (findMatching()over a decrypted-context cursor) keep full step hydration and decryption, so single-run inspection and replay are unaffected. Fail-safe side effect: because list views no longer decrypt step IO, a run with a poison/undecryptable step row now lists successfully (the count needs no plaintext) instead of trippingdecrypt_failure_policyon an interactive list command;find()/replay still decrypt fully and surface such failures. -
Added a
composer auditCI job that surfaces dependency advisories on every push and PR (#219). The new.github/workflows/audit.ymlresolves a fresh--prefer-stabletree (this package commits nocomposer.lock, so CI always tests against a freshly resolved tree) and runscomposer audit. It starts non-blocking (continue-on-error: true) so a transient upstream advisory landing mid-review never gates a merge; the documented path to gating is to dropcontinue-on-erroronce the tree has held advisory-clean for a release cycle. The advisory-affected transitive deps flagged at the time (guzzlehttp/psr7,symfony/http-foundation,symfony/routing,symfony/polyfill-intl-idn) resolve advisory-clean under the current constraints, so no top-level constraints incomposer.jsonchanged. The job audits both resolutions CI tests against —--prefer-stableand (added in #230)--prefer-lowest— via a build matrix, so an advisory present only in the lowest in-range tree is caught too; the matrix job name shows which resolution flagged it. -
Test-determinism: time-boundary durable lease-expiry and stale-run recovery assertions now run inside a frozen clock window (
$this->freezeTime()), so the pastleased_until/updated_atmarkers and the runtime's livenow()resolve to the same instant. Removes the tight sub-second real-clock margin that left the durable/lease/recovery lane vulnerable to execution jitter under load. (Cross-processtests/ProcessConcurrencysubprocesses keep their generous margins — a frozen clock can't be shared across processes.) -
Test-determinism follow-up (#231): the frozen-clock durable lease markers now build their timestamps with plain
now()instead ofnow('UTC'), matching the clock the runtime reads, so the assertions no longer depend on the test environment's timezone being UTC. -
Idiom/immutability nits (#222).
StreamedSwarmResponse::$eventsis now declaredreadonly, closing a value-object immutability hole — its parentSwarmResponsealready marks every propreadonly, and$eventsis never reassigned after construction. The PulseSwarmRunscard's topology/count assembly (src/Pulse/Livewire/SwarmRuns.php) is now expressed as Collection chains (->filter()->sortByDesc()->values()) instead of proceduralarray_filter+usortwrapped incollect(), matching the package's Laravel-native idiom. Zero behavior change: same filtering, same sort order, same Pulse card output. -
Collection fluency in propagation policies (#238). The
array_values(array_filter(...))+usortchains insideConversationPropagationPolicyandDefaultPropagationPolicyare now expressed as fluent Laravel Collection chains (->filter()->partition()/->sortBy()->values()->all()), matching the Laravel-native idiom established in #222. No behavior or return-type change; both methods still returnarray<int, MemoryEntry>. -
Audit signing now enforces a
signature_algorithmwhenever a signature is present (#223). Resolves the #223 spike: the package signs evidence on emit but, by design, never verifies a signature on read — verification is the sink's responsibility. To keep stored signatures verifiable and rotatable,SwarmAuditDispatchernow treats a signer that adds a non-emptysignaturewithout a non-emptysignature_algorithmas a signing failure, routing it through the boundSinkFailureHandlerlike any other signing failure. Underfailure_policy=halt(throws) orswallow(drops) the unverifiable record never reaches the sink; under aqueue/dead-letterpolicy it follows the configured outbox path and is delivered on the next drain. The documented per-category opt-out (returning the payload unchanged) is unaffected.docs/audit-evidence-contract.mdnow states the verification responsibility split and the canonicalization footgun explicitly. Behavior change — seeUPGRADING.md. -
Behavior change: durable advance jobs now enforce
step_timeout + marginas the queue-worker timeout (#243).ConfiguresDurableAdvanceJobpreviously exposed atimeout()method, but Laravel's queue layer reads timeout from the job's$timeoutproperty viaQueue::getAttributeValue()— atimeout()method is never called (unliketries()/backoff(), which have explicitmethod_existsguards ingetJobTries/getJobBackoff). As a resultAdvanceDurableSwarmandAdvanceDurableBranchsilently inherited the worker--timeout(default 60 s), which prematurely killed long-running steps and churned leases. Thetimeout()method is replaced by apublic int $timeoutproperty assigned in the constructor viaapplyDurableAdvanceJobTimeout(), sostep_timeout + timeout_margin_secondsreaches the serialized queue payload exactly as documented. No behavior change for operators who raisedSWARM_DURABLE_STEP_TIMEOUTabove 60 s and observed correct behavior — those deployments happened to have a worker--timeout≥ the step window; this fix makes the job's own timeout authoritative. Corrective: raiseSWARM_DURABLE_STEP_TIMEOUTif your steps need longer than the current value; the worker--timeoutmust remain ≥ the job timeout. Does not hard-cancel an in-flight provider call.
Fixed
-
swarm:health --jsonokfield now mirrors the exit-code contract; audit outbox checks are scoped to the configured failure policy;swarm.audit.failure_policydocs corrected (#247). Three related operator-surface gaps: (1)okin--jsonoutput wastrueonly when every row hadstatus=ok, so a healthy deployment with anote(relay scheduling) orwarning(stale outbox) row emitted{"ok": false}while exiting 0 —okis now the exact complement of the failure exit:trueiff no row hasstatus=failed. (2)runAuditOutboxChecks()ran table/staleness/dead-letter checks for all database-backed installs regardless ofswarm.audit.failure_policy; onlyqueueanddead_letterwrite to the audit outbox, soswallow/log/haltinstallations would fail with a misleading "table does not exist" if they had not run the outbox migration — those policies now return a singlenoterow and exit 0. (3)docs/configuration.mdlistedfailure_policy's default asswallow(the real default isqueuesince v0.5) and omittedqueue,dead_letter, andhaltfrom the description; corrected with accurate policy documentation. -
Queued-hierarchical-parallel resumes stranded in
runningafter a worker crash are now reclaimed and recovered (#244). When a multi-worker queued hierarchical run's post-join continuation worker died after acquiring the continuation lease, the run was left with its history row inrunningand an expiring lease that no path re-drove:recoverable()excludes theQueueHierarchicalParallelcoordination profile andrecoverableWaitingJoins()only matcheswaiting, so neither a queue retry norswarm:recoverre-dispatched it.DatabaseRunHistoryStore::acquireQueuedRunContinuationLease()now reclaims such a run once its lease expires — rotating to a fresh execution token while keeping the statusrunningand returning the previously-unusedQueuedRunAcquisition::reclaimed()outcome — scoped narrowly to theQueueHierarchicalParallelprofile (confirmed via the durable runs table) so no other profile, all of which resume through the durable lease path, is ever touched. A newDurableRunStore::recoverableQueuedResumes()query (batched, single join, no N+1) surfaces these stranded resumes toDurableRecoveryCoordinator::recover(), which re-dispatches each viadispatchQueuedHierarchicalResume(). Mutual exclusion rides the existing execution-token guard: a reclaim rotates the token, so a merely-slow (not-dead) prior worker's first lease-checked write fails withLostSwarmLeaseExceptionand aborts before any side effect, and the parallel-boundary fan-out (enqueued inside the continuation's token-guarded, outbox-transactional write) rolls back rather than double-dispatching. (#244) -
Durable recovery sweeps batch-load run hydration instead of an N+1 per candidate (#235).
recoverable(),dueRetries(), andrecoverableWaitingJoins()onDatabaseDurableRunStorefetched a bounded candidate list and then calledfind()once per row, and eachfind()re-queried the main row plus theswarm_durable_run_stateandswarm_durable_node_statesside tables (~3 queries × N, andrecoverableWaitingJoins()an extra branch lookup per surviving run) — the N+1 fired hardest during incident-driven recovery, exactly when the database is already under load. The side-table state is now batch-loaded for all candidaterun_ids in one pass each, sorecoverable()/dueRetries()issue a constant 3 queries andrecoverableWaitingJoins()a constant 4, regardless of candidate count, and zero side-table queries when there are no candidates. The 45-field run shape is now assembled by a single sharedhydrateRunRecord()that bothfind()and the sweeps call, so the batch path can never drift from the per-record output — the returned arrays (including thenode_statesmap contents and ordering, and the waiting-join terminal-branch filtering) are byte-for-byte identical to before. This path reads only JSON columns; no decryption is introduced. No migrations; no application change. -
Standardized scheduler-location docs on
routes/console.php(the only valid location for this Laravel-13-only package). Removed stalebootstrap/app.phpandapp/Console/Kernel.phpreferences fromdocs/durable-execution.md,docs/configuration.md, anddocs/execution-modes.mdso copying the relay/recover/prune schedule into the wrong file can no longer leave the scheduler silently non-running. (#218)