Skip to content

v0.12.2 — Hardening & hygiene

Choose a tag to compare

@dberry37388 dberry37388 released this 19 Jun 12:33
04bce02

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 losing recoverable or its exception_class) would surface only on a real replay. A new tests/Unit/Streaming/EventSerializationRoundTripTest.php asserts, per class over representative null/empty/full payloads, that toArray()SwarmStreamEvent::fromArray()toArray() round-trips identically and that every field — including the base-class invocation_id — survives. A directory-enumeration guard fails the suite if a new src/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 --tries flag (#237). Behavior change. InvokeSwarm and BroadcastSwarm previously declared no $tries property, silently inheriting the worker's global --tries. A common pattern of queue: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 a tries() method returning swarm.queue.tries (default 1), so worker-wide retry settings no longer silently apply. Opt back in by setting SWARM_QUEUE_TRIES=3 (or swarm.queue.tries in config) if your swarms are idempotent and the token cost of a full restart is acceptable. swarm.queue.timeout / SWARM_QUEUE_TIMEOUT now takes effect: the value is wired through the $timeout property (which is what Laravel's queue payload builder reads — it never calls a timeout() method), so an explicit ceiling can be set without affecting legitimately long LLM runs. Previously the configuration key was silently inert. ResumeQueuedHierarchicalSwarm is reclassified from ConfiguresQueuedSwarmJob to ConfiguresDurableAdvanceJob: 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 separate swarm.durable.job.* keys.

  • swarm:history and multi-run swarm:status no longer over-hydrate steps to render the Steps column (#236). The list query path (DatabaseRunHistoryStore::query(), which backs SwarmHistory::latest() and the forSwarm()/withStatus() filters) fully reconstructed every listed run's steps — per-run normalized-step loads plus per-step cipher decryption — only to call count() on the result. The list path now projects each row through a new lean mapRecordLean() 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 normalized swarm_run_steps indices, 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 tripping decrypt_failure_policy on an interactive list command; find()/replay still decrypt fully and surface such failures.

  • Added a composer audit CI job that surfaces dependency advisories on every push and PR (#219). The new .github/workflows/audit.yml resolves a fresh --prefer-stable tree (this package commits no composer.lock, so CI always tests against a freshly resolved tree) and runs composer 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 drop continue-on-error once 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 in composer.json changed. The job audits both resolutions CI tests against — --prefer-stable and (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 past leased_until/updated_at markers and the runtime's live now() 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-process tests/ProcessConcurrency subprocesses 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 of now('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::$events is now declared readonly, closing a value-object immutability hole — its parent SwarmResponse already marks every prop readonly, and $events is never reassigned after construction. The Pulse SwarmRuns card's topology/count assembly (src/Pulse/Livewire/SwarmRuns.php) is now expressed as Collection chains (->filter()->sortByDesc()->values()) instead of procedural array_filter + usort wrapped in collect(), 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(...)) + usort chains inside ConversationPropagationPolicy and DefaultPropagationPolicy are 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 return array<int, MemoryEntry>.

  • Audit signing now enforces a signature_algorithm whenever 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, SwarmAuditDispatcher now treats a signer that adds a non-empty signature without a non-empty signature_algorithm as a signing failure, routing it through the bound SinkFailureHandler like any other signing failure. Under failure_policy=halt (throws) or swallow (drops) the unverifiable record never reaches the sink; under a queue/dead-letter policy 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.md now states the verification responsibility split and the canonicalization footgun explicitly. Behavior change — see UPGRADING.md.

  • Behavior change: durable advance jobs now enforce step_timeout + margin as the queue-worker timeout (#243). ConfiguresDurableAdvanceJob previously exposed a timeout() method, but Laravel's queue layer reads timeout from the job's $timeout property via Queue::getAttributeValue() — a timeout() method is never called (unlike tries()/backoff(), which have explicit method_exists guards in getJobTries/getJobBackoff). As a result AdvanceDurableSwarm and AdvanceDurableBranch silently inherited the worker --timeout (default 60 s), which prematurely killed long-running steps and churned leases. The timeout() method is replaced by a public int $timeout property assigned in the constructor via applyDurableAdvanceJobTimeout(), so step_timeout + timeout_margin_seconds reaches the serialized queue payload exactly as documented. No behavior change for operators who raised SWARM_DURABLE_STEP_TIMEOUT above 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: raise SWARM_DURABLE_STEP_TIMEOUT if your steps need longer than the current value; the worker --timeout must remain ≥ the job timeout. Does not hard-cancel an in-flight provider call.

Fixed

  • swarm:health --json ok field now mirrors the exit-code contract; audit outbox checks are scoped to the configured failure policy; swarm.audit.failure_policy docs corrected (#247). Three related operator-surface gaps: (1) ok in --json output was true only when every row had status=ok, so a healthy deployment with a note (relay scheduling) or warning (stale outbox) row emitted {"ok": false} while exiting 0 — ok is now the exact complement of the failure exit: true iff no row has status=failed. (2) runAuditOutboxChecks() ran table/staleness/dead-letter checks for all database-backed installs regardless of swarm.audit.failure_policy; only queue and dead_letter write to the audit outbox, so swallow/log/halt installations would fail with a misleading "table does not exist" if they had not run the outbox migration — those policies now return a single note row and exit 0. (3) docs/configuration.md listed failure_policy's default as swallow (the real default is queue since v0.5) and omitted queue, dead_letter, and halt from the description; corrected with accurate policy documentation.

  • Queued-hierarchical-parallel resumes stranded in running after 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 in running and an expiring lease that no path re-drove: recoverable() excludes the QueueHierarchicalParallel coordination profile and recoverableWaitingJoins() only matches waiting, so neither a queue retry nor swarm:recover re-dispatched it. DatabaseRunHistoryStore::acquireQueuedRunContinuationLease() now reclaims such a run once its lease expires — rotating to a fresh execution token while keeping the status running and returning the previously-unused QueuedRunAcquisition::reclaimed() outcome — scoped narrowly to the QueueHierarchicalParallel profile (confirmed via the durable runs table) so no other profile, all of which resume through the durable lease path, is ever touched. A new DurableRunStore::recoverableQueuedResumes() query (batched, single join, no N+1) surfaces these stranded resumes to DurableRecoveryCoordinator::recover(), which re-dispatches each via dispatchQueuedHierarchicalResume(). 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 with LostSwarmLeaseException and 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(), and recoverableWaitingJoins() on DatabaseDurableRunStore fetched a bounded candidate list and then called find() once per row, and each find() re-queried the main row plus the swarm_durable_run_state and swarm_durable_node_states side tables (~3 queries × N, and recoverableWaitingJoins() 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 candidate run_ids in one pass each, so recoverable()/dueRetries() issue a constant 3 queries and recoverableWaitingJoins() 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 shared hydrateRunRecord() that both find() and the sweeps call, so the batch path can never drift from the per-record output — the returned arrays (including the node_states map 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 stale bootstrap/app.php and app/Console/Kernel.php references from docs/durable-execution.md, docs/configuration.md, and docs/execution-modes.md so copying the relay/recover/prune schedule into the wrong file can no longer leave the scheduler silently non-running. (#218)