@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,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