Class: Agentilda::StateMachine

Inherits:
Object
  • Object
show all
Includes:
AASM
Defined in:
lib/agentilda/state_machine.rb

Overview

The state machine for one plan folder.

The whole topology is the aasm block below, and it is nowhere else. To change what may follow what, edit an event's from: list. To add a state, add it to STATUSES and give it an event. There is no second table to keep in step: StateMachine.inbound, StateMachine.outbound, StateMachine.edge? and terminal? are all read back off these declarations.

Two rules hold the rest together:

  1. The guard for entering a state is that state's own invariant. There is one guard method, #justified?, and it reads the destination off the transition in flight — so a destination is named once, in to:.
  2. The state lives in the folder name. There is no column and no database. A successful transition renames the directory, which is why firing an event is a real side effect and asking a question is not.

Defined Under Namespace

Classes: Refused

Constant Summary collapse

SPINE =

The forward path a bare #promote! walks. Everything off this spine — blocking, deferring, discarding — has to be named explicitly, which is the entire reason for having a machine rather than a rename.

🔴 rejoins at 🎨 rather than continuing: fixing review comments puts the work back through both halves of building, it does not skip the reviewer and it does not assume the half nobody complained about still holds.

{
  retroactive: :planned,
  new: :researched,
  researched: :planned,
  planned: :building,
  building: :building_ui,
  building_ui: :ready_for_review,
  ready_for_review: :in_review,
  in_review: :approved,
  approved: :deployed,
  rejected: :building_ui,
  rolled_back: :ready_for_review,
  shit: :planned
}.freeze
PREFERENCE =

Preference order when several states fit a folder's contents at once. Read top to bottom: the loudest fact wins. A discard outranks everything, a block outranks progress, and retroactive is last because it is the absence of documents rather than the presence of any.

%i[
  discarded rolled_back shit deferred blocked product_blocked
  deployed approved rejected in_review ready_for_review building planned
  researched new
  retroactive
].freeze
FAMILIES =

States whose members a folder's contents cannot tell apart. Within a family the current name always wins, because re-deriving it would be a guess dressed up as a correction.

⭕️ and 🅱️ both mean "a human must decide before this moves"; which human is recorded nowhere but the emoji.

🟡 🟢 👀 🔴 all look identical on disk — a plan.md and some open pull requests. Whether someone is still building, CI is green and a reviewer is wanted, a reviewer is reading it, or a reviewer asked for changes is not written down anywhere a program could read. So resync never moves between them; they advance by events alone.

Order matters: the first member is the weakest claim in the family, and it is where a folder arriving from outside lands. Contents that fit the family justify only its floor, never its ceiling.

[
  %i[blocked product_blocked],
  %i[building ready_for_review in_review rejected]
].freeze
SETTLED =

States the agent loop leaves alone: work that is finished (✅ 😎), work that was dropped (❌), and work waiting on a human (⭕️ 🅱️ ☢️). Everything else is fair game for a specialist.

✅ Approved is here deliberately. hansolo-reviewer advancing a plan to it is the end of the loop, not a step in it: nothing merges, and no agent handles approved. Merging is the one act in this lifecycle that changes a branch everyone else builds on, and an autonomous loop that does it unattended has no way to be wrong quietly.

Moving approved out of this list is therefore a decision about blast radius rather than about topology. If it ever moves, something has to own approved -> deployed, and today nothing does.

%i[approved deployed discarded blocked product_blocked deferred].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(subject) ⇒ StateMachine

Returns a new instance of StateMachine.

