@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.
Files changed (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. 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 "node_packet"
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 packets, writes
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 packet builder, the worktree
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 packet, so `packet=<sha>` keeps naming a reproducible input
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 packet path, the one test command
55
- # (NodePacket.test_command_block, n4), and the call cap (n2) - fenced so a
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:, packet:, test_command:, call_cap:, agent: SPAWN_AGENT)
59
- lines = ["agent: #{agent}", "model: #{model}", "packet: #{packet}", test_command,
60
- NodePacket.call_cap_sentence(call_cap)]
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
- packet_builder: NodePacket.method(:build),
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
- packet_failures = []
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, caps: caps,
165
- edges: edges, nodes_decl: nodes_decl, packet_builder: packet_builder,
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[:packet_build_failed]
168
- packet_failures << result
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, packet_failures: packet_failures, running_count: running_count)
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 (packet, lease, `running`) -------------------
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:, packet_builder:, worktree:,
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 NodePacket.build - it
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 packet BEFORE writing `running` - a `running` line
232
- # naming bytes that do not exist yet is worse than a packet nobody reads.
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 packet and writing `running`, safe to
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 NodePacket.build falls back to its own
250
+ # names none, in which case NodeInput.build falls back to its own
240
251
  # default (row 10.9).
241
- build_result = packet_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
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 packet build never leaves an orphan worktree behind, and
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, packet_path: build_result[:path], runner: runner,
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, packet_build_failed: true, node: node, errors: build_result[:errors] }
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
- fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model, calls: calls_cap }
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, packet_path: build_result[:path], runner: runner,
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 = NodePacket.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
275
- spawn = spawn_block(model: model, packet: build_result[:path], test_command: test_command, call_cap: calls_cap)
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
- packet: build_result[:path], spawn: spawn },
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 packet this
320
- # call's own `packet_builder` may have written is ever deleted.
321
- def rollback_dispatch(context, node:, kind:, packet_path:, runner:, worktree:, created_this_dispatch:)
322
- File.delete(packet_path) if packet_path && File.exist?(packet_path)
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, packet_failures: [],
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 packet build writes no ledger line at all,
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) + packet_failures.map { |f| packet_failure_blocker(f) }
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 packet_failure_blocker(failure)
453
- "#{failure[:node]}: packet build failed (#{Array(failure[:errors]).join('; ')})"
467
+ def input_failure_blocker(failure)
468
+ "#{failure[:node]}: node input build failed (#{Array(failure[:errors]).join('; ')})"
454
469
  end
455
- private_class_method :packet_failure_blocker
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 packet path, the model, the worktree, the kind and
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 packet. Row 6.4: "spawn" carries the same, already fully
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], "packet" => d[:packet] }
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
- # packets/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
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 NodePacket uses to name that attempt's packet file, so the
203
- # extensions file for a `running` line always matches the packet it extends.
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, "packets", "#{node}--a#{attempt}.extensions")
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