Releases: duncanita/ruby-dag
v1.6.0 — project-review hardening
Project-review hardening pass: design fixes at the storage-port seams,
DRY consolidation, performance work, and CI/cop enforcement.
Added
DAG::Ports::EffectLedger, split out ofPorts::Storage(which now
includes it): the Dispatcher depends only on the ledger surface plus
append_event.complete_effect_succeeded/complete_effect_failed
are the canonical completion path with composed (non crash-atomic)
port defaults;thread_safe_for_dispatch?is a documented port method
defaulting tofalse.Dispatcher#tick(only_workflow_id:)forwards the V1.4 per-workflow
claim filter.DAG::Effects::DispatchAbortedError: an aborted tick no longer
discards the outcomes sibling workers already durably applied — the
error carries the partialDispatchReport(#report) and the
original exception (#cause).- Durable
:workflow_retryingevent appended atomically by
Runner#retry_workflowviaprepare_workflow_retry(event:); new
TraceRecord status:retrying. - Full-fidelity
to_h/from_hround-trip forSuccess,Failure,
Waiting,Effects::Intent,ProposedMutation,ReplacementGraph,
andGraph, withDAG::Result.from_has the deserialization entry
point (Symbol or String keys). DAG::AttemptOrder(single definition of the canonical
committed-attempt ordering),DAG::EventPublishing.publish_quietly,
DAG::Snapshotindifferent fetch,Validation.optional_node_id!.DAG::DuplicateWorkflowErrorandDAG::UnknownAttemptErrorreplace
bareArgumentErrorfor those storage states.- Executable §9.1 grep gate:
spec/r0/kernel_ai_terms_test.rb.
Changed
Ports::Storage.method_overridden?removed; extension behavior lives
in overridable port defaults instead ofMethod#ownerreflection
(which broke under decorators/proxies).list_committed_results_for_predecessorsport default raises
StaleStateErrorfor a committed predecessor with no committed
attempt instead of silently dropping itscontext_patch(the old
Runner fallback bug).- Rescued-exception payloads use one vocabulary:
error_class:
everywhere (:handler_raised,:effect_idempotency_conflict);
:handler_bad_returnusesreturned_class:. ExecutionContext#mergevalidates and copies only the patch
(canonical key collisions still rejected) instead of re-walking the
whole context per predecessor per attempt (~300x faster on large
contexts);Memory::StorageStategains per-node attempt and active
effect indexes, removing the per-execution full-ledger scans.- Retriable
Failuredocumented as immediate-retry by design; delayed
retries belong toWaiting+ effects. Single-runner invariant on the
crash-resume path documented on the port and inCONTRACT.md. - CI consolidated to one workflow (Ruby 3.4 / 4.0 / head matrix)
running withCOVERAGE=1, so the SimpleCov gate (100% line / 90%
branch) is enforced; cops tightened (Fiberbanned everywhere,
Kernel.system/spawn/forkflagged, stdlib require allowlist
trimmed to the frozen §3 list,mutation_service.rbadded to the
in-place-mutation cop scope).
v1.5.0 — mutation-testing release
Mutation-testing release (V1.5)
A focused Mutant gate over the pure
value/kernel surface, plus the equivalent-code simplifications and missing
assertions surfaced while driving the configured subjects to 100%
mutation coverage.
Added
mutant-minitestas a development dependency with a repo-local
.mutant.yml: minitest integration,libload path,dagrequire,
fail-fast execution, and an explicit matcher over the core immutable
graph/result/context/value-object subjects plusDAG::Effects::Await.rake mutant:test(verifies discovery + suite execution),
rake mutant:run(focused mutation gate), andrake mutant:changed
(scopes Mutant to subjects changed sinceMUTANT_SINCE, defaultmain).test/all_test.rbbridges Mutant's defaulttest/**/*_test.rb
discovery to this repo'sspec/**/*_test.rblayout;
mutant/minitest/coveragehooks andcoverdeclarations connect
tests to the configured subjects.- Additional assertions pinning graph normalization, frozen-state
behavior, metadata cleanup, DOT/hash ordering, path edge cases,
execution-context boundaries, effect-await status mapping, and public
error messages.
Changed
- Simplified equivalent code paths exposed by mutation testing without
changing the public contract: graph traversal/path helpers avoid
redundant lookups/reversals,ExecutionContext#inspectrelies on the
public key list,Graph::Validatordrops an unnecessary empty-graph
guard, andDAG::Effects::Awaitlets existing defaults handle absent
snapshots and terminal-failure retryability. .gitignoreexcludes Mutant session artifacts under/.mutant/.
Also included
fix: validate storage events before state changesFix audit findings
Gate results
- Mutant: 102 subjects, 3295 mutations, 3295 kills, 0 alive, 100.00% coverage.
- Full suite: 724 runs, 41009 assertions, 0 failures.
- standardrb + custom DAG cops: 155 files, 0 offenses. YARD 99.16% documented.
v1.4.0 — per-workflow effect claim scoping
Per-workflow effect claim scoping (V1.4)
Adds the optional only_workflow_id: kwarg to Storage#claim_ready_effects.
When non-nil, the claim is restricted to effects that have at least one
attempt-effect link belonging to the given workflow. This matches the
kernel's idempotency model: a single effect record can be shared across
workflows when two attempts reserve the same (type, key) with the same
fingerprint, so the filter resolves "effects this workflow is currently
waiting on" — not "effects this workflow created first". Default nil
preserves the V1.3 global-claim behaviour byte-identical.
# V1.3 path, unchanged
storage.claim_ready_effects(
limit: N, owner_id: "worker-1", lease_ms: 30_000, now_ms: t
)
# V1.4: scoped to one workflow's currently-linked effects
storage.claim_ready_effects(
limit: N, owner_id: "worker-1", lease_ms: 30_000, now_ms: t,
only_workflow_id: "wf-abc-123"
)Added
Ports::Storage#claim_ready_effects(only_workflow_id: nil). A workflow
with no linked effects yields an empty array (not an error). The CAS
guards on:reserved/:dispatching/ lease ownership inside the
claim loop are unchanged.Memory::StorageandMemory::StorageStateforward and apply the
predicate via the existingeffect_attempt_linksreverse index in
O(links per effect).DAG::Validation.optional_string!helper, parallel to
optional_integer!/optional_hash!/optional_instance!.- Six contract tests in
DAG::Testing::StorageContract::Effects:
attempt-linked scoping, shared-record visibility, default global,
explicit nil global, unknown id returning empty, and type validation.
Changed
- Adapters that re-implement
claim_ready_effectsoutside the gem must
add theonly_workflow_id:kwarg before bumping their ruby-dag pin
to~> 1.4. The four contract tests surface the missing kwarg as
ArgumentError: unknown keyword: :only_workflow_id.
Closes #186.
v1.3.0 — bounded intra-tick parallel dispatch
V1.3 introduces bounded intra-tick parallel dispatch so consumers can
fan out N co-eligible effects (e.g. tool calls and LLM calls in a
single workflow) instead of paying N × per_record wall time. The
opt-in is a single additive kwarg; the default is identical to V1.2.
Added
DAG::Effects::Dispatcher.new(parallelism: 1)(default) preserves
the V1.2 serial contract bit-identical. Values> 1claim records
as before but dispatch them through a bounded worker pool of at
mostparallelismThread.newworkers in flight regardless of
batch size. The pool reads from aQueueof[record, slot_index]
pairs and writes outcomes into a pre-allocatedArray.new(N)at
the matching slot, sosucceeded.map(&:id)is a subsequence of
claimed.map(&:id)in original order. Unexpected exceptions raised
inside a worker thread propagate out of#tickonly after every
worker has joined: each worker rescues every exception, parks it
in its own slot of a per-pool error array (slot-indexed writes
with no contention), drains the work queue so peer workers see
ThreadErroron their nextpopand exit (peers still finish
whatever record they were already processing), and exits its loop
normally. Afterworkers.each(&:join)returns,#tickraises the
first captured exception. Synchronization rides onQueue's
built-in thread-safety plus per-worker error slots; no contended
shared mutable state. This guarantees no worker is still mutating
storage when#tickraises and preserves the V1.2 serial-map
exception semantics where the caller observes a stable post-tick
state.parallelism > 1requires the storage adapter to declare
thread-safety by implementing#thread_safe_for_dispatch?returning
truthy. Adapters that do not declare it cause
Dispatcher.new(..., parallelism: > 1)to raiseArgumentError,
surfacing the contract mismatch at construction instead of at
runtime.DAG::Adapters::Memory::Storageis single-process by
Roadmap §2.4 and intentionally does not declare it; durable
adapters (typically SQLite-backed) bind every dispatcher-touched
method to a transaction and declare it explicitly.
Changed
- Roadmap v3.4 §2.4 / §9.1 carve out a single V1.3+ exception against
two cop gates so bounded parallel dispatch can be implemented inside
the kernel:Dag/NoThreadOrRactorletslib/dag/effects/dispatcher.rbuse
Thread.newandQueuefor the worker pool.Thread.startand
Thread.forkstay banned even in this file: bounded
parallel_mapdoes not need them, and keeping them blocked closes
the gap between the documented carve-out and the cop allow-list.Dag/NoInPlaceMutationlets the same file use<<,pop, and
[]=for queue feed, worker drain, and slot-indexed result
writes.
Both cops use the samedispatcher_relaxed_file?predicate.
Mutex,Monitor,SizedQueue,ConditionVariable,Fiber,
Process.fork/spawn/daemon,Ractor, and the other mutating
ops (merge!,update,delete,clear,shift,push)
remain banned even in the dispatcher. Tests in
spec/r0/rubocop_cops_test.rbcover both cops' allow-lists
(Thread/Queue and<</pop/[]=in the dispatcher) and the
still-banned cases (Mutex andmerge!in the dispatcher; Thread in
the runner;<<in otherlib/dag/effects/**files). Rationale: the
Dispatcher is already an I/O-bound boundary and already
non-deterministic by design (handlers complete in network/LLM/disk
order), so allowing intra-tick parallelism does not move the §2.1
Determinism pillar — Runner, Memory adapters, and every other
lib/dag/**file remain single-threaded and pure-value. The
parallelism:kwarg itself ships with the V1.3 feature release;
this changelog entry documents only the governance carve-out it
depends on.CONTRACT.mdgains a "Dispatcher Concurrency Contract"
subsection covering storage and handler thread-safety
responsibilities and theMemory::Storage+parallelism > 1
ArgumentErrorrule.
Added
DAG::Event::TYPESgains:effect_dispatch_stale_lease, emitted by
DAG::Effects::Dispatcherwhen a handler returns but the storage lease
has already expired and the completion mark cannot be applied. The
dispatcher appends the event durably viastorage.append_eventbefore
recording the in-memoryDispatchReport#errorsentry, so the failure
mode is visible in the workflow event log instead of having to be
reconstructed from process-local state. Payload carries
code: :stale_lease,effect_id,ref,type,lease_owner,
lease_until_ms, andmessage.DAG::TraceRecord::STATUSESgains:effect_dispatch_stale_leaseand
the matching entry inEVENT_STATUS, mirroring the
mutation_applied1:1 mapping pattern so trace consumers can render
the diagnostic without confusing it with a node-level failure.
Changed
DAG::Effects::Dispatchernow requires the storage adapter to
implementappend_event. The Memory adapter already does;
validate_storage!is updated to reject adapters that don't.
v1.2.0 — cooperative effect lease renewal
V1.2 extends the effect-aware storage contract with cooperative lease renewal so dispatchers can keep their default lease_ms short (fast worker-death recovery) while letting legitimately long-running handlers extend their own claim. Originated from a Delphi-side retry-storm trace (workflows 7134e4d6 and b540702c, 2026-05-06) where a 30s default lease_ms was shorter than legitimate LLM handler runtime (~110s), causing repeated re-claims and duplicate paid external work.
Added
DAG::Ports::Storage#renew_effect_lease(effect_id:, owner_id:, until_ms:, now_ms:)cooperatively extends the lease of an effect currently held byowner_id. Same lease CAS asmark_effect_*(status:dispatching, owner match, non-expired lease); updateslease_until_msandupdated_at_msatomically. Renewal is monotonic:until_msmust exceednow_msand not shrink the currentlease_until_ms(ArgumentErrorotherwise).until_ms == lease_until_msis a no-op success. A stale, foreign, or non-:dispatchinglease raisesDAG::Effects::StaleLeaseError.DAG::Adapters::Memory::Storageimplementsrenew_effect_lease.DAG::Testing::StorageContract::Effectsextends G6 with renewal coverage: success, idempotency on equaluntil_ms, wrong owner, expired lease, unclaimed effect, unknown effect, and rejection of bothuntil_ms <= now_msand shrinkinguntil_ms.- API-stability guard tests for release documentation, runtime profile compatibility, and legacy mutation storage adapters.
Changed
- Runtime profile, memory storage, and event values are hardened against mutable workflow, attempt, and event-bus-kind inputs without changing public API signatures.
- Effect key examples avoid
:inside consumer-ownedtypeandkeyparts so thetype:keyrecord identity stays unambiguous; stale execution-plan wording is preserved as historical reference.
Out of scope (next)
- Consumer-side heartbeat helper /
HandlerContext/LeaseRenewer— stays in the consumer host until we see how Delphi consumes the primitive directly. - Diagnostic event
:effect_dispatch_stale_lease— deferred to a separate PR; the dispatcher does not currently inject anevent_bus, so emitting a durable event needs its own design pass before extendingEvent::TYPES.
Compatibility
V1.2 is purely additive. Adapters built against V1.1 keep working; calling renew_effect_lease on an adapter that has not implemented it raises PortNotImplementedError. Consumers that do not call the new method are unchanged.
Full Changelog: v1.1.0...v1.2.0
ruby-dag v1.0.0
ruby-dag v1.0.0
First stable release of the deterministic Ruby DAG kernel.
Highlights:
- Kernel v1.0 complete: pure DAG, immutable workflow definitions, frozen dependency-injected runner, durable event log, resume, workflow retry, and structural mutation.
- Effect-aware kernel contract: abstract effect intents,
proposed_effectsonSuccess/Waiting, effect ledger storage API, lease-aware dispatcher, and deterministic effect snapshots. - Zero runtime dependencies; Ruby >= 3.4.
- Memory adapters are single-process and fully covered by the shared storage contract suite.
- Durable SQLite adapters live outside this zero-dependency gem and implement the public
DAG::Ports::Storagecontract. - The GitHub release includes the built gem artifact; it has not been published to RubyGems.
Verification:
bundle exec rakeSee CHANGELOG.md for the full release notes.