Parameters:

  • subject (#status, #file?, #read, #pull_requests, #rename_to)


240
241
242
243
# File 'lib/agentilda/state_machine.rb', line 240

def initialize(subject)
  @subject = subject
  @key = subject.status.key
end

Instance Attribute Details

#keySymbol (readonly)

Returns the state right now.

Returns:

  • (Symbol)

    the state right now



249
250
251
# File 'lib/agentilda/state_machine.rb', line 249

def key
  @key
end

#subjectObject (readonly)

Returns the plan folder this machine speaks for.

Returns:

  • (Object)

    the plan folder this machine speaks for



246
247
248
# File 'lib/agentilda/state_machine.rb', line 246

def subject
  @subject
end

Class Method Details

.edge?(from, to) ⇒ Boolean

Returns whether the topology permits this edge at all.

Parameters:

  • from (Symbol)
  • to (Symbol)

Returns:

  • (Boolean)

    whether the topology permits this edge at all



232
# File 'lib/agentilda/state_machine.rb', line 232

def edge?(from, to) = outbound(from).include?(to)

.event_forHash{Symbol => Symbol}

Destination => the event that reaches it. Every event above has exactly one to:, which is what lets --to <state> name a destination rather than making callers learn the verbs.

Returns:

  • (Hash{Symbol => Symbol})


198
199
200
201
202
# File 'lib/agentilda/state_machine.rb', line 198

def event_for
  @event_for ||= aasm.events.each_with_object({}) { |event, map|
    event.transitions.each { |t| map[t.to] ||= event.name }
  }.freeze
end

.family_of(key) ⇒ Array<Symbol>

Returns the family key belongs to, empty when it has none.

Parameters:

  • key (Symbol)

Returns:

  • (Array<Symbol>)

    the family key belongs to, empty when it has none



236
# File 'lib/agentilda/state_machine.rb', line 236

def family_of(key) = FAMILIES.find { |family| family.include?(key) } || []

.inboundHash{Symbol => Array<Symbol>}

Destination => the states that may legitimately reach it.

retroactive is absent: it is a birth state, produced by create --after for work that shipped undocumented.

Returns:

  • (Hash{Symbol => Array<Symbol>})


210
211
212
213
214
# File 'lib/agentilda/state_machine.rb', line 210

def inbound
  @inbound ||= aasm.events.each_with_object({}) { |event, map|
    event.transitions.each { |t| (map[t.to] ||= []).concat(Array(t.from)) }
  }.transform_values { |froms| froms.uniq.freeze }.freeze
end

.outbound(key) ⇒ Array<Symbol>

Parameters:

  • key (Symbol)

Returns:

  • (Array<Symbol>)


227
# File 'lib/agentilda/state_machine.rb', line 227

def outbound(key) = outbound_map.fetch(key, [])

.outbound_mapHash{Symbol => Array<Symbol>}

Source => the states it may reach.

Returns:

  • (Hash{Symbol => Array<Symbol>})


219
220
221
222
223
# File 'lib/agentilda/state_machine.rb', line 219

def outbound_map
  @outbound_map ||= inbound.each_with_object({}) { |(to, froms), map|
    froms.each { |from| (map[from] ||= []) << to }
  }.transform_values(&:freeze).freeze
end

Instance Method Details

#aasm_read_state(_name = :default) ⇒ Symbol

AASM keeps state in an ORM column; we keep it in a directory name. These three methods are the whole of that adaptation.

Returns:

  • (Symbol)


258
# File 'lib/agentilda/state_machine.rb', line 258

def aasm_read_state(_name = :default) = @key

#aasm_write_state(new_state, name = :default) ⇒ Boolean

Returns:

  • (Boolean)


261
# File 'lib/agentilda/state_machine.rb', line 261

def aasm_write_state(new_state, name = :default) = aasm_write_state_without_persistence(new_state, name)

#aasm_write_state_without_persistence(new_state, _name = :default) ⇒ Boolean

Returns:

  • (Boolean)


264
265
266
267
# File 'lib/agentilda/state_machine.rb', line 264

def aasm_write_state_without_persistence(new_state, _name = :default)
  @key = new_state
  true
end

#allowedArray<Symbol>

States reachable right now, guards applied.

Returns:

  • (Array<Symbol>)


272
# File 'lib/agentilda/state_machine.rb', line 272

def allowed = aasm.states(permitted: true).map(&:name)

#best_fitAgentilda::Status?

The state a folder's contents justify — the answer to "well, what should it be, then?". This is what resync dirs renames toward.

Invariants are minimum requirements, not exact matches: a ⚪️ folder that has since grown a plan.md still satisfies ⚪️, and is nonetheless ⭐️ now. So the furthest-justified state wins, and the caller compares it against the current one to decide whether anything should move.

The exception is a family, whose members contents cannot tell apart. Two rules cover it:

  • Already inside one — the current name wins. Re-deriving it would be a guess dressed up as a correction, and every ⭕️ would become 🅱️.
  • Arriving from outside — the family's first member wins, not the furthest. A ✅ folder found with an open pull request is demonstrably back in the PR phase; nothing shows whether a reviewer has seen it, so it lands on 🟡 rather than claiming 🔴.

Returns:



319
320
321
322
323
324
325
326
327
328
# File 'lib/agentilda/state_machine.rb', line 319

def best_fit
  fitting = STATUSES.select { |s| s.satisfied_by?(subject) }
  return nil if fitting.empty?

  best = PREFERENCE.filter_map { |k| fitting.find { |s| s.key == k } }.first || fitting.first
  return status if self.class.family_of(key).include?(best.key)

  family = self.class.family_of(best.key)
  family.empty? ? best : STATUS_BY_KEY.fetch(family.first)
end

#may?(to) ⇒ Boolean

Returns whether that move is permitted right now.

Parameters:

  • to (Symbol)

Returns:

  • (Boolean)

    whether that move is permitted right now



276
# File 'lib/agentilda/state_machine.rb', line 276

def may?(to) = allowed.include?(to)

#promote!(to = spine_next) ⇒ Agentilda::Status

Move the folder. With no argument it walks one step along the SPINE.

A refusal is information — it means the phase has not actually happened yet — so it arrives as Refused carrying the reason, never as false.

Parameters:

  • to (Symbol) (defaults to: spine_next)

    destination, defaulting to the next spine state

Returns:

Raises:



291
292
293
294
295
296
297
298
# File 'lib/agentilda/state_machine.rb', line 291

def promote!(to = spine_next)
  raise Refused, "#{describe(key)} is terminal — nothing follows it" if to.nil?
  raise Refused, "nothing can reach #{describe(to)}" unless self.class.event_for.key?(to)
  raise Refused, refusal(to) unless may?(to)

  aasm.fire!(self.class.event_for.fetch(to))
  status
end

#spine_nextSymbol?

The next state along the spine, whether or not its guard passes.

Returns:

  • (Symbol, nil)


281
# File 'lib/agentilda/state_machine.rb', line 281

def spine_next = SPINE[key]

#statusAgentilda::Status

Returns:



252
# File 'lib/agentilda/state_machine.rb', line 252

def status = STATUS_BY_KEY.fetch(key)