@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.
- package/package.json +1 -1
- package/scripts/doctor.rb +36 -0
- package/scripts/index-projection +74 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +44 -0
- package/scripts/lib/node_packet.rb +15 -2
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/report_screen.rb +26 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +17 -42
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/runner +392 -0
- package/skills/auto/SKILL.md +1 -1
- package/skills/roadmap/SKILL.md +17 -0
- package/templates/report-roadmap-plan.md +1 -1
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require_relative "ready_set"
|
|
7
|
+
require_relative "node_ledger"
|
|
8
|
+
require_relative "node_file"
|
|
9
|
+
require_relative "node_packet"
|
|
10
|
+
require_relative "node_worktree"
|
|
11
|
+
require_relative "work_graph_validator"
|
|
12
|
+
require_relative "runner_core"
|
|
13
|
+
require_relative "runner_policy"
|
|
14
|
+
require_relative "worktree"
|
|
15
|
+
require_relative "savepoint"
|
|
16
|
+
require_relative "guarded_append"
|
|
17
|
+
|
|
18
|
+
# RunnerDispatch (intent 340, G7, n5): validates the graph, computes the
|
|
19
|
+
# ready set, applies RunnerPolicy, mints leases, builds packets, writes
|
|
20
|
+
# `running`, and returns the dispatch plan `step` prints. Never spawns an
|
|
21
|
+
# agent itself (327 D42): the session does that from the plan this returns.
|
|
22
|
+
#
|
|
23
|
+
# Pure and dependency-injected down to the clock: every side effect - the
|
|
24
|
+
# full validator, the ready-set analyzer, the packet builder, the worktree
|
|
25
|
+
# module, the ledger write, git itself - is an injectable keyword argument
|
|
26
|
+
# with a real default, so a test never touches a real repository or a real
|
|
27
|
+
# filesystem outside its own tmpdir.
|
|
28
|
+
module RunnerDispatch
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
DEFAULT_LIMIT = 2
|
|
32
|
+
|
|
33
|
+
# The return-schema instruction (327 D5): rides in the dispatch PLAN, never
|
|
34
|
+
# inside the packet, so `packet=<sha>` keeps naming a reproducible input
|
|
35
|
+
# (matrix row 5.23). NodeReturn.parse (n4) is this text's implementation.
|
|
36
|
+
RETURN_CONTRACT = <<~TEXT.freeze
|
|
37
|
+
RETURN CONTRACT: reply with exactly one YAML document as your final
|
|
38
|
+
message, nothing else around it. Keys: node, status, commit, summary,
|
|
39
|
+
findings, proposed_nodes, proposed_edges, question, reason. status is one
|
|
40
|
+
of done, failed_verification, needs_decision, blocked. done requires
|
|
41
|
+
commit; needs_decision requires question; failed_verification and
|
|
42
|
+
blocked require reason. Anything that does not parse under this closed
|
|
43
|
+
schema is refused as failed_verification reason=return_unparsable.
|
|
44
|
+
TEXT
|
|
45
|
+
|
|
46
|
+
HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
|
|
47
|
+
|
|
48
|
+
# dispatch(context, limit:) -> a result hash. Always carries :ok, :reason,
|
|
49
|
+
# :errors, :rearm_command, :dispatched, :stop, :parked, :status, :blockers,
|
|
50
|
+
# :plan - fields that do not apply to a given outcome stay nil/empty rather
|
|
51
|
+
# than being omitted, so a caller never has to guard with `dig`.
|
|
52
|
+
def dispatch(context, limit: DEFAULT_LIMIT, now: Time.now, config: {}, caps: ReadySet::DEFAULT_CAPS,
|
|
53
|
+
validator: WorkGraphValidator.method(:validate),
|
|
54
|
+
ready_analyzer: ReadySet.method(:analyze),
|
|
55
|
+
packet_builder: NodePacket.method(:build),
|
|
56
|
+
worktree: NodeWorktree,
|
|
57
|
+
ledger: NodeLedger,
|
|
58
|
+
runner: Worktree::ShellRunner.new)
|
|
59
|
+
intent_dir = context.intent_dir
|
|
60
|
+
savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
|
|
61
|
+
|
|
62
|
+
# Row 5.31/5.32: this is RunnerDispatch's OWN lock check, never a shelled
|
|
63
|
+
# `node-transition` call - append_transition below is used in-process
|
|
64
|
+
# (RunnerAbsorb's own pattern), so nothing here inherits node-transition's
|
|
65
|
+
# CLI-level lock refusal (exit 4) for free.
|
|
66
|
+
return lock_refusal(intent_dir) unless context.session
|
|
67
|
+
|
|
68
|
+
content = read_savepoint(savepoint_path)
|
|
69
|
+
entries = NodeLedger.entries_from_content(content)
|
|
70
|
+
|
|
71
|
+
# Row 5.1/5.2/5.3: the full validator runs only when the ledger holds no
|
|
72
|
+
# `running` line for ANY node yet (327 D17's exact precondition) - every
|
|
73
|
+
# later dispatch skips it and relies on the cheaper re-read below.
|
|
74
|
+
#
|
|
75
|
+
# M9: both the validator and the ready analyzer re-parse graph.md fresh
|
|
76
|
+
# (RunnerCore.context's own first read, already guarded, is not reused
|
|
77
|
+
# here on purpose - row 5.4), and a malformed graph.md - non-UTF-8 bytes,
|
|
78
|
+
# say - raises out of both rather than reporting :ok false. Guarded here
|
|
79
|
+
# so `step` refuses cleanly instead of dying with a raw stack trace.
|
|
80
|
+
if entries.none? { |e| e[:state] == "running" }
|
|
81
|
+
full = safe_validate(validator, intent_dir)
|
|
82
|
+
return invalid_graph_result(full[:errors]) unless full[:ok]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Row 5.4: re-read graph.md and cycle-check on EVERY dispatch, first or
|
|
86
|
+
# not. ReadySet.analyze re-parses graph.md and nodes/ from disk itself,
|
|
87
|
+
# so nothing here trusts `context.graph`, which was resolved once,
|
|
88
|
+
# before this step even started.
|
|
89
|
+
analysis = safe_analyze(ready_analyzer, intent_dir, now: now, caps: caps)
|
|
90
|
+
return invalid_graph_result(analysis[:errors]) unless analysis[:ok]
|
|
91
|
+
|
|
92
|
+
loaded = RunnerCore.safe_load_graph(intent_dir)
|
|
93
|
+
return invalid_graph_result(loaded[:errors]) unless loaded[:ok]
|
|
94
|
+
|
|
95
|
+
edges = loaded[:edges]
|
|
96
|
+
nodes_decl = loaded[:nodes]
|
|
97
|
+
|
|
98
|
+
# Minor 9: count `running` only for NODE subjects - an `Intent` subject
|
|
99
|
+
# in a running-like state must never eat a dispatch slot meant for the
|
|
100
|
+
# concurrency ceiling over nodes.
|
|
101
|
+
running_count = NodeLedger.status_from_content(content).count do |subject, s|
|
|
102
|
+
s == "running" && subject.to_s.match?(Savepoint::NODE_SUBJECT_RE)
|
|
103
|
+
end
|
|
104
|
+
slots = [limit.to_i - running_count, 0].max
|
|
105
|
+
|
|
106
|
+
dispatched = []
|
|
107
|
+
parked = []
|
|
108
|
+
stop = nil
|
|
109
|
+
packet_failures = []
|
|
110
|
+
ceiling_blocked = false
|
|
111
|
+
|
|
112
|
+
analysis[:ranked_ready].each do |row|
|
|
113
|
+
break if stop
|
|
114
|
+
|
|
115
|
+
node = row[:id].to_s
|
|
116
|
+
kind = (nodes_decl[node] || {})[:kind] || row[:kind]
|
|
117
|
+
|
|
118
|
+
# Row 5.33: the ONLY overlap check anywhere in this loop - re-evaluated
|
|
119
|
+
# against the latest on-disk content on every iteration, so a sibling
|
|
120
|
+
# this very step just dispatched (its `running` line already written)
|
|
121
|
+
# is seen by the very next candidate, without RunnerDispatch ever
|
|
122
|
+
# carrying its own copy of the overlap rule.
|
|
123
|
+
live = ReadySet.ready?(content: content, subject: node, graph: { edges: edges }, nodes: nodes_decl,
|
|
124
|
+
caps: caps)
|
|
125
|
+
next unless live[:ready]
|
|
126
|
+
|
|
127
|
+
if kind.to_s == "decision"
|
|
128
|
+
stop = write_decision_stop(savepoint_path, intent_dir, node, ledger: ledger, now: now)
|
|
129
|
+
content = read_savepoint(savepoint_path)
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
if RunnerPolicy.at_retry_cap?(entries, node, kind)
|
|
134
|
+
parked << write_retry_cap_park(savepoint_path, intent_dir, node, kind, entries, ledger: ledger, now: now)
|
|
135
|
+
content = read_savepoint(savepoint_path)
|
|
136
|
+
entries = NodeLedger.entries_from_content(content)
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# M10: a ready node that cannot dispatch because every slot is taken
|
|
141
|
+
# is QUEUED, not stalled - the graph can still continue, it is merely
|
|
142
|
+
# waiting on the ceiling, matrix row 10.13.
|
|
143
|
+
if slots <= 0
|
|
144
|
+
ceiling_blocked = true
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
result = dispatch_one(context, node: node, kind: kind, now: now, config: config, caps: caps,
|
|
149
|
+
edges: edges, nodes_decl: nodes_decl, packet_builder: packet_builder,
|
|
150
|
+
worktree: worktree, ledger: ledger, runner: runner)
|
|
151
|
+
if result[:packet_build_failed]
|
|
152
|
+
packet_failures << result
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
next unless result[:ok]
|
|
156
|
+
|
|
157
|
+
dispatched << result[:entry]
|
|
158
|
+
slots -= 1
|
|
159
|
+
content = read_savepoint(savepoint_path)
|
|
160
|
+
entries = NodeLedger.entries_from_content(content)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Row 5.34: re-render graph.md's ## Status once, after every write this
|
|
164
|
+
# step made, mirroring RunnerAbsorb's own single call after its own
|
|
165
|
+
# transition.
|
|
166
|
+
RunnerCore.render_status(context) if dispatched.any? || stop || parked.any?
|
|
167
|
+
|
|
168
|
+
build_report(context: context, dispatched: dispatched, stop: stop, parked: parked,
|
|
169
|
+
ceiling_blocked: ceiling_blocked, packet_failures: packet_failures, running_count: running_count)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# --- guarded re-entries into graph.md (M9) ----------------------------------
|
|
173
|
+
|
|
174
|
+
def safe_validate(validator, intent_dir)
|
|
175
|
+
validator.call(intent_dir)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
{ ok: false, errors: ["graph.md could not be read: #{e.message}"] }
|
|
178
|
+
end
|
|
179
|
+
private_class_method :safe_validate
|
|
180
|
+
|
|
181
|
+
def safe_analyze(ready_analyzer, intent_dir, now:, caps:)
|
|
182
|
+
ready_analyzer.call(intent_dir, now: now, caps: caps)
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
{ ok: false, errors: ["graph.md could not be read: #{e.message}"] }
|
|
185
|
+
end
|
|
186
|
+
private_class_method :safe_analyze
|
|
187
|
+
|
|
188
|
+
# --- one node's whole dispatch (packet, lease, `running`) -------------------
|
|
189
|
+
|
|
190
|
+
def dispatch_one(context, node:, kind:, now:, config:, caps:, edges:, nodes_decl:, packet_builder:, worktree:,
|
|
191
|
+
ledger:, runner:)
|
|
192
|
+
intent_dir = context.intent_dir
|
|
193
|
+
savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
|
|
194
|
+
|
|
195
|
+
holder = context.session
|
|
196
|
+
model = RunnerPolicy.model_for(kind, config: config)
|
|
197
|
+
expires = RunnerPolicy.lease_expires(kind, now: now)
|
|
198
|
+
|
|
199
|
+
# Row 10.16/M13: recorded BEFORE provisioning - a worktree this dispatch
|
|
200
|
+
# finds already on disk (kept there by a prior failed_verification
|
|
201
|
+
# attempt, D7) must never be the one a later rollback in this same call
|
|
202
|
+
# deletes; only a worktree THIS call actually creates may be rolled back.
|
|
203
|
+
pre_existing_worktree = worktree_pre_existing?(worktree, context, node, kind)
|
|
204
|
+
|
|
205
|
+
# Row 5.16/5.29: only a `work` node gets a worktree, and this is the
|
|
206
|
+
# node-scoped `worktree_reader:` D23 injects into NodePacket.build - it
|
|
207
|
+
# names THIS node's own worktree and branch, never the intent's.
|
|
208
|
+
provisioned = RunnerPolicy.worktree?(kind) ? worktree.provision(context, node: node, kind: kind, runner: runner)
|
|
209
|
+
: unprovisioned
|
|
210
|
+
node_reader = lambda do |intent_dir:|
|
|
211
|
+
{ "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned] }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Row 5.18: build the packet BEFORE writing `running` - a `running` line
|
|
215
|
+
# naming bytes that do not exist yet is worse than a packet nobody reads.
|
|
216
|
+
# Row 5.30: `force: true` always - a fresh attempt number this dispatch
|
|
217
|
+
# computes is, by construction, never one `running` has already claimed,
|
|
218
|
+
# so an existing file at that path is always an orphan from a step that
|
|
219
|
+
# crashed between building the packet and writing `running`, safe to
|
|
220
|
+
# overwrite outright.
|
|
221
|
+
# Row 5.20/10.8: the node's own declared budget: (M7) - nil when the node
|
|
222
|
+
# names none, in which case NodePacket.build falls back to its own
|
|
223
|
+
# default (row 10.9).
|
|
224
|
+
build_result = packet_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
|
|
225
|
+
model: model, force: true, worktree_reader: node_reader,
|
|
226
|
+
budget_tokens: node_declared_budget(intent_dir, node))
|
|
227
|
+
unless build_result[:ok]
|
|
228
|
+
# M6: a failed packet build never leaves an orphan worktree behind, and
|
|
229
|
+
# its errors travel back up so the step's report can name the node and
|
|
230
|
+
# the reason instead of a bare "stalled" (row 10.6/10.7).
|
|
231
|
+
rollback_dispatch(context, node: node, kind: kind, packet_path: build_result[:path], runner: runner,
|
|
232
|
+
worktree: worktree, created_this_dispatch: !pre_existing_worktree)
|
|
233
|
+
return { ok: false, packet_build_failed: true, node: node, errors: build_result[:errors] }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
precondition = lambda do |c|
|
|
237
|
+
ReadySet.ready?(content: c, subject: node, graph: { edges: edges }, nodes: nodes_decl, caps: caps)[:ready]
|
|
238
|
+
end
|
|
239
|
+
fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model }
|
|
240
|
+
|
|
241
|
+
result = begin
|
|
242
|
+
ledger.append_transition(savepoint_path, subject: node, state: "running", fields: fields, now: now,
|
|
243
|
+
precondition: precondition)
|
|
244
|
+
rescue GuardedAppend::Unavailable
|
|
245
|
+
:unavailable
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Row 5.20: a refused (or unavailable) `running` write rolls back both
|
|
249
|
+
# side effects this method already produced - the node never ran, so
|
|
250
|
+
# nothing may act like it did.
|
|
251
|
+
unless result == :written
|
|
252
|
+
rollback_dispatch(context, node: node, kind: kind, packet_path: build_result[:path], runner: runner,
|
|
253
|
+
worktree: worktree, created_this_dispatch: !pre_existing_worktree)
|
|
254
|
+
return { ok: false }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
{
|
|
258
|
+
ok: true,
|
|
259
|
+
entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, worktree: provisioned[:path],
|
|
260
|
+
packet: build_result[:path] },
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The node's own declared budget: (frontmatter), or nil when it names
|
|
265
|
+
# none - M7. Parsed directly off the node file, never through
|
|
266
|
+
# `nodes_decl` (ReadySet.load_graph's own decl hash carries only kind and
|
|
267
|
+
# files, never budget), so this stays independent of that module.
|
|
268
|
+
def node_declared_budget(intent_dir, node)
|
|
269
|
+
path = ReadySet.find_node_path(intent_dir, node)
|
|
270
|
+
return nil unless path
|
|
271
|
+
|
|
272
|
+
nf = NodeFile.parse(path)
|
|
273
|
+
nf[:ok] ? nf[:budget] : nil
|
|
274
|
+
end
|
|
275
|
+
private_class_method :node_declared_budget
|
|
276
|
+
|
|
277
|
+
# true iff a `work` node's own worktree already exists BEFORE this call
|
|
278
|
+
# provisions anything - the pre-check `rollback_dispatch` needs to tell a
|
|
279
|
+
# worktree this dispatch created from one it merely found (row 10.16).
|
|
280
|
+
def worktree_pre_existing?(worktree, context, node, kind)
|
|
281
|
+
return false unless RunnerPolicy.worktree?(kind)
|
|
282
|
+
|
|
283
|
+
p = worktree.paths(context, node: node)
|
|
284
|
+
!!(p && p["path"] && Dir.exist?(p["path"]))
|
|
285
|
+
end
|
|
286
|
+
private_class_method :worktree_pre_existing?
|
|
287
|
+
|
|
288
|
+
def role_for(kind)
|
|
289
|
+
kind.to_s == "verify" ? "advisor" : "executor"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def unprovisioned
|
|
293
|
+
{ ok: true, path: nil, branch: nil, provisioned: false }
|
|
294
|
+
end
|
|
295
|
+
private_class_method :unprovisioned
|
|
296
|
+
|
|
297
|
+
# Row 10.16/M13: `created_this_dispatch:` gates the worktree half of the
|
|
298
|
+
# rollback - a worktree this call did not create (kept on disk by a prior
|
|
299
|
+
# attempt's failed_verification, D7) is never touched, only a packet this
|
|
300
|
+
# call's own `packet_builder` may have written is ever deleted.
|
|
301
|
+
def rollback_dispatch(context, node:, kind:, packet_path:, runner:, worktree:, created_this_dispatch:)
|
|
302
|
+
File.delete(packet_path) if packet_path && File.exist?(packet_path)
|
|
303
|
+
return unless RunnerPolicy.worktree?(kind)
|
|
304
|
+
return unless created_this_dispatch
|
|
305
|
+
|
|
306
|
+
p = worktree.paths(context, node: node)
|
|
307
|
+
return if p["path"].nil? || !Dir.exist?(p["path"])
|
|
308
|
+
|
|
309
|
+
Worktree.remove_worktree(runner, repo: p["repo"], worktree: p["path"])
|
|
310
|
+
Worktree.prune(runner, repo: p["repo"])
|
|
311
|
+
end
|
|
312
|
+
private_class_method :rollback_dispatch
|
|
313
|
+
|
|
314
|
+
# --- decision stop and the retry-cap park -----------------------------------
|
|
315
|
+
|
|
316
|
+
# Row 5.9/5.10: a ready decision node is never dispatched - it stops the
|
|
317
|
+
# WHOLE step (any node ranked after it this step is simply not reached)
|
|
318
|
+
# and writes its own `needs_decision` line so it reads that way from
|
|
319
|
+
# `status` too, carrying the exact `runner answer` command that clears it.
|
|
320
|
+
def write_decision_stop(savepoint_path, intent_dir, node, ledger:, now:)
|
|
321
|
+
question = decision_question(intent_dir, node)
|
|
322
|
+
safe_append(ledger, savepoint_path, node, "needs_decision", { question: question }, now: now)
|
|
323
|
+
{ reason: "decision", node: node, question: question, answer_command: answer_command(intent_dir, node) }
|
|
324
|
+
end
|
|
325
|
+
private_class_method :write_decision_stop
|
|
326
|
+
|
|
327
|
+
# Row 5.11/5.27/5.28: a node at RunnerPolicy's SOFT cap is parked at
|
|
328
|
+
# `needs_decision` with a synthesized `question=` rather than dispatched a
|
|
329
|
+
# further time, carrying the same `runner answer` shape.
|
|
330
|
+
def write_retry_cap_park(savepoint_path, intent_dir, node, kind, entries, ledger:, now:)
|
|
331
|
+
count = RunnerPolicy.retry_count(entries, node)
|
|
332
|
+
cap = RunnerPolicy.retry_cap(kind)
|
|
333
|
+
question = "#{node} has failed verification #{count} time(s), its #{kind} retry cap is #{cap}; " \
|
|
334
|
+
"retry, rewind, or abandon it?"
|
|
335
|
+
safe_append(ledger, savepoint_path, node, "needs_decision", { question: question }, now: now)
|
|
336
|
+
{ reason: "retry_cap", node: node, question: question, answer_command: answer_command(intent_dir, node) }
|
|
337
|
+
end
|
|
338
|
+
private_class_method :write_retry_cap_park
|
|
339
|
+
|
|
340
|
+
def safe_append(ledger, savepoint_path, node, state, fields, now:)
|
|
341
|
+
ledger.append_transition(savepoint_path, subject: node, state: state, fields: fields, now: now)
|
|
342
|
+
rescue GuardedAppend::Unavailable
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
private_class_method :safe_append
|
|
346
|
+
|
|
347
|
+
def decision_question(intent_dir, node)
|
|
348
|
+
path = ReadySet.find_node_path(intent_dir, node)
|
|
349
|
+
fallback = "#{node} needs an owner decision; see its ## Question section"
|
|
350
|
+
return fallback unless path
|
|
351
|
+
|
|
352
|
+
nf = NodeFile.parse(path)
|
|
353
|
+
return fallback unless nf[:ok]
|
|
354
|
+
|
|
355
|
+
section = NodeFile.split_by_headings(nf[:body]).find { |(heading, _)| heading.to_s.strip == "## Question" }
|
|
356
|
+
text = section && section[1].to_s.strip
|
|
357
|
+
text && !text.empty? ? squash(text) : fallback
|
|
358
|
+
end
|
|
359
|
+
private_class_method :decision_question
|
|
360
|
+
|
|
361
|
+
def squash(text)
|
|
362
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
363
|
+
end
|
|
364
|
+
private_class_method :squash
|
|
365
|
+
|
|
366
|
+
# Row 5.10/5.28: the one command shape every stop and every park prints,
|
|
367
|
+
# matching scripts/runner's own published usage
|
|
368
|
+
# (`runner <step|status|answer> <intent_dir> [--node ID] [--answer TEXT]`).
|
|
369
|
+
def answer_command(intent_dir, node)
|
|
370
|
+
"runner answer #{intent_dir} --node #{node} --answer \"<your answer>\""
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Row 5.32: re-arms delivery.lock for a resumed session with a new id -
|
|
374
|
+
# `plastic-lock arm` is the shipped command that takes ownership again.
|
|
375
|
+
def rearm_command(intent_dir)
|
|
376
|
+
"plastic-lock arm --intent-dir #{intent_dir}"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# --- refusals and the report -------------------------------------------------
|
|
380
|
+
|
|
381
|
+
def empty_result
|
|
382
|
+
{ ok: true, reason: nil, errors: [], rearm_command: nil, dispatched: [], stop: nil, parked: [],
|
|
383
|
+
status: nil, blockers: [], plan: nil }
|
|
384
|
+
end
|
|
385
|
+
private_class_method :empty_result
|
|
386
|
+
|
|
387
|
+
def lock_refusal(intent_dir)
|
|
388
|
+
empty_result.merge(ok: false, reason: "lock_not_held", rearm_command: rearm_command(intent_dir))
|
|
389
|
+
end
|
|
390
|
+
private_class_method :lock_refusal
|
|
391
|
+
|
|
392
|
+
def invalid_graph_result(errors)
|
|
393
|
+
empty_result.merge(ok: false, reason: "invalid_graph", errors: Array(errors))
|
|
394
|
+
end
|
|
395
|
+
private_class_method :invalid_graph_result
|
|
396
|
+
|
|
397
|
+
def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false, packet_failures: [],
|
|
398
|
+
running_count: 0)
|
|
399
|
+
base = empty_result.merge(dispatched: dispatched, stop: stop, parked: parked,
|
|
400
|
+
plan: render_plan(dispatched))
|
|
401
|
+
|
|
402
|
+
if dispatched.any?
|
|
403
|
+
base.merge(status: "dispatched")
|
|
404
|
+
elsif stop
|
|
405
|
+
base.merge(status: "needs_decision")
|
|
406
|
+
elsif ceiling_blocked || running_count.to_i.positive?
|
|
407
|
+
# M10/v2 NEW-7: a ready node waiting on the concurrency ceiling is one
|
|
408
|
+
# shape of "still in flight" - a graph where every ready node is
|
|
409
|
+
# ALREADY running (no candidate ever reaches the ceiling check at all,
|
|
410
|
+
# so `ceiling_blocked` never sets) is the ordinary busy case, and it
|
|
411
|
+
# used to fall all the way through to `stalled`. Any node genuinely
|
|
412
|
+
# `running` means the graph can still continue on its own.
|
|
413
|
+
base.merge(status: "queued")
|
|
414
|
+
else
|
|
415
|
+
# Row 5.25: complete iff EVERY declared node is terminal - an empty
|
|
416
|
+
# ready set from parked/blocked nodes must never read as finished.
|
|
417
|
+
complete = RunnerCore.complete?(context)
|
|
418
|
+
if complete
|
|
419
|
+
base.merge(status: "complete")
|
|
420
|
+
else
|
|
421
|
+
# M6/row 10.7: a failed packet build writes no ledger line at all,
|
|
422
|
+
# so `named_blockers` (ledger-derived) never sees it on its own -
|
|
423
|
+
# its own node and reason are named here so `stalled` never prints
|
|
424
|
+
# bare.
|
|
425
|
+
blockers = named_blockers(context) + packet_failures.map { |f| packet_failure_blocker(f) }
|
|
426
|
+
base.merge(status: "stalled", blockers: blockers)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
private_class_method :build_report
|
|
431
|
+
|
|
432
|
+
def packet_failure_blocker(failure)
|
|
433
|
+
"#{failure[:node]}: packet build failed (#{Array(failure[:errors]).join('; ')})"
|
|
434
|
+
end
|
|
435
|
+
private_class_method :packet_failure_blocker
|
|
436
|
+
|
|
437
|
+
# Row 5.25a/5.26: every unfinished node's own blockers, with ReadySet's
|
|
438
|
+
# hard-attempt-cap wording renamed so it reads as the named backstop it is
|
|
439
|
+
# (D22: the exit here is `runner answer`, never another dispatch), rather
|
|
440
|
+
# than one more indistinguishable blocker line.
|
|
441
|
+
def named_blockers(context)
|
|
442
|
+
intent_dir = context.intent_dir
|
|
443
|
+
rows = RunnerCore.status(context)
|
|
444
|
+
rows.each_with_object([]) do |(id, view), out|
|
|
445
|
+
next if ReadySet::TERMINAL_STATES.include?(view[:state])
|
|
446
|
+
|
|
447
|
+
view[:blockers].each { |b| out << name_blocker(intent_dir, id, b) }
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
private_class_method :named_blockers
|
|
451
|
+
|
|
452
|
+
def name_blocker(intent_dir, id, blocker)
|
|
453
|
+
rest = blocker.to_s.sub(/\A#{Regexp.escape(id)} /, "")
|
|
454
|
+
m = rest.match(HARD_CAP_RE)
|
|
455
|
+
return "#{id}: #{blocker}" unless m
|
|
456
|
+
|
|
457
|
+
"#{id} has reached its hard attempt backstop (#{m[1]}/#{m[2]}) - #{answer_command(intent_dir, id)}"
|
|
458
|
+
end
|
|
459
|
+
private_class_method :name_blocker
|
|
460
|
+
|
|
461
|
+
# Row 5.22/5.23/5.24: one machine-readable (YAML) document naming, per
|
|
462
|
+
# dispatched node, the packet path, the model, the worktree, the kind and
|
|
463
|
+
# the role, plus the return contract ONCE at the top level - never inside
|
|
464
|
+
# any one node's packet.
|
|
465
|
+
def render_plan(dispatched)
|
|
466
|
+
return nil if dispatched.empty?
|
|
467
|
+
|
|
468
|
+
YAML.dump(
|
|
469
|
+
"return_contract" => RETURN_CONTRACT,
|
|
470
|
+
"dispatch" => dispatched.map do |d|
|
|
471
|
+
{ "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
|
|
472
|
+
"worktree" => d[:worktree], "packet" => d[:packet] }
|
|
473
|
+
end
|
|
474
|
+
)
|
|
475
|
+
end
|
|
476
|
+
private_class_method :render_plan
|
|
477
|
+
|
|
478
|
+
def read_savepoint(path)
|
|
479
|
+
File.exist?(path) ? File.read(path) : ""
|
|
480
|
+
end
|
|
481
|
+
private_class_method :read_savepoint
|
|
482
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "ready_set"
|
|
5
|
+
require_relative "node_worktree"
|
|
6
|
+
require_relative "agent_models"
|
|
7
|
+
|
|
8
|
+
# RunnerPolicy (intent 340, G7, n5): the kind table. Four facts per kind -
|
|
9
|
+
# which model it runs on, whether it gets a worktree, its SOFT retry cap, and
|
|
10
|
+
# its diff rule - looked up by one fallback rule: an unknown or nil kind gets
|
|
11
|
+
# `work`'s row, the widest of the four, never no policy at all (matrix 5.17,
|
|
12
|
+
# mirroring ReadySet's own `caps.fetch(kind) { caps["work"] }` fallback).
|
|
13
|
+
#
|
|
14
|
+
# This is deliberately a SECOND, SOFTER cap than ReadySet::DEFAULT_CAPS
|
|
15
|
+
# (327 D22): that cap is the transition layer's hard backstop, counted as
|
|
16
|
+
# `running` lines since the last terminal line, and `node-transition` is
|
|
17
|
+
# never changed to accept an override of it. RunnerPolicy's cap is counted
|
|
18
|
+
# from `failed_verification` lines alone (ReadySet.failed_verification_count,
|
|
19
|
+
# matrix 5.12) - a node reclaimed after a crash, with no failed_verification
|
|
20
|
+
# line at all, never trips this cap, exactly as 327 D12 specifies: "work runs
|
|
21
|
+
# ... retry cap 2 ... verify ... retry cap 1 ... research ... retry cap 1".
|
|
22
|
+
#
|
|
23
|
+
# Pure and dependency-injected: `model_for` takes `config:` (an already
|
|
24
|
+
# loaded config hash, AgentModels' own `agents.models` shape), never reads
|
|
25
|
+
# ENV or a real config.yml itself - the caller (RunnerDispatch) owns loading
|
|
26
|
+
# real config, this module only resolves values out of what it is handed.
|
|
27
|
+
module RunnerPolicy
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# D31 (327): "One advisor: the smartest model available, or the model set
|
|
31
|
+
# in user config." Never the cheap tier - that is the one thing D31 rules
|
|
32
|
+
# out for a verify node (matrix 5.14).
|
|
33
|
+
#
|
|
34
|
+
# D19 (327): "knows nothing about either harness." The executor default
|
|
35
|
+
# resolves through AgentModels::TIER_DEFAULTS, the one place
|
|
36
|
+
# `plastic-executor`'s shipped tier is already declared (post-execution
|
|
37
|
+
# review minor 4), rather than a second, independently-drifting literal
|
|
38
|
+
# here. `plastic-advisor` carries no lifecycle-stage entry in
|
|
39
|
+
# TIER_DEFAULTS at all (it is a consultation agent, never auto-dispatched
|
|
40
|
+
# - see agent_models.rb's own docstring), so its default stays the one
|
|
41
|
+
# literal this table cannot source from anywhere else.
|
|
42
|
+
DEFAULT_EXECUTOR_MODEL = AgentModels::TIER_DEFAULTS.fetch("plastic-executor")
|
|
43
|
+
DEFAULT_ADVISOR_MODEL = "opus"
|
|
44
|
+
|
|
45
|
+
EXECUTOR_CONFIG_KEY = "plastic-executor"
|
|
46
|
+
ADVISOR_CONFIG_KEY = "plastic-advisor"
|
|
47
|
+
|
|
48
|
+
# {model_role:, worktree:, retry_cap:, diff_rule:, lease_minutes:} per kind
|
|
49
|
+
# (327 D12). `decision` carries no retry cap or lease: it is never
|
|
50
|
+
# dispatched (RunnerDispatch stops the loop on one instead), so nothing
|
|
51
|
+
# here ever needs to answer "how long is a decision node's lease".
|
|
52
|
+
KIND_TABLE = {
|
|
53
|
+
"work" => { model_role: :executor, worktree: true, retry_cap: 2, diff_rule: :inside_files,
|
|
54
|
+
lease_minutes: 180 },
|
|
55
|
+
"verify" => { model_role: :advisor, worktree: false, retry_cap: 1, diff_rule: :none,
|
|
56
|
+
lease_minutes: 30 },
|
|
57
|
+
"research" => { model_role: :executor, worktree: false, retry_cap: 1, diff_rule: :none,
|
|
58
|
+
lease_minutes: 60 },
|
|
59
|
+
"decision" => { model_role: nil, worktree: false, retry_cap: nil, diff_rule: :none,
|
|
60
|
+
lease_minutes: nil },
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# matrix 5.17: an unknown, nil, or blank kind gets `work`'s whole row.
|
|
64
|
+
def policy_for(kind)
|
|
65
|
+
KIND_TABLE.fetch(kind.to_s, KIND_TABLE["work"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# matrix 5.13/5.14/5.15: work and research resolve the executor model,
|
|
69
|
+
# verify resolves the advisor model, and a config with no override falls
|
|
70
|
+
# back to the shipped default rather than an empty string (`running`
|
|
71
|
+
# requires a non-blank `model=`).
|
|
72
|
+
def model_for(kind, config: {})
|
|
73
|
+
policy_for(kind)[:model_role] == :advisor ? advisor_model(config: config) : executor_model(config: config)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def executor_model(config: {})
|
|
77
|
+
resolve_model(config, EXECUTOR_CONFIG_KEY, DEFAULT_EXECUTOR_MODEL)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def advisor_model(config: {})
|
|
81
|
+
resolve_model(config, ADVISOR_CONFIG_KEY, DEFAULT_ADVISOR_MODEL)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve_model(config, key, shipped_default)
|
|
85
|
+
value = AgentModels.models_section(config)[key]
|
|
86
|
+
present?(value) ? value : shipped_default
|
|
87
|
+
end
|
|
88
|
+
private_class_method :resolve_model
|
|
89
|
+
|
|
90
|
+
def present?(value)
|
|
91
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
92
|
+
end
|
|
93
|
+
private_class_method :present?
|
|
94
|
+
|
|
95
|
+
# matrix 5.16/5.17: only `work` gets a worktree; an unknown kind falls back
|
|
96
|
+
# to `work`'s own row like every other field here. `KIND_TABLE["work"]` is
|
|
97
|
+
# kept equal to `NodeWorktree::WORKTREE_KINDS` by the assertion below, so
|
|
98
|
+
# the two lists cannot silently drift apart.
|
|
99
|
+
raise "RunnerPolicy/NodeWorktree kind lists have drifted" unless KIND_TABLE.select { |_, v| v[:worktree] }.keys ==
|
|
100
|
+
NodeWorktree::WORKTREE_KINDS
|
|
101
|
+
|
|
102
|
+
def worktree?(kind)
|
|
103
|
+
!!policy_for(kind)[:worktree]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def diff_rule(kind)
|
|
107
|
+
policy_for(kind)[:diff_rule]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# matrix 5.12: the SOFT cap, or nil for a kind that carries none (decision).
|
|
111
|
+
def retry_cap(kind)
|
|
112
|
+
policy_for(kind)[:retry_cap]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The soft-cap count itself: every non-torn `failed_verification` line for
|
|
116
|
+
# `node`, delegated to ReadySet (matrix 5.12's "counts from
|
|
117
|
+
# failed_verification lines", never from attempts).
|
|
118
|
+
def retry_count(entries, node)
|
|
119
|
+
ReadySet.failed_verification_count(entries, node.to_s)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# true only when a real cap exists AND the count has reached it (a nil cap,
|
|
123
|
+
# e.g. `decision`, never trips).
|
|
124
|
+
def at_retry_cap?(entries, node, kind)
|
|
125
|
+
cap = retry_cap(kind)
|
|
126
|
+
return false if cap.nil?
|
|
127
|
+
|
|
128
|
+
retry_count(entries, node) >= cap
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# matrix 5.21: `expires=` on `running` comes from the kind's own lease
|
|
132
|
+
# length, never one shared constant - a long `work` build must not be
|
|
133
|
+
# reclaimed under a still-working executor the way a quick `verify` pass
|
|
134
|
+
# would be.
|
|
135
|
+
def lease_minutes(kind)
|
|
136
|
+
policy_for(kind)[:lease_minutes] || policy_for("work")[:lease_minutes]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def lease_expires(kind, now: Time.now)
|
|
140
|
+
(now + (lease_minutes(kind) * 60)).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
141
|
+
end
|
|
142
|
+
end
|