Class: Agent::Lock::Record
- Inherits:
-
Object
- Object
- Agent::Lock::Record
- Defined in:
- lib/agent/lock/record.rb
Overview
One lock, on disk.
A markdown file with YAML front matter, rather than plain YAML, because the interesting half of a lock is the sentence saying what the holder is doing. An agent that finds a file locked can read that and decide whether to wait or to work elsewhere; "occupied" tells it nothing. A human opening the file in an editor sees the same thing.
---
agent_id: claude-9feca100
scope: workflow/**
pid: 69232
---
Rewriting the installer's filter pair.
Constant Summary collapse
- FIELDS =
%i[id agent_id parent_agent_id scope tree worktree pid started host created_at updated_at status frozen_paths].freeze
- ACTIVE =
"active"- ORPHANED =
"orphaned"- SEPARATOR =
"---"- NOTES_HEADING =
"## Progress"
Class Method Summary collapse
- .build(scope:, tree:, identity:, intent:) ⇒ Record
-
.id_for(tree, scope) ⇒ String
Derived from the tree and the scope so that a second run looking for the same lock finds it without reading every file in the store.
- .parse(text, path: nil) ⇒ Record?
-
.read(path) ⇒ Record?
Nil for a file this gem did not write.
Instance Method Summary collapse
-
#active? ⇒ Boolean
A claim anybody has to respect.
-
#alive? ⇒ Boolean
The holder's process is still running, on this host.
-
#blocks?(identity) ⇒ Boolean
Blocking answers "may I claim an overlapping scope": everything except my own lock and my parent's.
-
#expired?(minutes) ⇒ Boolean
Whether the claim is void, so reaping it takes nothing from anybody.
-
#held_by?(identity) ⇒ Boolean
Two different questions, deliberately not one.
-
#initialize(intent: "", path: nil, **fields) ⇒ Record
constructor
A new instance of Record.
-
#note(text) ⇒ Record
What the holder has written down since taking the lock.
-
#notes? ⇒ Boolean
Whether anything worth keeping was written down.
-
#orphaned? ⇒ Boolean
The holder is gone, but the work it recorded is not.
- #scope_object ⇒ Scope
-
#stale?(minutes) ⇒ Boolean
A claim still standing, whose holder has not touched it in longer than anybody should need.
-
#summary ⇒ String
One line, for a listing.
-
#to_markdown ⇒ String
The file's whole content.
-
#with(intent: self.intent, **changes) ⇒ Record
A copy, since a record on disk is not edited in place.
Constructor Details
#initialize(intent: "", path: nil, **fields) ⇒ Record
Returns a new instance of Record.
84 85 86 87 88 89 |
# File 'lib/agent/lock/record.rb', line 84 def initialize(intent: "", path: nil, **fields) FIELDS.each { |field| instance_variable_set(:"@#{field}", fields[field]) } @frozen_paths = Array(@frozen_paths) @intent = intent.to_s @path = path end |
Class Method Details
.build(scope:, tree:, identity:, intent:) ⇒ Record
44 45 46 47 48 49 50 51 52 |
# File 'lib/agent/lock/record.rb', line 44 def build(scope:, tree:, identity:, intent:) evidence = identity.evidence new( id: id_for(tree, scope), agent_id: identity.id, parent_agent_id: identity.parent_id, scope: scope.to_s, tree: tree.root, worktree: tree.worktree?, pid: evidence[:pid], started: evidence[:started], host: evidence[:host], created_at: Time.now.utc.iso8601, status: ACTIVE, frozen_paths: [], intent: intent ) end |
.id_for(tree, scope) ⇒ String
Derived from the tree and the scope so that a second run looking for the same lock finds it without reading every file in the store.
58 59 60 61 |
# File 'lib/agent/lock/record.rb', line 58 def id_for(tree, scope) digest = Digest::SHA256.hexdigest("#{tree.root}\0#{scope}")[0, 8] "#{scope.slug}-#{digest}" end |
.parse(text, path: nil) ⇒ Record?
73 74 75 76 77 78 79 80 81 |
# File 'lib/agent/lock/record.rb', line 73 def parse(text, path: nil) _, front, body = text.to_s.split(/^#{SEPARATOR}\s*$/, 3) data = YAML.safe_load(front.to_s, permitted_classes: [], aliases: false) return nil unless data.is_a?(Hash) new(**data.transform_keys(&:to_sym).slice(*FIELDS), intent: body.to_s.strip, path: path) rescue Psych::Exception, ArgumentError nil end |
.read(path) ⇒ Record?
Returns nil for a file this gem did not write.
65 66 67 68 69 |
# File 'lib/agent/lock/record.rb', line 65 def read(path) parse(File.read(path), path: path) rescue Errno::ENOENT, Errno::EISDIR nil end |
Instance Method Details
#active? ⇒ Boolean
Returns a claim anybody has to respect. An orphaned lock is a message left for whoever comes next, not a claim, so it blocks nobody.
109 |
# File 'lib/agent/lock/record.rb', line 109 def active? = !orphaned? |
#alive? ⇒ Boolean
Returns the holder's process is still running, on this host.
158 159 160 161 162 |
# File 'lib/agent/lock/record.rb', line 158 def alive? return true unless same_host? ProcessInfo.alive?(pid, started: started) end |
#blocks?(identity) ⇒ Boolean
Blocking answers "may I claim an overlapping scope": everything except my own lock and my parent's. The one that matters is the sibling: two sub-agents of one session, let loose in the same tree, are exactly the pair this gem exists to keep apart, and treating the whole family as one holder would let them write over each other freely.
A child's lock blocks its parent too. The parent handed that scope out; taking it back while the child is still in there is the same collision from the other direction.
155 |
# File 'lib/agent/lock/record.rb', line 155 def blocks?(identity) = !(mine?(identity) || ancestor_of?(identity)) |
#expired?(minutes) ⇒ Boolean
Whether the claim is void, so reaping it takes nothing from anybody.
On this host the holder can be asked, and its answer is the only one
that counts: a live holder's lock is never expired, however old. Age
used to count here too, measured from created_at, so a session two
hours into a refactor lost its lock mid-edit and the next agent walked
straight in. A holder on another host cannot be asked, so time is all
there is, measured from its last write so that notes act as a
heartbeat.
176 |
# File 'lib/agent/lock/record.rb', line 176 def expired?(minutes) = same_host? ? !alive? : untouched_for?(minutes) |
#held_by?(identity) ⇒ Boolean
Two different questions, deliberately not one.
Ownership answers "may I release this, write notes in it, and does
mine list it": yes for my own locks and my sub-agents', never for my
parent's. A session cleaning up after itself has to be able to take
its children's locks with it, or a crashed sub-agent's claim outlives
everybody. The reverse is how a child's release-all used to drop the
umbrella its parent had just fanned out under.
141 |
# File 'lib/agent/lock/record.rb', line 141 def held_by?(identity) = mine?(identity) || descendant_of?(identity) |
#note(text) ⇒ Record
What the holder has written down since taking the lock.
A lock outlives a reboot; the session that took it does not. Notes are kept in the lock itself, with the same lifespan, so that coming back to a tree after a crash is reading one file rather than guessing.
119 120 121 122 |
# File 'lib/agent/lock/record.rb', line 119 def note(text) body = notes? ? intent : "#{intent}\n\n#{NOTES_HEADING}" with(intent: "#{body}\n- #{Time.now.utc.iso8601} #{text.strip}", updated_at: Time.now.utc.iso8601) end |
#notes? ⇒ Boolean
Returns whether anything worth keeping was written down.
125 |
# File 'lib/agent/lock/record.rb', line 125 def notes? = intent.include?(NOTES_HEADING) |
#orphaned? ⇒ Boolean
Returns the holder is gone, but the work it recorded is not.
105 |
# File 'lib/agent/lock/record.rb', line 105 def orphaned? = status == ORPHANED |
#scope_object ⇒ Scope
128 |
# File 'lib/agent/lock/record.rb', line 128 def scope_object = @scope_object ||= Scope.new(scope) |
#stale?(minutes) ⇒ Boolean
A claim still standing, whose holder has not touched it in longer than anybody should need. Reported, never acted on: the holder may be alive and simply slow, so breaking it is a decision somebody announces.
184 |
# File 'lib/agent/lock/record.rb', line 184 def stale?(minutes) = active? && untouched_for?(minutes) |
#summary ⇒ String
Returns one line, for a listing.
187 188 189 190 |
# File 'lib/agent/lock/record.rb', line 187 def summary where = worktree ? "#{tree} (worktree)" : tree "#{scope}\t#{agent_id}\t#{created_at}\t#{where}" end |
#to_markdown ⇒ String
Returns the file's whole content.
99 100 101 102 |
# File 'lib/agent/lock/record.rb', line 99 def to_markdown front = FIELDS.to_h { |field| [field.to_s, public_send(field)] }.compact "#{YAML.dump(front)}#{SEPARATOR}\n\n#{intent.strip}\n" end |
#with(intent: self.intent, **changes) ⇒ Record
Returns a copy, since a record on disk is not edited in place.
93 94 95 96 |
# File 'lib/agent/lock/record.rb', line 93 def with(intent: self.intent, **changes) fields = FIELDS.to_h { |field| [field, public_send(field)] } self.class.new(**fields, **changes, intent: intent, path: path) end |