@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24
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/PLASTIC.md +6 -5
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +22 -19
- package/scripts/exec-worktree +5 -5
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +16 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +26 -3
- package/scripts/hook-session-start +49 -10
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_entry.rb +53 -0
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +228 -9
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +11 -2
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +2 -2
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +36 -10
- package/scripts/lib/runner_dispatch.rb +66 -51
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +9 -10
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +22 -16
- package/scripts/lib/session_ledger.rb +44 -4
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/new-intent +1 -1
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +224 -0
- package/scripts/plastic-lock +33 -32
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +8 -8
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +4 -4
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +7 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -6,7 +6,7 @@ require "yaml"
|
|
|
6
6
|
require_relative "ready_set"
|
|
7
7
|
require_relative "node_ledger"
|
|
8
8
|
require_relative "node_file"
|
|
9
|
-
require_relative "
|
|
9
|
+
require_relative "node_input"
|
|
10
10
|
require_relative "node_worktree"
|
|
11
11
|
require_relative "work_graph_validator"
|
|
12
12
|
require_relative "runner_core"
|
|
@@ -14,14 +14,15 @@ require_relative "runner_policy"
|
|
|
14
14
|
require_relative "worktree"
|
|
15
15
|
require_relative "savepoint"
|
|
16
16
|
require_relative "guarded_append"
|
|
17
|
+
require_relative "harness_adapter"
|
|
17
18
|
|
|
18
19
|
# RunnerDispatch (intent 340, G7, n5): validates the graph, computes the
|
|
19
|
-
# ready set, applies RunnerPolicy, mints leases, builds
|
|
20
|
+
# ready set, applies RunnerPolicy, mints leases, builds node inputs, writes
|
|
20
21
|
# `running`, and returns the dispatch plan `step` prints. Never spawns an
|
|
21
22
|
# agent itself (327 D42): the session does that from the plan this returns.
|
|
22
23
|
#
|
|
23
24
|
# Pure and dependency-injected down to the clock: every side effect - the
|
|
24
|
-
# full validator, the ready-set analyzer, the
|
|
25
|
+
# full validator, the ready-set analyzer, the input builder, the worktree
|
|
25
26
|
# module, the ledger write, git itself - is an injectable keyword argument
|
|
26
27
|
# with a real default, so a test never touches a real repository or a real
|
|
27
28
|
# filesystem outside its own tmpdir.
|
|
@@ -31,7 +32,7 @@ module RunnerDispatch
|
|
|
31
32
|
DEFAULT_LIMIT = 2
|
|
32
33
|
|
|
33
34
|
# The return-schema instruction (327 D5): rides in the dispatch PLAN, never
|
|
34
|
-
# inside the
|
|
35
|
+
# inside the node input, so `input=<sha>` keeps naming a reproducible input
|
|
35
36
|
# (matrix row 5.23). NodeReturn.parse (n4) is this text's implementation.
|
|
36
37
|
RETURN_CONTRACT = <<~TEXT.freeze
|
|
37
38
|
RETURN CONTRACT: reply with exactly one YAML document as your final
|
|
@@ -51,13 +52,13 @@ module RunnerDispatch
|
|
|
51
52
|
SPAWN_AGENT = "plastic-executor"
|
|
52
53
|
|
|
53
54
|
# matrix 6.1/6.2: one spawn block per dispatched node - agent, the model
|
|
54
|
-
# RunnerPolicy.model_for resolved, the
|
|
55
|
-
# (
|
|
55
|
+
# RunnerPolicy.model_for resolved, the node input path, the one test command
|
|
56
|
+
# (NodeInput.test_command_block, n4), and the call cap (n2) - fenced so a
|
|
56
57
|
# session pastes it straight into the Agent tool (327 D42: the runner
|
|
57
58
|
# itself never spawns).
|
|
58
|
-
def spawn_block(model:,
|
|
59
|
-
lines = ["agent: #{agent}", "model: #{model}", "
|
|
60
|
-
|
|
59
|
+
def spawn_block(model:, input:, test_command:, call_cap:, agent: SPAWN_AGENT)
|
|
60
|
+
lines = ["agent: #{agent}", "model: #{model}", "input: #{input}", test_command,
|
|
61
|
+
NodeInput.call_cap_sentence(call_cap)]
|
|
61
62
|
(["```"] + lines + ["```"]).join("\n")
|
|
62
63
|
end
|
|
63
64
|
|
|
@@ -65,16 +66,25 @@ module RunnerDispatch
|
|
|
65
66
|
# :errors, :rearm_command, :dispatched, :stop, :parked, :status, :blockers,
|
|
66
67
|
# :plan - fields that do not apply to a given outcome stay nil/empty rather
|
|
67
68
|
# than being omitted, so a caller never has to guard with `dig`.
|
|
68
|
-
def dispatch(context, limit: DEFAULT_LIMIT, now: Time.now, config: {}, caps: ReadySet::DEFAULT_CAPS,
|
|
69
|
+
def dispatch(context, limit: DEFAULT_LIMIT, now: Time.now, config: {}, harness: nil, caps: ReadySet::DEFAULT_CAPS,
|
|
69
70
|
validator: WorkGraphValidator.method(:validate),
|
|
70
71
|
ready_analyzer: ReadySet.method(:analyze),
|
|
71
|
-
|
|
72
|
+
input_builder: NodeInput.method(:build),
|
|
72
73
|
worktree: NodeWorktree,
|
|
73
74
|
ledger: NodeLedger,
|
|
74
|
-
runner: Worktree::ShellRunner.new
|
|
75
|
+
runner: Worktree::ShellRunner.new,
|
|
76
|
+
harness_adapter: HarnessAdapter)
|
|
75
77
|
intent_dir = context.intent_dir
|
|
76
78
|
savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
|
|
77
79
|
|
|
80
|
+
# Intent 340b, G7c, n1, rows 1.19/1.20/D21: resolved ONCE for the whole
|
|
81
|
+
# step through HarnessAdapter, never a literal - `harness:` (this call's
|
|
82
|
+
# `--harness` override, or nil) wins over `config`'s own `agent.type`.
|
|
83
|
+
# Every node this call dispatches carries the SAME value, and the
|
|
84
|
+
# caller (scripts/runner) reads it back off the report to pick which
|
|
85
|
+
# harness's block to render.
|
|
86
|
+
harness_key = harness_adapter.resolve_key(config: config, override: harness)
|
|
87
|
+
|
|
78
88
|
# Row 5.31/5.32: this is RunnerDispatch's OWN lock check, never a shelled
|
|
79
89
|
# `node-transition` call - append_transition below is used in-process
|
|
80
90
|
# (RunnerAbsorb's own pattern), so nothing here inherits node-transition's
|
|
@@ -122,7 +132,7 @@ module RunnerDispatch
|
|
|
122
132
|
dispatched = []
|
|
123
133
|
parked = []
|
|
124
134
|
stop = nil
|
|
125
|
-
|
|
135
|
+
input_failures = []
|
|
126
136
|
ceiling_blocked = false
|
|
127
137
|
|
|
128
138
|
analysis[:ranked_ready].each do |row|
|
|
@@ -161,11 +171,11 @@ module RunnerDispatch
|
|
|
161
171
|
next
|
|
162
172
|
end
|
|
163
173
|
|
|
164
|
-
result = dispatch_one(context, node: node, kind: kind, now: now, config: config,
|
|
165
|
-
edges: edges, nodes_decl: nodes_decl,
|
|
174
|
+
result = dispatch_one(context, node: node, kind: kind, now: now, config: config, harness: harness_key,
|
|
175
|
+
caps: caps, edges: edges, nodes_decl: nodes_decl, input_builder: input_builder,
|
|
166
176
|
worktree: worktree, ledger: ledger, runner: runner)
|
|
167
|
-
if result[:
|
|
168
|
-
|
|
177
|
+
if result[:input_build_failed]
|
|
178
|
+
input_failures << result
|
|
169
179
|
next
|
|
170
180
|
end
|
|
171
181
|
next unless result[:ok]
|
|
@@ -182,7 +192,8 @@ module RunnerDispatch
|
|
|
182
192
|
RunnerCore.render_status(context) if dispatched.any? || stop || parked.any?
|
|
183
193
|
|
|
184
194
|
build_report(context: context, dispatched: dispatched, stop: stop, parked: parked,
|
|
185
|
-
ceiling_blocked: ceiling_blocked,
|
|
195
|
+
ceiling_blocked: ceiling_blocked, input_failures: input_failures, running_count: running_count,
|
|
196
|
+
harness: harness_key)
|
|
186
197
|
end
|
|
187
198
|
|
|
188
199
|
# --- guarded re-entries into graph.md (M9) ----------------------------------
|
|
@@ -201,10 +212,10 @@ module RunnerDispatch
|
|
|
201
212
|
end
|
|
202
213
|
private_class_method :safe_analyze
|
|
203
214
|
|
|
204
|
-
# --- one node's whole dispatch (
|
|
215
|
+
# --- one node's whole dispatch (node input, lease, `running`) -------------------
|
|
205
216
|
|
|
206
|
-
def dispatch_one(context, node:, kind:, now:, config:, caps:, edges:, nodes_decl:,
|
|
207
|
-
ledger:, runner:)
|
|
217
|
+
def dispatch_one(context, node:, kind:, now:, config:, harness:, caps:, edges:, nodes_decl:, input_builder:,
|
|
218
|
+
worktree:, ledger:, runner:)
|
|
208
219
|
intent_dir = context.intent_dir
|
|
209
220
|
savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
|
|
210
221
|
|
|
@@ -220,7 +231,7 @@ module RunnerDispatch
|
|
|
220
231
|
pre_existing_worktree = worktree_pre_existing?(worktree, context, node, kind)
|
|
221
232
|
|
|
222
233
|
# Row 5.16/5.29: only a `work` node gets a worktree, and this is the
|
|
223
|
-
# node-scoped `worktree_reader:` D23 injects into
|
|
234
|
+
# node-scoped `worktree_reader:` D23 injects into NodeInput.build - it
|
|
224
235
|
# names THIS node's own worktree and branch, never the intent's.
|
|
225
236
|
provisioned = RunnerPolicy.worktree?(kind) ? worktree.provision(context, node: node, kind: kind, runner: runner)
|
|
226
237
|
: unprovisioned
|
|
@@ -228,32 +239,36 @@ module RunnerDispatch
|
|
|
228
239
|
{ "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned] }
|
|
229
240
|
end
|
|
230
241
|
|
|
231
|
-
# Row 5.18: build the
|
|
232
|
-
# naming bytes that do not exist yet is worse than a
|
|
242
|
+
# Row 5.18: build the node input BEFORE writing `running` - a `running` line
|
|
243
|
+
# naming bytes that do not exist yet is worse than a node input nobody reads.
|
|
233
244
|
# Row 5.30: `force: true` always - a fresh attempt number this dispatch
|
|
234
245
|
# computes is, by construction, never one `running` has already claimed,
|
|
235
246
|
# so an existing file at that path is always an orphan from a step that
|
|
236
|
-
# crashed between building the
|
|
247
|
+
# crashed between building the node input and writing `running`, safe to
|
|
237
248
|
# overwrite outright.
|
|
238
249
|
# Row 5.20/10.8: the node's own declared budget: (M7) - nil when the node
|
|
239
|
-
# names none, in which case
|
|
250
|
+
# names none, in which case NodeInput.build falls back to its own
|
|
240
251
|
# default (row 10.9).
|
|
241
|
-
build_result =
|
|
252
|
+
build_result = input_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
|
|
242
253
|
model: model, force: true, worktree_reader: node_reader,
|
|
243
254
|
budget_tokens: node_declared_budget(intent_dir, node), call_cap: calls_cap)
|
|
244
255
|
unless build_result[:ok]
|
|
245
|
-
# M6: a failed
|
|
256
|
+
# M6: a failed input build never leaves an orphan worktree behind, and
|
|
246
257
|
# its errors travel back up so the step's report can name the node and
|
|
247
258
|
# the reason instead of a bare "stalled" (row 10.6/10.7).
|
|
248
|
-
rollback_dispatch(context, node: node, kind: kind,
|
|
259
|
+
rollback_dispatch(context, node: node, kind: kind, input_path: build_result[:path], runner: runner,
|
|
249
260
|
worktree: worktree, created_this_dispatch: !pre_existing_worktree)
|
|
250
|
-
return { ok: false,
|
|
261
|
+
return { ok: false, input_build_failed: true, node: node, errors: build_result[:errors] }
|
|
251
262
|
end
|
|
252
263
|
|
|
253
264
|
precondition = lambda do |c|
|
|
254
265
|
ReadySet.ready?(content: c, subject: node, graph: { edges: edges }, nodes: nodes_decl, caps: caps)[:ready]
|
|
255
266
|
end
|
|
256
|
-
|
|
267
|
+
# Row 1.19/1.20/D21: harness= rides alongside model= on every `running`
|
|
268
|
+
# line, resolved once by the caller through HarnessAdapter and threaded
|
|
269
|
+
# straight through here - never re-resolved, never a literal.
|
|
270
|
+
fields = { holder: holder, expires: expires, input: build_result[:sha], model: model, harness: harness,
|
|
271
|
+
calls: calls_cap }
|
|
257
272
|
|
|
258
273
|
result = begin
|
|
259
274
|
ledger.append_transition(savepoint_path, subject: node, state: "running", fields: fields, now: now,
|
|
@@ -266,18 +281,18 @@ module RunnerDispatch
|
|
|
266
281
|
# side effects this method already produced - the node never ran, so
|
|
267
282
|
# nothing may act like it did.
|
|
268
283
|
unless result == :written
|
|
269
|
-
rollback_dispatch(context, node: node, kind: kind,
|
|
284
|
+
rollback_dispatch(context, node: node, kind: kind, input_path: build_result[:path], runner: runner,
|
|
270
285
|
worktree: worktree, created_this_dispatch: !pre_existing_worktree)
|
|
271
286
|
return { ok: false }
|
|
272
287
|
end
|
|
273
288
|
|
|
274
|
-
test_command =
|
|
275
|
-
spawn = spawn_block(model: model,
|
|
289
|
+
test_command = NodeInput.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
|
|
290
|
+
spawn = spawn_block(model: model, input: build_result[:path], test_command: test_command, call_cap: calls_cap)
|
|
276
291
|
|
|
277
292
|
{
|
|
278
293
|
ok: true,
|
|
279
294
|
entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, worktree: provisioned[:path],
|
|
280
|
-
|
|
295
|
+
input: build_result[:path], spawn: spawn },
|
|
281
296
|
}
|
|
282
297
|
end
|
|
283
298
|
|
|
@@ -316,10 +331,10 @@ module RunnerDispatch
|
|
|
316
331
|
|
|
317
332
|
# Row 10.16/M13: `created_this_dispatch:` gates the worktree half of the
|
|
318
333
|
# rollback - a worktree this call did not create (kept on disk by a prior
|
|
319
|
-
# attempt's failed_verification, D7) is never touched, only a
|
|
320
|
-
# call's own `
|
|
321
|
-
def rollback_dispatch(context, node:, kind:,
|
|
322
|
-
File.delete(
|
|
334
|
+
# attempt's failed_verification, D7) is never touched, only a node input this
|
|
335
|
+
# call's own `input_builder` may have written is ever deleted.
|
|
336
|
+
def rollback_dispatch(context, node:, kind:, input_path:, runner:, worktree:, created_this_dispatch:)
|
|
337
|
+
File.delete(input_path) if input_path && File.exist?(input_path)
|
|
323
338
|
return unless RunnerPolicy.worktree?(kind)
|
|
324
339
|
return unless created_this_dispatch
|
|
325
340
|
|
|
@@ -400,7 +415,7 @@ module RunnerDispatch
|
|
|
400
415
|
|
|
401
416
|
def empty_result
|
|
402
417
|
{ ok: true, reason: nil, errors: [], rearm_command: nil, dispatched: [], stop: nil, parked: [],
|
|
403
|
-
status: nil, blockers: [], plan: nil }
|
|
418
|
+
status: nil, blockers: [], plan: nil, harness: nil }
|
|
404
419
|
end
|
|
405
420
|
private_class_method :empty_result
|
|
406
421
|
|
|
@@ -414,10 +429,10 @@ module RunnerDispatch
|
|
|
414
429
|
end
|
|
415
430
|
private_class_method :invalid_graph_result
|
|
416
431
|
|
|
417
|
-
def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false,
|
|
418
|
-
running_count: 0)
|
|
432
|
+
def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false, input_failures: [],
|
|
433
|
+
running_count: 0, harness: nil)
|
|
419
434
|
base = empty_result.merge(dispatched: dispatched, stop: stop, parked: parked,
|
|
420
|
-
plan: render_plan(dispatched))
|
|
435
|
+
plan: render_plan(dispatched), harness: harness)
|
|
421
436
|
|
|
422
437
|
if dispatched.any?
|
|
423
438
|
base.merge(status: "dispatched")
|
|
@@ -438,21 +453,21 @@ module RunnerDispatch
|
|
|
438
453
|
if complete
|
|
439
454
|
base.merge(status: "complete")
|
|
440
455
|
else
|
|
441
|
-
# M6/row 10.7: a failed
|
|
456
|
+
# M6/row 10.7: a failed input build writes no ledger line at all,
|
|
442
457
|
# so `named_blockers` (ledger-derived) never sees it on its own -
|
|
443
458
|
# its own node and reason are named here so `stalled` never prints
|
|
444
459
|
# bare.
|
|
445
|
-
blockers = named_blockers(context) +
|
|
460
|
+
blockers = named_blockers(context) + input_failures.map { |f| input_failure_blocker(f) }
|
|
446
461
|
base.merge(status: "stalled", blockers: blockers)
|
|
447
462
|
end
|
|
448
463
|
end
|
|
449
464
|
end
|
|
450
465
|
private_class_method :build_report
|
|
451
466
|
|
|
452
|
-
def
|
|
453
|
-
"#{failure[:node]}:
|
|
467
|
+
def input_failure_blocker(failure)
|
|
468
|
+
"#{failure[:node]}: node input build failed (#{Array(failure[:errors]).join('; ')})"
|
|
454
469
|
end
|
|
455
|
-
private_class_method :
|
|
470
|
+
private_class_method :input_failure_blocker
|
|
456
471
|
|
|
457
472
|
# Row 5.25a/5.26: every unfinished node's own blockers, with ReadySet's
|
|
458
473
|
# hard-attempt-cap wording renamed so it reads as the named backstop it is
|
|
@@ -479,9 +494,9 @@ module RunnerDispatch
|
|
|
479
494
|
private_class_method :name_blocker
|
|
480
495
|
|
|
481
496
|
# Row 5.22/5.23/5.24: one machine-readable (YAML) document naming, per
|
|
482
|
-
# dispatched node, the
|
|
497
|
+
# dispatched node, the node input path, the model, the worktree, the kind and
|
|
483
498
|
# the role, plus the return contract ONCE at the top level - never inside
|
|
484
|
-
# any one node's
|
|
499
|
+
# any one node's input. Row 6.4: "spawn" carries the same, already fully
|
|
485
500
|
# rendered spawn block for each dispatched node in order, so any reader of
|
|
486
501
|
# this data (YAML today, JSON if it is ever re-serialized) finds it under
|
|
487
502
|
# `spawn` rather than re-deriving it from the other fields.
|
|
@@ -492,7 +507,7 @@ module RunnerDispatch
|
|
|
492
507
|
"return_contract" => RETURN_CONTRACT,
|
|
493
508
|
"dispatch" => dispatched.map do |d|
|
|
494
509
|
{ "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
|
|
495
|
-
"worktree" => d[:worktree], "
|
|
510
|
+
"worktree" => d[:worktree], "input" => d[:input] }
|
|
496
511
|
end,
|
|
497
512
|
"spawn" => dispatched.map { |d| d[:spawn] }
|
|
498
513
|
)
|
|
@@ -32,7 +32,7 @@ require_relative "savepoint"
|
|
|
32
32
|
# newer commits is extended instead, up to twice per attempt (row 2.7); the
|
|
33
33
|
# extension is never a ledger transition (`running` cannot re-enter `running`
|
|
34
34
|
# under the transition layer), so it is one line appended to
|
|
35
|
-
#
|
|
35
|
+
# attempts/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
|
|
36
36
|
# count (row 2.18), never trusted from the caller. A third expiry reclaims
|
|
37
37
|
# regardless of new commits.
|
|
38
38
|
#
|
|
@@ -199,15 +199,15 @@ module RunnerSweep
|
|
|
199
199
|
|
|
200
200
|
# Row 2.18: the attempt number comes from the ledger's own count of
|
|
201
201
|
# `running` lines since the subject's last terminal line - the exact same
|
|
202
|
-
# arithmetic
|
|
203
|
-
# extensions file for a `running` line always matches the
|
|
202
|
+
# arithmetic NodeInput uses to name that attempt's input file, so the
|
|
203
|
+
# extensions file for a `running` line always matches the node input it extends.
|
|
204
204
|
def current_attempt(entries, subject)
|
|
205
205
|
ReadySet.attempts_count(entries, subject)
|
|
206
206
|
end
|
|
207
207
|
private_class_method :current_attempt
|
|
208
208
|
|
|
209
209
|
def extensions_path(intent_dir, node, attempt)
|
|
210
|
-
File.join(intent_dir.to_s, "
|
|
210
|
+
File.join(intent_dir.to_s, "attempts", "#{node}--a#{attempt}.extensions")
|
|
211
211
|
end
|
|
212
212
|
private_class_method :extensions_path
|
|
213
213
|
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "time"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require_relative "runner_core"
|
|
8
|
+
require_relative "runner_sweep"
|
|
9
|
+
require_relative "runner_absorb"
|
|
10
|
+
require_relative "runner_dispatch"
|
|
11
|
+
require_relative "node_worktree"
|
|
12
|
+
require_relative "lock"
|
|
13
|
+
|
|
14
|
+
# RunnerUntilEmpty (intent 340b, G7c, n7): the Codex loop. `runner
|
|
15
|
+
# until-empty` composes `step` and `node-run` itself, since Codex has no
|
|
16
|
+
# session on the other end to make the subagent calls `step` only ever
|
|
17
|
+
# prints a plan for - dispatch, run at most two `node-run` subprocesses at
|
|
18
|
+
# once, absorb each return, and around again, stopping on complete,
|
|
19
|
+
# stalled, needs_decision, a refused step, or an iteration cap.
|
|
20
|
+
#
|
|
21
|
+
# #step_once is one turn - abort-if-merging, the heartbeat, absorb every
|
|
22
|
+
# return this call carries (serially, a plain Ruby loop, never a thread),
|
|
23
|
+
# reclaim, reap, then dispatch - the same fixed order scripts/runner's own
|
|
24
|
+
# `run_step_body` uses, returning data rather than printing it, so #run can
|
|
25
|
+
# make its own stop/continue decision instead of scraping stdout. #run is
|
|
26
|
+
# the loop: it grows `active` from whatever #step_once actually dispatched
|
|
27
|
+
# (already capped at the concurrency ceiling by RunnerDispatch's own
|
|
28
|
+
# running-count check, never a second cap layered on top here), spawns one
|
|
29
|
+
# real `node-run` subprocess per newly dispatched node, and blocks for AT
|
|
30
|
+
# LEAST one of them to finish before it ever calls #step_once again - so an
|
|
31
|
+
# absorb is always a single, uncontended call, and a second `node-run`
|
|
32
|
+
# finishing while the first is mid-absorb simply waits its own turn as the
|
|
33
|
+
# next iteration's `returns`, never as a second in-flight absorb.
|
|
34
|
+
#
|
|
35
|
+
# A shared `<repo>/.git` between two worktrees is a real `index.lock`
|
|
36
|
+
# collision risk at commit time; this module does not try to prevent it by
|
|
37
|
+
# serializing the two `node-run` processes (that would throw away the
|
|
38
|
+
# concurrency this node exists to deliver) - it records the collision, on
|
|
39
|
+
# whichever side actually hit it, and lets the kind's retry cap (already
|
|
40
|
+
# built) recover the node on a later attempt.
|
|
41
|
+
#
|
|
42
|
+
# Pure and dependency-injected: `step:`, `node_run_spawner:` and
|
|
43
|
+
# `node_run_waiter:` are keyword seams with real defaults, so a test can
|
|
44
|
+
# drive #run entirely off doubles (no real subprocess, no real git repo)
|
|
45
|
+
# for every row except the ones that are precisely about real OS
|
|
46
|
+
# concurrency or the real CLI arm.
|
|
47
|
+
module RunnerUntilEmpty
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
MAX_CONCURRENCY = 2
|
|
51
|
+
DEFAULT_MAX_ITERATIONS = 200
|
|
52
|
+
|
|
53
|
+
NODE_RUN_SCRIPT = File.expand_path(File.join(__dir__, "..", "node-run")).freeze
|
|
54
|
+
|
|
55
|
+
# run(context, harness:) -> {status:, iterations:, ...}. `status` is one
|
|
56
|
+
# of complete, stalled, needs_decision, iteration_cap, refused - never
|
|
57
|
+
# anything else, and the loop returns the instant it reaches one of them.
|
|
58
|
+
def run(context, harness: nil, max_iterations: DEFAULT_MAX_ITERATIONS,
|
|
59
|
+
step: method(:step_once), node_run_spawner: method(:spawn_node_run),
|
|
60
|
+
node_run_waiter: method(:wait_for_node_run), out: $stdout)
|
|
61
|
+
active = {}
|
|
62
|
+
pending_returns = {}
|
|
63
|
+
iterations = 0
|
|
64
|
+
|
|
65
|
+
loop do
|
|
66
|
+
iterations += 1
|
|
67
|
+
if iterations > max_iterations
|
|
68
|
+
out.puts "until-empty: stopped at the iteration cap (#{max_iterations})"
|
|
69
|
+
return { status: "iteration_cap", iterations: iterations - 1 }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result = step.call(context, harness: harness, returns: pending_returns)
|
|
73
|
+
pending_returns = {}
|
|
74
|
+
|
|
75
|
+
unless result[:ok]
|
|
76
|
+
out.puts "until-empty: stopped, step refused (#{result[:reason]})"
|
|
77
|
+
return { status: "refused", reason: result[:reason], iterations: iterations }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Array(result[:absorbed]).each { |a| out.puts "absorbed #{a[:node]}: #{a[:state]}" }
|
|
81
|
+
|
|
82
|
+
case result[:status]
|
|
83
|
+
when "complete"
|
|
84
|
+
out.puts "complete"
|
|
85
|
+
return { status: "complete", iterations: iterations }
|
|
86
|
+
when "stalled"
|
|
87
|
+
out.puts "stalled"
|
|
88
|
+
Array(result[:blockers]).each { |b| out.puts "blocked: #{b}" }
|
|
89
|
+
return { status: "stalled", iterations: iterations, blockers: result[:blockers] }
|
|
90
|
+
when "needs_decision"
|
|
91
|
+
stop = result[:stop]
|
|
92
|
+
out.puts "needs_decision: #{stop[:node]} - #{stop[:question]}"
|
|
93
|
+
out.puts stop[:answer_command]
|
|
94
|
+
return { status: "needs_decision", iterations: iterations, stop: stop }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Array(result[:dispatched]).each do |node|
|
|
98
|
+
next if active.key?(node)
|
|
99
|
+
|
|
100
|
+
active[node] = node_run_spawner.call(context, node: node, harness: harness)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if active.empty?
|
|
104
|
+
out.puts "stalled"
|
|
105
|
+
out.puts "blocked: queued with no active node-run to wait on"
|
|
106
|
+
return { status: "stalled", iterations: iterations,
|
|
107
|
+
blockers: ["queued with no active node-run to wait on"] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
finished = Array(node_run_waiter.call(active))
|
|
111
|
+
finished.each do |f|
|
|
112
|
+
active.delete(f[:node])
|
|
113
|
+
out.puts "until-empty: index.lock collision recorded for #{f[:node]}" if index_lock_collision?(f)
|
|
114
|
+
pending_returns[f[:node]] = f[:return_path]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# --- one turn ----------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# step_once(context, harness:, returns:) -> {ok:, reason:, status:,
|
|
122
|
+
# dispatched: [node ids], stop:, blockers:, absorbed: [{node:, state:}]}.
|
|
123
|
+
# Every absorb in `returns` runs in this one Ruby method, in the order
|
|
124
|
+
# `returns` iterates, before `dispatch` ever runs (row 7.3) - never a
|
|
125
|
+
# second call to this method from another thread while one is already in
|
|
126
|
+
# flight (row 7.2), because #run above never starts a second one.
|
|
127
|
+
def step_once(context, harness:, returns: {}, allow_core_drift: false, now: Time.now,
|
|
128
|
+
sweep: RunnerSweep, absorb: RunnerAbsorb, dispatch: RunnerDispatch,
|
|
129
|
+
worktree: NodeWorktree, lock: Lock, config_loader: method(:load_agent_config))
|
|
130
|
+
abort_result = sweep.abort_if_merging(context)
|
|
131
|
+
unless abort_result[:ok]
|
|
132
|
+
return refusal("merge_in_progress", abort_result[:error])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
lock.heartbeat(context.intent_dir, session: context.session) unless context.session.to_s.strip.empty?
|
|
136
|
+
|
|
137
|
+
return refusal("lock_not_held", nil) unless context.session
|
|
138
|
+
|
|
139
|
+
absorbed = returns.map do |node, path|
|
|
140
|
+
result = absorb.absorb(context, node: node, return_path: path, allow_core_drift: allow_core_drift, now: now)
|
|
141
|
+
{ node: node, state: result[:state] }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
swept = sweep.reclaim(context, skip: returns.keys, now: now)
|
|
145
|
+
reaped = worktree.reap(context)
|
|
146
|
+
|
|
147
|
+
agent_config = config_loader.call(context.plastic_home)
|
|
148
|
+
dispatch_result = dispatch.dispatch(context, config: agent_config, harness: harness, now: now)
|
|
149
|
+
unless dispatch_result[:ok]
|
|
150
|
+
return refusal(dispatch_result[:reason], Array(dispatch_result[:errors]).join("; "))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
ok: true, reason: nil, status: dispatch_result[:status],
|
|
155
|
+
dispatched: dispatch_result[:dispatched].map { |d| d[:node] },
|
|
156
|
+
stop: dispatch_result[:stop], blockers: dispatch_result[:blockers], absorbed: absorbed,
|
|
157
|
+
reclaimed: swept[:reclaimed], extended: swept[:extended], reaped: reaped[:removed],
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def refusal(reason, detail)
|
|
162
|
+
{ ok: false, reason: reason, detail: detail, status: nil, dispatched: [], stop: nil, blockers: [],
|
|
163
|
+
absorbed: [] }
|
|
164
|
+
end
|
|
165
|
+
private_class_method :refusal
|
|
166
|
+
|
|
167
|
+
# Mirrors scripts/runner's own Runner.load_agent_config exactly (row
|
|
168
|
+
# 1.1 of nodes/n1.md): a missing or unparseable config.yml reads as {},
|
|
169
|
+
# never raises, and it is read fresh every call rather than cached, since
|
|
170
|
+
# a long-running until-empty process must see a config edit made mid-run
|
|
171
|
+
# the same way a fresh `step` call always would.
|
|
172
|
+
def load_agent_config(plastic_home)
|
|
173
|
+
path = File.join(plastic_home.to_s, "config.yml")
|
|
174
|
+
return {} unless File.exist?(path)
|
|
175
|
+
|
|
176
|
+
YAML.safe_load(File.read(path)) || {}
|
|
177
|
+
rescue StandardError
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# --- node-run, the real subprocess --------------------------------------------
|
|
182
|
+
|
|
183
|
+
# spawn_node_run(context, node:, harness:) -> a handle {node:, pid:,
|
|
184
|
+
# stdout_io:, stderr_io:}. `harness:` is accepted for symmetry with
|
|
185
|
+
# `step_once` even though node-run itself never takes a --harness flag -
|
|
186
|
+
# the harness was already recorded on the node's own `running` line by the
|
|
187
|
+
# dispatch this call followed, and node-run reads its node input off that
|
|
188
|
+
# line, never off this argv. RUBYOPT is cleared explicitly, the same
|
|
189
|
+
# contract every other ruby-spawning site in this tree carries.
|
|
190
|
+
def spawn_node_run(context, node:, harness: nil)
|
|
191
|
+
argv = [RbConfig.ruby, NODE_RUN_SCRIPT, context.intent_dir, "--node", node]
|
|
192
|
+
argv += ["--session", context.session] if context.session
|
|
193
|
+
|
|
194
|
+
out_r, out_w = IO.pipe
|
|
195
|
+
err_r, err_w = IO.pipe
|
|
196
|
+
pid = Process.spawn({ "RUBYOPT" => nil }, *argv, out: out_w, err: err_w)
|
|
197
|
+
out_w.close
|
|
198
|
+
err_w.close
|
|
199
|
+
{ node: node, pid: pid, stdout_io: out_r, stderr_io: err_r }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# wait_for_node_run(active) -> [{node:, return_path:, stderr:}] for
|
|
203
|
+
# whichever ONE handle in `active` finishes first (real concurrency
|
|
204
|
+
# between two live node-run processes; this call itself blocks on
|
|
205
|
+
# whichever exits first, never both at once). `active` is keyed by node,
|
|
206
|
+
# so a caller never needs to search its own values by pid outside this
|
|
207
|
+
# method.
|
|
208
|
+
def wait_for_node_run(active)
|
|
209
|
+
pid, = Process.waitpid2(-1)
|
|
210
|
+
handle = active.values.find { |h| h[:pid] == pid }
|
|
211
|
+
return [] unless handle
|
|
212
|
+
|
|
213
|
+
[{ node: handle[:node], return_path: read_and_close(handle[:stdout_io]).to_s.strip,
|
|
214
|
+
stderr: read_and_close(handle[:stderr_io]) }]
|
|
215
|
+
rescue Errno::ECHILD
|
|
216
|
+
[]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def read_and_close(io)
|
|
220
|
+
io.read
|
|
221
|
+
rescue StandardError
|
|
222
|
+
""
|
|
223
|
+
ensure
|
|
224
|
+
begin
|
|
225
|
+
io.close
|
|
226
|
+
rescue StandardError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
private_class_method :read_and_close
|
|
231
|
+
|
|
232
|
+
# index_lock_collision?(finished) -> true when either the node-run
|
|
233
|
+
# subprocess's own stderr or the return body it wrote mentions
|
|
234
|
+
# `index.lock` - the shared signature of a git ref/index lock collision
|
|
235
|
+
# between two worktrees writing into one repository's `.git` at the same
|
|
236
|
+
# time (row 7.4). Never used to change what the loop does next: the
|
|
237
|
+
# finished node's return is absorbed exactly like any other, and the
|
|
238
|
+
# kind's own retry cap is what recovers it.
|
|
239
|
+
def index_lock_collision?(finished)
|
|
240
|
+
parts = [finished[:stderr].to_s]
|
|
241
|
+
path = finished[:return_path]
|
|
242
|
+
parts << safe_read_file(path) if path && !path.empty?
|
|
243
|
+
parts.join("\n").match?(/index\.lock/i)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def safe_read_file(path)
|
|
247
|
+
File.exist?(path) ? File.read(path) : ""
|
|
248
|
+
rescue StandardError
|
|
249
|
+
""
|
|
250
|
+
end
|
|
251
|
+
private_class_method :safe_read_file
|
|
252
|
+
end
|