Module: Silas::Ledger

Defined in:
lib/silas/ledger.rb

Overview

The three-state tool executor — the exactly-once machinery the spike proved.

settle!(step, resolver:) walks the step's invocations in creation order and drives each to a terminal state. Returns :completed when every invocation settled, or :parked when one is awaiting a human (approval or in-doubt).

State machine per invocation:

pending  --approval fires--> pending + approval_state=required  (PARK)
pending  --transactional-->  completed        (tool + row in ONE txn)
pending  --at_most_once/idempotent--> started --> completed
started  --found on replay, at_most_once--> in_doubt            (PARK)
started  --found on replay, idempotent-->  re-run --> completed
completed --> no-op (replay feeds the persisted result forward)

Concurrency: claims are compare-and-swap UPDATEs (status='pending' in the WHERE clause), so two executions racing the same invocation cannot both run the tool. The unique (step_id, tool_call_id) index backstops row creation.

Constant Summary collapse

GUARD_KEY =
:silas_ledger_transaction

Class Method Summary collapse

Class Method Details

.assert_no_checkpoint!Object



34
35
36
37
38
39
40
41
# File 'lib/silas/ledger.rb', line 34

def assert_no_checkpoint!
  return unless in_transaction?

  raise CheckpointInLedgerError,
        "A continuation checkpoint was attempted inside a ledger transaction. " \
        "Checkpoints raise Interrupt, which would roll back tool side effects " \
        "the continuation believes are committed."
end

.execute_invocation!(invocation, resolver:) ⇒ Object

Drive a SINGLE freshly-created invocation to a terminal state — the hosted MCP endpoint (Mcp::Handler) creates one invocation per tools/call and needs exactly the same exactly-once/effect-mode machinery as settle!. Returns :done or :parked; the invocation carries its .result.



62
63
64
# File 'lib/silas/ledger.rb', line 62

def execute_invocation!(invocation, resolver:)
  settle_invocation!(invocation, resolver)
end

.in_transaction?Boolean

True while a ledger transaction is open in this execution context. A continuation checkpoint inside would raise Interrupt and roll back committed-looking progress (spike finding #5) — AgentLoopJob asserts against this. Stored in IsolatedExecutionState (not Thread.current, which is fiber-local) so the guard follows the app's configured isolation level, exactly like Silas.current_scope — under the default :thread isolation it survives into internally-created fibers (enumerators, streaming bodies) where a fiber-local flag would silently vanish.

Returns:

  • (Boolean)


32
# File 'lib/silas/ledger.rb', line 32

def in_transaction? = ActiveSupport::IsolatedExecutionState[GUARD_KEY] == true

.settle!(step, resolver:) ⇒ Object

Settle EVERY invocation in the step, then report whether the turn must park. When the model emits parallel tool calls, an ungated one must not be blocked by a gated sibling's approval — and it wouldn't be protected anyway: an unsettled sibling executes on resume regardless of whether the human approves or declines the gated call, so stopping at the first park only delays independent work. Execute what can proceed; park if any invocation is awaiting a human.



50
51
52
53
54
55
56
# File 'lib/silas/ledger.rb', line 50

def settle!(step, resolver:)
  parked = false
  step.tool_invocations.order(:id).each do |invocation|
    parked = true if settle_invocation!(invocation, resolver) == :parked
  end
  parked ? :parked : :completed
end