Class: Agentilda::Runner

Inherits:
Object
  • Object
show all
Defined in:
lib/agentilda/runner.rb

Overview

Drives specialist agents over a .plans tree until it stops changing.

The hard part of any agent loop is knowing when to stop, and this one does not have to guess: the state machine already defines "satisfied". A round advances plans; the loop ends at a FIXED POINT — a round in which no plan changed state — or when nothing is left that an agent may touch.

Blocked plans are not failures and not work. ⭕️ and 🅱️ mean a human must decide, so the loop reports them and steps around them. An agent that could move them would make the states meaningless.

Defined Under Namespace

Classes: Attempt, Round, Task

Constant Summary collapse

FAILURE =

How a task's return value reads on its spinner line: any hop that was not ok makes the whole line a failure, shown with that hop's note. The executor reports failure by returning rather than raising, and a line that drew ✓ "done" over a timed-out agent — directly above a round table saying FAIL — was the contradiction this closes.

->(result) { result.find { |a| !a.ok }&.note if result.is_a?(Array) }
DRY_ROUNDS =

Rounds with no movement before the loop concedes. One is not enough: an agent can legitimately spend a round writing something another agent needs before either can advance.

2
MAX_CHAIN_HOPS =

The most agents one task will chain through in a single round. The pipeline is shorter than this, so hitting the cap means states are cycling, and a cap beats a loop.

6

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(tree:, executor:, agents: Agents.new, max_rounds: 10, isolation: :shared, jobs: 1, worktree: nil, plans: nil, publisher: nil, dry_run: false, chain: false) ⇒ Runner

Returns a new instance of Runner.

Parameters:

  • tree (Agentilda::Tree)
  • executor (#call)

    receives (agent, subject) and returns [ok, note]

  • agents (Agentilda::Agents) (defaults to: Agents.new)
  • max_rounds (Integer) (defaults to: 10)

    a hard ceiling, so a loop cannot run forever

  • isolation (Symbol) (defaults to: :shared)

    :worktree gives each plan its own checkout and branch; :shared runs every agent against one tree, which is only safe serially

  • jobs (Integer) (defaults to: 1)

    how many agents run at once

  • plans (Array<Agentilda::Ordinal>, nil) (defaults to: nil)

    restrict the loop to these plans; nil (the default) is the whole tree

  • publisher (Agentilda::Publisher, nil) (defaults to: nil)

    pushes a finished worktree and opens its pull request as soon as one lands, rather than once at the very end of the whole loop. nil (the default) never pushes anything — the caller's opt-out.

  • dry_run (Boolean) (defaults to: false)

    no agent is invoked, so the per-round resync must not rename anything either — a preview that moves folders is not a preview.

  • chain (Boolean) (defaults to: false)

    when an agent finishes and the plan's CONTENTS now justify the next state, hand the plan straight to that state's agent in the same round — researcher to writer to planner — instead of paying a full round per hop. The folder is not renamed mid-round (the serial resync still owns that); the chain reads Subject#best_fit afresh, which needs no rename. Off when the caller restricted the run to one agent, since chaining past the restriction would un-restrict it.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/agentilda/runner.rb', line 127

def initialize(tree:, executor:, agents: Agents.new, max_rounds: 10,
  isolation: :shared, jobs: 1, worktree: nil, plans: nil, publisher: nil,
  dry_run: false, chain: false)
  @tree = tree
  @executor = executor
  @agents = agents
  @max_rounds = max_rounds
  @isolation = isolation
  @worktree = worktree
  @plans = plans
  @publisher = publisher
  @dry_run = dry_run
  @chain = chain
  @rounds = []

  # Concurrency without isolation is the exact failure the worktree exists
  # to prevent: two agents editing one checkout produce no git conflict, so
  # the last writer wins silently. Refuse rather than corrupt.
  @jobs = isolated? ? jobs : 1
end

Instance Attribute Details

#jobsInteger (readonly)

Returns agents running at once.

Returns:

  • (Integer)

    agents running at once



157
158
159
# File 'lib/agentilda/runner.rb', line 157

def jobs
  @jobs
end

#roundsArray<Agentilda::Runner::Round> (readonly)

Returns:



166
167
168
# File 'lib/agentilda/runner.rb', line 166

def rounds
  @rounds
end

#treeAgentilda::Tree (readonly)

Returns:



163
164
165
# File 'lib/agentilda/runner.rb', line 163

def tree
  @tree
end

#worktreeAgentilda::Worktree? (readonly)

Returns:



160
161
162
# File 'lib/agentilda/runner.rb', line 160

def worktree
  @worktree
end

Instance Method Details

#blockedArray<Agentilda::Subject>

Plans nobody may act on, for the closing report.

Returns:



194
# File 'lib/agentilda/runner.rb', line 194

def blocked = in_scope.select { |s| %i[blocked product_blocked].include?(s.status.key) }

#callArray<Agentilda::Runner::Round>

Run until the tree stops changing.

Returns:



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/agentilda/runner.rb', line 171

def call
  dry = 0

  1.upto(@max_rounds) do |number|
    # `q` ends the loop at the next seam rather than instantly: the round
    # in flight finishes (its agents were asked to STOP and get a grace
    # period to write out), and no new round starts.
    break if Control.quit?

    round = run_round(number)
    @rounds << round
    break if round.attempts.empty?

    dry = round.dry? ? dry + 1 : 0
    break if dry >= DRY_ROUNDS
  end

  @rounds
end

#in_scope?(subject) ⇒ Boolean

Returns whether this run's scope covers this plan at all — --plan restricts it; with no --plan every plan is in scope.

Parameters:

Returns:

  • (Boolean)

    whether this run's scope covers this plan at all — --plan restricts it; with no --plan every plan is in scope



154
# File 'lib/agentilda/runner.rb', line 154

def in_scope?(subject) = @plans.nil? || @plans.include?(subject.feature.ordinal)

#isolated?Boolean

Returns:

  • (Boolean)


149
# File 'lib/agentilda/runner.rb', line 149

def isolated? = @isolation == :worktree

#settled?Boolean

Returns every plan in scope is either finished or deliberately parked.

Returns:

  • (Boolean)

    every plan in scope is either finished or deliberately parked



197
198
199
# File 'lib/agentilda/runner.rb', line 197

def settled?
  in_scope.all? { |s| StateMachine::SETTLED.include?(s.status.key) }
end