Class: Agent::Lock::Record

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

Parameters:

Returns:



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.

Returns:

  • (String)


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?

Parameters:

  • text (String) —

    a lock document, from a file or from Redis

Returns:



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.

Parameters:

  • path (String)

Returns:

  • (Record, nil) —

    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.

Returns:

  • (Boolean) —

    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.

Returns:

  • (Boolean) —

    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.

Parameters:

Returns:

  • (Boolean)


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.

Parameters:

  • minutes (Integer) —

    how long an unverifiable lock is trusted

Returns:

  • (Boolean)


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.

Parameters:

Returns:

  • (Boolean)


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.

Parameters:

  • text (String)

Returns:

  • (Record) —

    a copy carrying the note



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.

Returns:

  • (Boolean) —

    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.

Returns:

  • (Boolean) —

    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

Returns:



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.

Parameters:

  • minutes (Integer)

Returns:

  • (Boolean)


184
# File 'lib/agent/lock/record.rb', line 184

def stale?(minutes) = active? && untouched_for?(minutes)

#summary ⇒ String

Returns one line, for a listing.

Returns:

  • (String) —

    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.

Returns:

  • (String) —

    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.

Parameters:

  • changes (Hash) —

    fields to replace

Returns:

  • (Record) —

    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