Skip to content

Releases: duncanita/ruby-dag

v1.6.0 — project-review hardening

10 Jun 11:28
c759dfa

Choose a tag to compare

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 of Ports::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 to false.
  • 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 partial DispatchReport (#report) and the
    original exception (#cause).
  • Durable :workflow_retrying event appended atomically by
    Runner#retry_workflow via prepare_workflow_retry(event:); new
    TraceRecord status :retrying.
  • Full-fidelity to_h / from_h round-trip for Success, Failure,
    Waiting, Effects::Intent, ProposedMutation, ReplacementGraph,
    and Graph, with DAG::Result.from_h as the deserialization entry
    point (Symbol or String keys).
  • DAG::AttemptOrder (single definition of the canonical
    committed-attempt ordering), DAG::EventPublishing.publish_quietly,
    DAG::Snapshot indifferent fetch, Validation.optional_node_id!.
  • DAG::DuplicateWorkflowError and DAG::UnknownAttemptError replace
    bare ArgumentError for 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 of Method#owner reflection
    (which broke under decorators/proxies).
  • list_committed_results_for_predecessors port default raises
    StaleStateError for a committed predecessor with no committed
    attempt instead of silently dropping its context_patch (the old
    Runner fallback bug).
  • Rescued-exception payloads use one vocabulary: error_class:
    everywhere (:handler_raised, :effect_idempotency_conflict);
    :handler_bad_return uses returned_class:.
  • ExecutionContext#merge validates 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::StorageState gains per-node attempt and active
    effect indexes, removing the per-execution full-ledger scans.
  • Retriable Failure documented as immediate-retry by design; delayed
    retries belong to Waiting + effects. Single-runner invariant on the
    crash-resume path documented on the port and in CONTRACT.md.
  • CI consolidated to one workflow (Ruby 3.4 / 4.0 / head matrix)
    running with COVERAGE=1, so the SimpleCov gate (100% line / 90%
    branch) is enforced; cops tightened (Fiber banned everywhere,
    Kernel.system/spawn/fork flagged, stdlib require allowlist
    trimmed to the frozen §3 list, mutation_service.rb added to the
    in-place-mutation cop scope).

v1.5.0 — mutation-testing release

31 May 08:31
b5051db

Choose a tag to compare

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-minitest as a development dependency with a repo-local
    .mutant.yml: minitest integration, lib load path, dag require,
    fail-fast execution, and an explicit matcher over the core immutable
    graph/result/context/value-object subjects plus DAG::Effects::Await.
  • rake mutant:test (verifies discovery + suite execution),
    rake mutant:run (focused mutation gate), and rake mutant:changed
    (scopes Mutant to subjects changed since MUTANT_SINCE, default main).
  • test/all_test.rb bridges Mutant's default test/**/*_test.rb
    discovery to this repo's spec/**/*_test.rb layout;
    mutant/minitest/coverage hooks and cover declarations 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#inspect relies on the
    public key list, Graph::Validator drops an unnecessary empty-graph
    guard, and DAG::Effects::Await lets existing defaults handle absent
    snapshots and terminal-failure retryability.
  • .gitignore excludes Mutant session artifacts under /.mutant/.

Also included

  • fix: validate storage events before state changes
  • Fix 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

11 May 19:25
592ac3d

Choose a tag to compare

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::Storage and Memory::StorageState forward and apply the
    predicate via the existing effect_attempt_links reverse 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_effects outside the gem must
    add the only_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

08 May 10:51
4ba6cc3

Choose a tag to compare

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 > 1 claim records
    as before but dispatch them through a bounded worker pool of at
    most parallelism Thread.new workers in flight regardless of
    batch size. The pool reads from a Queue of [record, slot_index]
    pairs and writes outcomes into a pre-allocated Array.new(N) at
    the matching slot, so succeeded.map(&:id) is a subsequence of
    claimed.map(&:id) in original order. Unexpected exceptions raised
    inside a worker thread propagate out of #tick only 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
    ThreadError on their next pop and exit (peers still finish
    whatever record they were already processing), and exits its loop
    normally. After workers.each(&:join) returns, #tick raises the
    first captured exception. Synchronization rides on Queue's
    built-in thread-safety plus per-worker error slots; no contended
    shared mutable state. This guarantees no worker is still mutating
    storage when #tick raises and preserves the V1.2 serial-map
    exception semantics where the caller observes a stable post-tick
    state.
  • parallelism > 1 requires 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 raise ArgumentError,
    surfacing the contract mismatch at construction instead of at
    runtime. DAG::Adapters::Memory::Storage is 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/NoThreadOrRactor lets lib/dag/effects/dispatcher.rb use
      Thread.new and Queue for the worker pool. Thread.start and
      Thread.fork stay banned even in this file: bounded
      parallel_map does not need them, and keeping them blocked closes
      the gap between the documented carve-out and the cop allow-list.
    • Dag/NoInPlaceMutation lets the same file use <<, pop, and
      []= for queue feed, worker drain, and slot-indexed result
      writes.
      Both cops use the same dispatcher_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.rb cover both cops' allow-lists
      (Thread/Queue and <</pop/[]= in the dispatcher) and the
      still-banned cases (Mutex and merge! in the dispatcher; Thread in
      the runner; << in other lib/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.md gains a "Dispatcher Concurrency Contract"
      subsection covering storage and handler thread-safety
      responsibilities and the Memory::Storage + parallelism > 1
      ArgumentError rule.

Added

  • DAG::Event::TYPES gains :effect_dispatch_stale_lease, emitted by
    DAG::Effects::Dispatcher when a handler returns but the storage lease
    has already expired and the completion mark cannot be applied. The
    dispatcher appends the event durably via storage.append_event before
    recording the in-memory DispatchReport#errors entry, 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, and message.
  • DAG::TraceRecord::STATUSES gains :effect_dispatch_stale_lease and
    the matching entry in EVENT_STATUS, mirroring the
    mutation_applied 1:1 mapping pattern so trace consumers can render
    the diagnostic without confusing it with a node-level failure.

Changed

  • DAG::Effects::Dispatcher now requires the storage adapter to
    implement append_event. The Memory adapter already does;
    validate_storage! is updated to reject adapters that don't.

v1.2.0 — cooperative effect lease renewal

07 May 09:01
8428487

Choose a tag to compare

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 by owner_id. Same lease CAS as mark_effect_* (status :dispatching, owner match, non-expired lease); updates lease_until_ms and updated_at_ms atomically. Renewal is monotonic: until_ms must exceed now_ms and not shrink the current lease_until_ms (ArgumentError otherwise). until_ms == lease_until_ms is a no-op success. A stale, foreign, or non-:dispatching lease raises DAG::Effects::StaleLeaseError.
  • DAG::Adapters::Memory::Storage implements renew_effect_lease.
  • DAG::Testing::StorageContract::Effects extends G6 with renewal coverage: success, idempotency on equal until_ms, wrong owner, expired lease, unclaimed effect, unknown effect, and rejection of both until_ms <= now_ms and shrinking until_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-owned type and key parts so the type:key record 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 an event_bus, so emitting a durable event needs its own design pass before extending Event::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

01 May 12:03

Choose a tag to compare

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_effects on Success / 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::Storage contract.
  • The GitHub release includes the built gem artifact; it has not been published to RubyGems.

Verification:

bundle exec rake

See CHANGELOG.md for the full release notes.