@zalom/plastic 2.0.0-alpha.19 → 2.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,254 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "node_file"
6
+ require_relative "node_ids"
7
+ require_relative "graph_edges"
8
+ require_relative "graph_file"
9
+ require_relative "atomic_write"
10
+ require_relative "ready_set"
11
+ require_relative "node_ledger"
12
+ require_relative "savepoint"
13
+ require_relative "work_graph_validator"
14
+
15
+ # RunnerProposals (intent 340, G7, n6): accepts or refuses what an executor
16
+ # proposed in its return (327 D15, D28, D30). The runner mints every node id;
17
+ # an executor never sees or picks one. A proposed node is scaffolded from its
18
+ # kind's template, its id substituted everywhere the template hard-codes its
19
+ # own placeholder (`n1`, `v1`, `d1`, `r1` - the frontmatter key, the H1, and
20
+ # the failure-mode matrix heading), and appended to graph.md's ## Graph
21
+ # section as a fresh, undispatched line - no ledger line at all, which reads
22
+ # as "planned" by construction. A proposed edge is accepted only when both
23
+ # endpoints exist (among the already-declared nodes or a node this very call
24
+ # is minting), its head is not `running`, and the resulting graph stays
25
+ # acyclic (D30); a refusal writes one ledger comment naming which of the
26
+ # three failed.
27
+ #
28
+ # One call, one all-or-nothing write (D15's "nothing is partially written"):
29
+ # every proposed node and edge is validated BEFORE anything touches disk. A
30
+ # single rejected edge refuses the WHOLE call, leaving graph.md and nodes/
31
+ # byte-identical to how #accept found them, and burning no minted id (an id
32
+ # is only ever consumed once a node FILE actually exists; NodeIds.taken never
33
+ # sees one this call abandoned).
34
+ #
35
+ # Pure and dependency-injected: the full validator re-run (327 D17's "a mid-
36
+ # run scaffold is checked, not merely accepted"), the templates directory,
37
+ # the clock and the rename call are all injectable keyword arguments with
38
+ # real defaults, so a test never touches a real ~/.plastic install.
39
+ module RunnerProposals
40
+ module_function
41
+
42
+ KIND_TEMPLATES = {
43
+ "work" => "node-work.md",
44
+ "verify" => "node-verify.md",
45
+ "decision" => "node-decision.md",
46
+ "research" => "node-research.md",
47
+ }.freeze
48
+
49
+ # accept(context, proposer:, proposed_nodes:, proposed_edges:, now:,
50
+ # validator:, templates_dir:, renamer:) -> {ok:, minted:, validator:,
51
+ # errors:}. `proposer` names the node whose return carried these proposals
52
+ # - it is used only to attribute the refusal comment; the runner itself
53
+ # decides everything about the proposal's fate.
54
+ def accept(context, proposer:, proposed_nodes: [], proposed_edges: [], now: Time.now,
55
+ validator: WorkGraphValidator.method(:validate), templates_dir: nil,
56
+ renamer: File.method(:rename))
57
+ intent_dir = context.intent_dir
58
+ graph_path = File.join(intent_dir, "graph.md")
59
+ templates_root = templates_dir || File.join(context.plastic_home.to_s, "templates")
60
+
61
+ # v2 NEW-5/row 11.8: `ReadySet.load_graph` documents itself as never
62
+ # raising, but that guarantee is only as good as the parser underneath
63
+ # it - a `graph.md` carrying invalid UTF-8 bytes raises `ArgumentError`
64
+ # out of `GraphFile`'s own fence-line scan before `load_graph` ever gets
65
+ # a chance to catch it. M4 made this call reachable straight from
66
+ # `RunnerAbsorb#absorb`, so a `--return` carrying a proposal against a
67
+ # malformed graph used to die with a raw stack trace instead of a plain
68
+ # refusal.
69
+ loaded = safe_load_graph(intent_dir)
70
+ return refuse(intent_dir, proposer, "graph.md is unreadable: #{loaded[:errors].join('; ')}", now) if loaded[:unreadable]
71
+
72
+ declared = loaded[:edges].keys
73
+ status_map = NodeLedger.status_from_content(read_savepoint(intent_dir))
74
+
75
+ mint_pool = NodeIds.taken(intent_dir).dup
76
+ node_specs = []
77
+
78
+ Array(proposed_nodes).each do |raw|
79
+ spec = stringify(raw)
80
+ kind = spec["kind"].to_s
81
+ template_name = KIND_TEMPLATES[kind]
82
+ unless template_name
83
+ return refuse(intent_dir, proposer, "proposed node kind #{kind.inspect} has no template", now)
84
+ end
85
+
86
+ template_path = File.join(templates_root, template_name)
87
+ unless File.exist?(template_path)
88
+ return refuse(intent_dir, proposer, "missing template for kind #{kind.inspect} at #{template_path}", now)
89
+ end
90
+
91
+ id = NodeFile.mint_id(kind, mint_pool)
92
+ mint_pool << id
93
+ node_specs << {
94
+ id: id, kind: kind, template_path: template_path,
95
+ needs: Array(spec["needs"]).map(&:to_s),
96
+ files: spec["files"].nil? ? nil : Array(spec["files"]),
97
+ budget: spec["budget"],
98
+ }
99
+ end
100
+
101
+ trial_edges = loaded[:edges].dup
102
+ node_specs.each { |s| trial_edges[s[:id]] = s[:needs] }
103
+ known_ids = declared + node_specs.map { |s| s[:id] }
104
+
105
+ edge_specs = []
106
+ Array(proposed_edges).each do |raw|
107
+ e = stringify(raw)
108
+ from = e["from"].to_s
109
+ to = e["to"].to_s
110
+
111
+ unless known_ids.include?(from) && known_ids.include?(to)
112
+ return refuse(intent_dir, proposer,
113
+ "edge #{from}->#{to} refused (endpoint_unknown): both ends must be a declared or proposed node", now)
114
+ end
115
+
116
+ if status_map.fetch(from, "planned") == "running"
117
+ return refuse(intent_dir, proposer,
118
+ "edge #{from}->#{to} refused (head_running): #{from} is currently running", now)
119
+ end
120
+
121
+ trial = trial_edges.dup
122
+ trial[from] = (trial[from] || []) + [to]
123
+ if GraphEdges.cycle(trial)
124
+ return refuse(intent_dir, proposer,
125
+ "edge #{from}->#{to} refused (would_cycle): would make the graph cyclic", now)
126
+ end
127
+
128
+ trial_edges = trial
129
+ edge_specs << { from: from, to: to }
130
+ end
131
+
132
+ node_specs.each { |s| scaffold_node_file(intent_dir, s) }
133
+ if node_specs.any? || edge_specs.any?
134
+ append_to_graph(graph_path, node_specs: node_specs, edge_specs: edge_specs, renamer: renamer)
135
+ end
136
+
137
+ # v2 NEW-5/row 11.8: the accept itself already landed on disk by this
138
+ # point (327 D17's "a mid-run scaffold is checked" runs strictly AFTER
139
+ # the write) - a raising validator must report a broken verdict, never
140
+ # turn an already-successful accept into an uncaught exception.
141
+ { ok: true, minted: node_specs.map { |s| s[:id] }, validator: safe_validate(validator, intent_dir), errors: [] }
142
+ end
143
+
144
+ # --- node scaffolding --------------------------------------------------
145
+
146
+ # A template's own placeholder id ("n1", "v1", ...) is replaced only as a
147
+ # whole token, so minting past 9 (a proposed "n10") never mangles a
148
+ # coincidental substring match.
149
+ def substitute_id(text, old_id, new_id)
150
+ text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
151
+ end
152
+
153
+ def scaffold_node_file(intent_dir, spec)
154
+ parsed_template = NodeFile.parse(spec[:template_path])
155
+ old_id = parsed_template[:node]
156
+ raw = File.read(spec[:template_path])
157
+ parts = raw.split("---", 3)
158
+ body = substitute_id(parts[2].to_s, old_id, spec[:id])
159
+
160
+ files = spec[:files].nil? ? parsed_template[:files] : spec[:files]
161
+ budget = spec[:budget].nil? ? parsed_template[:budget] : spec[:budget]
162
+
163
+ frontmatter = "---\nnode: #{spec[:id]}\nkind: #{spec[:kind]}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
164
+ path = File.join(intent_dir, "nodes", "#{spec[:id]}.md")
165
+ FileUtils.mkdir_p(File.dirname(path))
166
+ File.write(path, "#{frontmatter}#{body}")
167
+ end
168
+ private_class_method :scaffold_node_file
169
+
170
+ # --- graph.md editing ----------------------------------------------------
171
+
172
+ # Appends one "- <id> needs <targets>" line per minted node, then applies
173
+ # each accepted edge onto the line (existing or just-appended) whose id
174
+ # matches its `from`, through GraphFile's own public section helpers
175
+ # (section_bounds, section_body, replace_or_append_section - none of them
176
+ # private_class_method'd) so this stays a plain text edit, never a second
177
+ # bespoke parser.
178
+ def append_to_graph(graph_path, node_specs:, edge_specs:, renamer:)
179
+ content = File.read(graph_path)
180
+ bounds = GraphFile.section_bounds(content, "## Graph")
181
+ return unless bounds
182
+
183
+ body = GraphFile.section_body(content, "## Graph").to_s
184
+ lines = body.split("\n")
185
+
186
+ node_specs.each do |s|
187
+ rendered = s[:needs].empty? ? "nothing" : s[:needs].join(" ")
188
+ lines << "- #{s[:id]} needs #{rendered}"
189
+ end
190
+
191
+ edge_specs.each do |e|
192
+ idx = lines.index { |l| l.strip.match?(/\A-\s*#{Regexp.escape(e[:from])}\s+needs\b/) }
193
+ next unless idx
194
+
195
+ stripped = lines[idx].strip
196
+ m = stripped.match(/\A-\s*(\S+)\s+needs\s+(.*)\z/)
197
+ targets = m[2].to_s.strip.split(/\s+/)
198
+ targets = [] if targets == ["nothing"]
199
+ targets << e[:to] unless targets.include?(e[:to])
200
+ lines[idx] = "- #{e[:from]} needs #{targets.empty? ? 'nothing' : targets.join(' ')}"
201
+ end
202
+
203
+ new_body = lines.join("\n")
204
+ new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
205
+ AtomicWrite.write(graph_path, new_content, renamer: renamer)
206
+ end
207
+ private_class_method :append_to_graph
208
+
209
+ # --- refusal -------------------------------------------------------------
210
+
211
+ # One ledger comment (a plain milestone-style line through Savepoint's own
212
+ # public append primitive, never a state transition - a refused proposal
213
+ # changes nothing about the proposer's own status) naming which of the
214
+ # three checks failed. Dedup'd by (stage, text) like every other such line,
215
+ # so an identical refusal repeated verbatim across retries writes once.
216
+ def refuse(intent_dir, proposer, reason, now)
217
+ Savepoint.append_savepoint_line(intent_dir, "Proposal", "#{proposer}: #{reason}", now)
218
+ { ok: false, minted: [], validator: nil, errors: [reason] }
219
+ end
220
+ private_class_method :refuse
221
+
222
+ # --- guarded re-entries into graph.md (v2 NEW-5/row 11.8) -------------------
223
+
224
+ # `unreadable: true` marks the ONE case a raise actually happened -
225
+ # distinct from `ReadySet.load_graph`'s own ordinary `ok: false` (a
226
+ # cyclic graph, a missing node file, ...), which callers here already
227
+ # tolerate and must keep tolerating unchanged.
228
+ def safe_load_graph(intent_dir)
229
+ ReadySet.load_graph(intent_dir)
230
+ rescue StandardError => e
231
+ { ok: false, edges: {}, nodes: {}, errors: ["graph.md could not be read: #{e.message}"], unreadable: true }
232
+ end
233
+ private_class_method :safe_load_graph
234
+
235
+ def safe_validate(validator, intent_dir)
236
+ validator.call(intent_dir)
237
+ rescue StandardError => e
238
+ { ok: false, missing: [], errors: ["graph.md could not be validated: #{e.message}"] }
239
+ end
240
+ private_class_method :safe_validate
241
+
242
+ # --- internals -------------------------------------------------------------
243
+
244
+ def stringify(hash)
245
+ (hash || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
246
+ end
247
+ private_class_method :stringify
248
+
249
+ def read_savepoint(intent_dir)
250
+ path = File.join(intent_dir.to_s, "savepoint.md")
251
+ File.exist?(path) ? File.read(path) : ""
252
+ end
253
+ private_class_method :read_savepoint
254
+ end
@@ -0,0 +1,201 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "node_ledger"
6
+ require_relative "ready_set"
7
+ require_relative "node_file"
8
+ require_relative "node_ids"
9
+ require_relative "graph_file"
10
+ require_relative "atomic_write"
11
+ require_relative "runner_core"
12
+ require_relative "worktree"
13
+ require_relative "node_worktree"
14
+
15
+ # RunnerRewind (intent 340, G7, n6): resets the intent branch to a node's own
16
+ # recorded commit, marks every downstream node superseded (its evidence no
17
+ # longer stands once the code it was built on is gone), and respins the
18
+ # rewound node itself the same way RunnerAnswer's hard-cap path does - never
19
+ # a plain `planned` line, because `failed_verification_count` is counted per
20
+ # subject id over the WHOLE ledger and never resets; only a brand new id
21
+ # starts clean.
22
+ #
23
+ # Internal, confirm-gated (327 D15's rewind clause): `confirm:` must be
24
+ # explicitly true, and the whole intent must be quiescent (no `running` node
25
+ # anywhere) before a branch reset is safe to make - resetting the branch
26
+ # under a live executor's own worktree would move the base it is building on
27
+ # out from under it.
28
+ #
29
+ # Pure and dependency-injected: every git call goes through an injected
30
+ # `runner:` (default Worktree::ShellRunner), always `-C <path>`, never cwd;
31
+ # the clock and the rename call are injectable the same way every other
32
+ # runner_* module here already is.
33
+ module RunnerRewind
34
+ module_function
35
+
36
+ def rewind(context, node:, confirm:, now: Time.now, ledger: NodeLedger,
37
+ runner: Worktree::ShellRunner.new, renamer: File.method(:rename),
38
+ worktree: NodeWorktree)
39
+ node = node.to_s
40
+ return refusal("confirm_required") unless confirm
41
+
42
+ intent_dir = context.intent_dir
43
+ # M9: a second, unguarded re-parse of graph.md - RunnerCore.context's
44
+ # own first read is already safe, but this fresh read is not.
45
+ loaded = RunnerCore.safe_load_graph(intent_dir)
46
+ return refusal("invalid_graph", detail: Array(loaded[:errors]).join("; ")) unless loaded[:ok]
47
+
48
+ edges = loaded[:edges]
49
+ nodes_decl = loaded[:nodes]
50
+ before_content = read_savepoint(intent_dir)
51
+ entries = NodeLedger.entries_from_content(before_content)
52
+ status_map = NodeLedger.status_from_content(before_content)
53
+
54
+ return refusal("a_node_is_running") if status_map.value?("running")
55
+
56
+ commit = last_commit(entries, node)
57
+ return refusal("no_recorded_commit") if commit.nil? || commit.to_s.strip.empty?
58
+
59
+ return refusal("no_intent_worktree") if blank?(context.worktree)
60
+
61
+ reset = runner.run("-C", context.worktree, "reset", "--hard", commit)
62
+ return refusal("git_reset_failed", detail: reset.stderr.to_s.strip) unless reset.success?
63
+
64
+ downstream = downstream_of(node, edges)
65
+ downstream.each do |d|
66
+ ledger.append_transition(savepoint_path(intent_dir), subject: d, state: "superseded",
67
+ fields: { by: node }, now: now)
68
+ # M5/D7: a rewind-superseded node's evidence no longer stands once the
69
+ # code it was built on is gone - its worktree releases here too.
70
+ worktree.release(context, node: d, state: "superseded", runner: runner)
71
+ end
72
+
73
+ succ_id = respin(intent_dir, node, nodes_decl, edges, ledger: ledger, now: now, renamer: renamer,
74
+ context: context, worktree: worktree, runner: runner)
75
+
76
+ RunnerCore.render_status(context)
77
+
78
+ {
79
+ ok: true, reset_to: commit, superseded: downstream, respun_to: succ_id, errors: [],
80
+ newly_ready: newly_ready(intent_dir, before_content: before_content, before_nodes: nodes_decl,
81
+ before_edges: edges),
82
+ }
83
+ end
84
+
85
+ # --- downstream: everything that, directly or transitively, needs the
86
+ # rewound node - the mirror of ReadySet.critical_paths' own successors map.
87
+
88
+ def downstream_of(node, edges)
89
+ successors = Hash.new { |h, k| h[k] = [] }
90
+ edges.each { |id, targets| (targets || []).each { |t| successors[t] << id } }
91
+
92
+ seen = []
93
+ stack = successors[node].dup
94
+ until stack.empty?
95
+ n = stack.pop
96
+ next if seen.include?(n)
97
+
98
+ seen << n
99
+ stack.concat(successors[n] || [])
100
+ end
101
+ seen.sort
102
+ end
103
+ private_class_method :downstream_of
104
+
105
+ def last_commit(entries, node)
106
+ last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "done" }.last
107
+ last && (last[:fields] || {})["commit"]
108
+ end
109
+ private_class_method :last_commit
110
+
111
+ # --- the respin (327 D22, same shape as RunnerAnswer's hard-cap path) ------
112
+
113
+ def respin(intent_dir, node, nodes_decl, edges, ledger:, now:, renamer:,
114
+ context:, worktree: NodeWorktree, runner: Worktree::ShellRunner.new)
115
+ decl = nodes_decl[node] || {}
116
+ kind = decl[:kind]
117
+ node_path = ReadySet.find_node_path(intent_dir, node)
118
+ parsed = NodeFile.parse(node_path)
119
+
120
+ mint_pool = NodeIds.taken(intent_dir).dup
121
+ succ_id = NodeFile.mint_id(kind, mint_pool)
122
+
123
+ raw = File.read(node_path)
124
+ parts = raw.split("---", 3)
125
+ body = substitute_id(parts[2].to_s, node, succ_id)
126
+ files = parsed[:files] || decl[:files] || []
127
+ budget = parsed[:budget]
128
+
129
+ frontmatter = "---\nnode: #{succ_id}\nkind: #{kind}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
130
+ succ_path = File.join(intent_dir, "nodes", "#{succ_id}.md")
131
+ FileUtils.mkdir_p(File.dirname(succ_path))
132
+ File.write(succ_path, "#{frontmatter}#{body}")
133
+
134
+ append_graph_line(File.join(intent_dir, "graph.md"), succ_id, edges[node] || [], renamer: renamer)
135
+
136
+ ledger.append_transition(savepoint_path(intent_dir), subject: node, state: "superseded",
137
+ fields: { by: succ_id }, now: now)
138
+
139
+ # M5/D7: the rewound node's own worktree releases too, same as the
140
+ # downstream nodes above.
141
+ worktree.release(context, node: node, state: "superseded", runner: runner)
142
+
143
+ succ_id
144
+ end
145
+ private_class_method :respin
146
+
147
+ def append_graph_line(graph_path, id, needs, renamer:)
148
+ content = File.read(graph_path)
149
+ body = GraphFile.section_body(content, "## Graph").to_s
150
+ lines = body.split("\n")
151
+ rendered = needs.empty? ? "nothing" : needs.join(" ")
152
+ lines << "- #{id} needs #{rendered}"
153
+ new_body = lines.join("\n")
154
+ new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
155
+ AtomicWrite.write(graph_path, new_content, renamer: renamer)
156
+ end
157
+ private_class_method :append_graph_line
158
+
159
+ def substitute_id(text, old_id, new_id)
160
+ text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
161
+ end
162
+ private_class_method :substitute_id
163
+
164
+ def newly_ready(intent_dir, before_content:, before_nodes:, before_edges:)
165
+ after = RunnerCore.safe_load_graph(intent_dir)
166
+ return [] unless after[:ok]
167
+
168
+ after_content = read_savepoint(intent_dir)
169
+
170
+ after[:nodes].keys.select do |id|
171
+ was = before_nodes.key?(id) &&
172
+ ReadySet.ready?(content: before_content, subject: id, graph: { edges: before_edges },
173
+ nodes: before_nodes)[:ready]
174
+ now_ready = ReadySet.ready?(content: after_content, subject: id, graph: { edges: after[:edges] },
175
+ nodes: after[:nodes])[:ready]
176
+ now_ready && !was
177
+ end.sort
178
+ end
179
+ private_class_method :newly_ready
180
+
181
+ def refusal(reason, detail: nil)
182
+ { ok: false, reason: reason, detail: detail, reset_to: nil, superseded: [], respun_to: nil, newly_ready: [] }
183
+ end
184
+ private_class_method :refusal
185
+
186
+ def blank?(value)
187
+ value.nil? || value.to_s.strip.empty?
188
+ end
189
+ private_class_method :blank?
190
+
191
+ def read_savepoint(intent_dir)
192
+ path = savepoint_path(intent_dir)
193
+ File.exist?(path) ? File.read(path) : ""
194
+ end
195
+ private_class_method :read_savepoint
196
+
197
+ def savepoint_path(intent_dir)
198
+ File.join(intent_dir.to_s, "savepoint.md")
199
+ end
200
+ private_class_method :savepoint_path
201
+ end
@@ -0,0 +1,231 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "fileutils"
6
+ require_relative "worktree"
7
+ require_relative "lock"
8
+ require_relative "node_ledger"
9
+ require_relative "ready_set"
10
+ require_relative "savepoint"
11
+
12
+ # RunnerSweep (intent 340, G7, n2): the first thing every `step` does. Two
13
+ # separately callable entry points - #abort_if_merging and #reclaim - plus
14
+ # #run, which composes them for a caller that has no absorb step to interleave.
15
+ # `step` (a later node) calls #abort_if_merging, runs absorb, then calls
16
+ # #reclaim itself, so a reclaimed node's just-landed work from absorb is never
17
+ # thrown away by a stale read (matrix row 2.20).
18
+ #
19
+ # #abort_if_merging refuses the whole step when the intent worktree already
20
+ # has a merge in progress (`MERGE_HEAD` resolves): dispatching on top of a
21
+ # half-finished merge would hand the next node a diff full of someone else's
22
+ # conflict markers. Nothing is written when this fires - not the ledger, not
23
+ # graph.md, not even the delivery lease heartbeat (row 2.2, row 2.16: the
24
+ # heartbeat runs strictly after the abort check).
25
+ #
26
+ # #reclaim walks every node whose CURRENT status (the ledger's own resolution,
27
+ # never a raw scan) is `running`, skipping anything named in `skip:` (the
28
+ # nodes this step already absorbed, row 2.19) or already terminal (a `done`
29
+ # node is never touched, row 2.12 - it simply never shows up as `running`).
30
+ # An expired lease with no commits on the node's own branch newer than its
31
+ # expiry is reclaimed outright. An expired lease whose branch DOES carry
32
+ # newer commits is extended instead, up to twice per attempt (row 2.7); the
33
+ # extension is never a ledger transition (`running` cannot re-enter `running`
34
+ # under the transition layer), so it is one line appended to
35
+ # packets/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
36
+ # count (row 2.18), never trusted from the caller. A third expiry reclaims
37
+ # regardless of new commits.
38
+ #
39
+ # Pure and dependency-injected: every git call goes through an injected
40
+ # `runner:` (default Worktree::ShellRunner), never cwd; the delivery-lease
41
+ # heartbeat goes through an injected `heartbeat:` (default Lock.heartbeat) so
42
+ # ordering (row 2.16) is provable without a real lock file. No eval, no
43
+ # ENV/global-constant seam.
44
+ module RunnerSweep
45
+ module_function
46
+
47
+ # D-ish: at most two extensions per attempt (row 2.7); the third expiry
48
+ # reclaims regardless of new commits.
49
+ MAX_EXTENSIONS_PER_ATTEMPT = 2
50
+
51
+ # abort_if_merging(context, runner:) -> {ok:, error:, recovery_command:}.
52
+ # `context.worktree` is nil for a global-store-only intent (no git repo to
53
+ # merge into); that case is always ok - there is nothing to abort.
54
+ def abort_if_merging(context, runner: Worktree::ShellRunner.new)
55
+ worktree = context&.worktree
56
+ return { ok: true, error: nil, recovery_command: nil } if blank?(worktree)
57
+
58
+ res = runner.run("-C", worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD")
59
+ return { ok: true, error: nil, recovery_command: nil } unless res.success?
60
+
61
+ recovery_command = "git -C #{worktree} merge --abort"
62
+ warn "runner: a merge is already in progress in #{worktree}; run `#{recovery_command}` " \
63
+ "before the next step can dispatch"
64
+ { ok: false, error: "a merge is in progress in #{worktree}", recovery_command: recovery_command }
65
+ end
66
+
67
+ # reclaim(context, runner:, skip:, now:) -> {reclaimed: [{node:, holder:,
68
+ # expired:}], extended: [{node:, head:, time:}]}. Reads the ledger fresh on
69
+ # every call (no cache), which is what makes calling it AFTER absorb (row
70
+ # 2.20) actually see absorb's own just-landed work rather than a stale
71
+ # snapshot taken before it.
72
+ def reclaim(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now)
73
+ intent_dir = context.intent_dir
74
+ content = savepoint_content(intent_dir)
75
+ entries = NodeLedger.entries_from_content(content)
76
+ status_map = NodeLedger.status_from_content(content)
77
+ skip_set = Array(skip).map(&:to_s)
78
+
79
+ reclaimed = []
80
+ extended = []
81
+
82
+ status_map.each do |subject, state|
83
+ next unless state == "running"
84
+ next unless subject.match?(Savepoint::NODE_SUBJECT_RE)
85
+ next if skip_set.include?(subject)
86
+
87
+ last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
88
+ next unless last
89
+
90
+ fields = last[:fields] || {}
91
+ expires_raw = fields["expires"]
92
+ expires_at = parse_time(expires_raw)
93
+ next unless expires_at
94
+ next if now < expires_at # row 2.5: an unexpired lease is left alone
95
+
96
+ holder = fields["holder"]
97
+ branch = node_branch(context, subject)
98
+ head_sha, head_time = branch_head(runner, context.worktree, branch)
99
+ has_new_commits = head_time && head_time > expires_at
100
+
101
+ if has_new_commits
102
+ attempt = current_attempt(entries, subject)
103
+ count = extension_count(intent_dir, subject, attempt)
104
+ if count < MAX_EXTENSIONS_PER_ATTEMPT
105
+ record_extension(intent_dir, subject, attempt, head_sha, now)
106
+ extended << { node: subject, head: head_sha, time: now.utc.iso8601 }
107
+ next
108
+ end
109
+ # row 2.7: the cap is spent - fall through and reclaim anyway.
110
+ end
111
+
112
+ NodeLedger.append_transition(
113
+ savepoint_path(intent_dir),
114
+ subject: subject,
115
+ state: "reclaimed",
116
+ fields: { holder: holder, expired: expires_raw },
117
+ now: now
118
+ )
119
+ reclaimed << { node: subject, holder: holder, expired: expires_raw }
120
+ end
121
+
122
+ { reclaimed: reclaimed, extended: extended }
123
+ end
124
+
125
+ # run(context, runner:, skip:, now:, heartbeat:) -> the composed report a
126
+ # caller with no absorb step to interleave uses directly. Order is fixed on
127
+ # purpose (row 2.2, row 2.16): abort check, THEN the lease heartbeat, THEN
128
+ # reclaim - never the reverse, and never a write of any kind before the
129
+ # abort check has cleared.
130
+ def run(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now, heartbeat: Lock.method(:heartbeat))
131
+ abort_result = abort_if_merging(context, runner: runner)
132
+ unless abort_result[:ok]
133
+ return {
134
+ ok: false, aborted: true, error: abort_result[:error],
135
+ recovery_command: abort_result[:recovery_command], reclaimed: [], extended: [],
136
+ }
137
+ end
138
+
139
+ session = context&.session
140
+ heartbeat.call(context.intent_dir, session: session, now: now) unless blank?(session)
141
+
142
+ result = reclaim(context, runner: runner, skip: skip, now: now)
143
+ {
144
+ ok: true, aborted: false, error: nil, recovery_command: nil,
145
+ reclaimed: result[:reclaimed], extended: result[:extended],
146
+ }
147
+ end
148
+
149
+ # --- internals ---------------------------------------------------------------
150
+
151
+ def blank?(value)
152
+ value.nil? || value.to_s.strip.empty?
153
+ end
154
+ private_class_method :blank?
155
+
156
+ def savepoint_path(intent_dir)
157
+ File.join(intent_dir.to_s, "savepoint.md")
158
+ end
159
+ private_class_method :savepoint_path
160
+
161
+ def savepoint_content(intent_dir)
162
+ path = savepoint_path(intent_dir)
163
+ File.exist?(path) ? File.read(path) : ""
164
+ end
165
+ private_class_method :savepoint_content
166
+
167
+ # The node's own worktree branch (n3's naming: `plastic/<id>--<slug>--<node>`),
168
+ # derived from the intent id/slug alone - never through NodeWorktree, which
169
+ # this node does not depend on.
170
+ def node_branch(context, node)
171
+ "plastic/#{context.intent_id}--#{context.intent_slug}--#{node}"
172
+ end
173
+ private_class_method :node_branch
174
+
175
+ # [head_sha, committer_time] for `branch` in `worktree`'s repo, or [nil, nil]
176
+ # when the worktree is gone, the branch does not exist, or anything else
177
+ # about the git call fails (row 2.13: never raise).
178
+ def branch_head(runner, worktree, branch)
179
+ return [nil, nil] if blank?(worktree) || blank?(branch)
180
+
181
+ res = runner.run("-C", worktree, "log", "-1", "--format=%H%x1f%cI", branch)
182
+ return [nil, nil] unless res.success?
183
+
184
+ sha, iso = res.stdout.to_s.strip.split("\x1f")
185
+ [sha, parse_time(iso)]
186
+ rescue StandardError
187
+ [nil, nil]
188
+ end
189
+ private_class_method :branch_head
190
+
191
+ def parse_time(raw)
192
+ return nil if blank?(raw)
193
+
194
+ Time.iso8601(raw.to_s)
195
+ rescue ArgumentError
196
+ nil
197
+ end
198
+ private_class_method :parse_time
199
+
200
+ # Row 2.18: the attempt number comes from the ledger's own count of
201
+ # `running` lines since the subject's last terminal line - the exact same
202
+ # arithmetic NodePacket uses to name that attempt's packet file, so the
203
+ # extensions file for a `running` line always matches the packet it extends.
204
+ def current_attempt(entries, subject)
205
+ ReadySet.attempts_count(entries, subject)
206
+ end
207
+ private_class_method :current_attempt
208
+
209
+ def extensions_path(intent_dir, node, attempt)
210
+ File.join(intent_dir.to_s, "packets", "#{node}--a#{attempt}.extensions")
211
+ end
212
+ private_class_method :extensions_path
213
+
214
+ # Row 2.8: counted from the attempt-scoped file alone, so a prior attempt's
215
+ # extensions never count against a fresh dispatch.
216
+ def extension_count(intent_dir, node, attempt)
217
+ path = extensions_path(intent_dir, node, attempt)
218
+ return 0 unless File.exist?(path)
219
+
220
+ File.read(path).each_line.count { |l| !l.strip.empty? }
221
+ end
222
+ private_class_method :extension_count
223
+
224
+ # Row 2.9: the observed head sha and the time, one line, append-only.
225
+ def record_extension(intent_dir, node, attempt, head_sha, now)
226
+ path = extensions_path(intent_dir, node, attempt)
227
+ FileUtils.mkdir_p(File.dirname(path))
228
+ File.open(path, "a") { |f| f.write("#{now.utc.iso8601} head=#{head_sha}\n") }
229
+ end
230
+ private_class_method :record_extension
231
+ end