@zalom/plastic 2.0.0-alpha.21 → 2.0.0-alpha.23
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 +20 -18
- 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/doctor.rb +159 -10
- package/scripts/end-intent +3 -3
- package/scripts/graph-measure +249 -0
- package/scripts/hook-capture +1 -0
- package/scripts/hook-savepoint +24 -2
- package/scripts/hook-session-start +333 -321
- package/scripts/hook-stop +57 -0
- package/scripts/insight-append +18 -4
- package/scripts/lib/active_delivery.rb +61 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/engine_permissions.rb +88 -0
- 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 +408 -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 +36 -5
- 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_projection.rb +1 -1
- package/scripts/lib/installer_core.rb +112 -6
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/node_ledger.rb +8 -1
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.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/runner_absorb.rb +31 -5
- package/scripts/lib/runner_dispatch.rb +26 -11
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +3 -3
- package/scripts/lib/session_git.rb +2 -2
- 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/new-intent +1 -1
- package/scripts/node-run +224 -0
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/skill-lint +115 -6
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +54 -56
- package/skills/auto/references/agent-architecture.md +10 -8
- package/skills/auto/references/human-report-contract.md +1 -1
- package/skills/conventions/references/completion-and-done.md +7 -7
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/maintenance-and-revisions.md +1 -1
- package/skills/doctor/SKILL.md +3 -3
- package/skills/doctor/report.md +1 -1
- package/skills/intent-continuing/references/boarding-matrix.md +2 -2
- package/skills/intent-creating/SKILL.md +58 -133
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-ending/SKILL.md +48 -56
- package/skills/intent-ending/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +43 -136
- package/skills/intent-speccing/SKILL.md +3 -0
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/SKILL.md +2 -1
- package/skills/tutorial/references/track-1-guided.md +21 -40
- package/skills/tutorial/references/track-2-auto.md +3 -3
- package/templates/agents.md +2 -2
|
@@ -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 packet 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
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "time"
|
|
7
|
+
require "rbconfig"
|
|
8
|
+
require_relative "worktree"
|
|
9
|
+
require_relative "runner_core"
|
|
10
|
+
require_relative "runner_sweep"
|
|
11
|
+
require_relative "ready_set"
|
|
12
|
+
require_relative "node_ledger"
|
|
13
|
+
require_relative "savepoint"
|
|
14
|
+
require_relative "atomic_write"
|
|
15
|
+
require_relative "runner_until_empty"
|
|
16
|
+
require_relative "harness_adapter"
|
|
17
|
+
require_relative "meter_watch"
|
|
18
|
+
|
|
19
|
+
# RunnerWatch (intent 340a, G7b, n1): one tick over disk truth. The whole
|
|
20
|
+
# watch minus the CLI and the dispatch branch (340a n2). Composes the
|
|
21
|
+
# existing modules the way RunnerSweep and MeterWatch already do - the
|
|
22
|
+
# git runner, the clock and the sweep module are all injected, nothing
|
|
23
|
+
# reads ENV, nothing evals.
|
|
24
|
+
#
|
|
25
|
+
# Order, fixed by graph.md D2: take a non-blocking lock on
|
|
26
|
+
# `<intent_dir>/watch.lock` for the whole tick (a losing tick is `busy`,
|
|
27
|
+
# writes nothing); `RunnerSweep.abort_if_merging`; refuse the tick outright
|
|
28
|
+
# on a half-finished merge (row 1.3), writing nothing; `RunnerSweep.reclaim`
|
|
29
|
+
# (never `RunnerSweep.run`, D3: that method heartbeats the delivery lease,
|
|
30
|
+
# which would keep a dead lead's lock fresh forever); classify (D4);
|
|
31
|
+
# persist the snapshot and the record line only when `record:` holds.
|
|
32
|
+
module RunnerWatch
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
LOCK_FILENAME = "watch.lock"
|
|
36
|
+
STATE_FILENAME = "watch.state"
|
|
37
|
+
RECORD_FILENAME = "watch.record"
|
|
38
|
+
|
|
39
|
+
# Classes that never dispatch (D8): a finished delivery re-running its own
|
|
40
|
+
# graph on every tick is exactly the bug a timer must not have.
|
|
41
|
+
FINISHED_CLASSES = %w[closed done_unreported].freeze
|
|
42
|
+
|
|
43
|
+
# tick(context, record:, dispatch:, harness:, now:, runner:, sweep:,
|
|
44
|
+
# until_empty:) -> {class:, blockers:, ready:, reclaimed:, dispatched:,
|
|
45
|
+
# tick:, busy:}. `dispatch:` (327 Q6, D7, D8) only ever runs under an
|
|
46
|
+
# explicit ask - never inferred from the class - and only when the lock is
|
|
47
|
+
# held, the class is not finished, and the meter does not read `stop`.
|
|
48
|
+
# `until_empty:` is `RunnerUntilEmpty` by default; a test can inject a
|
|
49
|
+
# double so the whole dispatch path never spawns a real `node-run`
|
|
50
|
+
# subprocess. `harness=` and `meter=` on the record line read `-` only
|
|
51
|
+
# when `dispatch:` itself was never asked for (D5) - every other refusal
|
|
52
|
+
# (an unheld lock, a finished class, a stopped meter) still consults and
|
|
53
|
+
# records the meter, since the tick DID look, it just chose not to act.
|
|
54
|
+
def tick(context, record: true, dispatch: false, harness: nil, now: Time.now,
|
|
55
|
+
runner: Worktree::ShellRunner.new, sweep: RunnerSweep, until_empty: RunnerUntilEmpty)
|
|
56
|
+
intent_dir = context.intent_dir.to_s
|
|
57
|
+
lock_handle = acquire_lock(File.join(intent_dir, LOCK_FILENAME))
|
|
58
|
+
return busy_result unless lock_handle
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
abort_result = sweep.abort_if_merging(context, runner: runner)
|
|
62
|
+
unless abort_result[:ok]
|
|
63
|
+
return {
|
|
64
|
+
class: "merge_in_progress", blockers: [abort_result[:error]], ready: [],
|
|
65
|
+
reclaimed: [], dispatched: [], tick: nil, busy: false,
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
reclaim_result = sweep.reclaim(context, runner: runner, skip: [], now: now)
|
|
70
|
+
reclaimed_ids = Array(reclaim_result[:reclaimed]).map { |r| r[:node] }
|
|
71
|
+
extended = !Array(reclaim_result[:extended]).empty?
|
|
72
|
+
|
|
73
|
+
view = classify(context, runner: runner, now: now, intent_dir: intent_dir, extended: extended)
|
|
74
|
+
|
|
75
|
+
lock_state = context.session ? "held" : "not_held"
|
|
76
|
+
harness_field = "-"
|
|
77
|
+
meter_state = "-"
|
|
78
|
+
dispatched_ids = []
|
|
79
|
+
|
|
80
|
+
# B1: the dispatch loop and the persist step share one inner
|
|
81
|
+
# begin/ensure so a raise mid `until_empty.run` (a launchd SIGTERM, a
|
|
82
|
+
# Process.spawn Errno) still lands the snapshot and the record line
|
|
83
|
+
# with whatever `dispatched_ids` was collected before the raise, and
|
|
84
|
+
# then the exception keeps propagating past this method (the outer
|
|
85
|
+
# `ensure` below still releases the tick lock).
|
|
86
|
+
begin
|
|
87
|
+
if dispatch
|
|
88
|
+
harness_field = blank?(harness) ? "-" : harness.to_s
|
|
89
|
+
meter_state = read_meter_state(context)
|
|
90
|
+
|
|
91
|
+
if context.session && !FINISHED_CLASSES.include?(view[:class]) && meter_state != "stop"
|
|
92
|
+
run_until_empty_dispatch(context, harness: harness, until_empty: until_empty,
|
|
93
|
+
dispatched_ids: dispatched_ids)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
ensure
|
|
97
|
+
if record
|
|
98
|
+
Worktree.ensure_gitignored(context.plastic_home, STATE_FILENAME, runner: runner)
|
|
99
|
+
write_snapshot(intent_dir, view[:snapshot])
|
|
100
|
+
append_record(
|
|
101
|
+
intent_dir, now: now, tick: view[:tick], klass: view[:class],
|
|
102
|
+
reclaimed: reclaimed_ids, ready: view[:ready], dispatched: dispatched_ids,
|
|
103
|
+
harness: harness_field, meter: meter_state, lock: lock_state
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
class: view[:class], blockers: view[:blockers], ready: view[:ready],
|
|
110
|
+
reclaimed: reclaimed_ids, dispatched: dispatched_ids, tick: view[:tick], busy: false,
|
|
111
|
+
}
|
|
112
|
+
ensure
|
|
113
|
+
release_lock(lock_handle)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# install_timer(context, home:, harness_key:, installer:) -> the written
|
|
118
|
+
# plist path. Graph.md D9: the Codex carrier is `runner watch
|
|
119
|
+
# --install-timer`, and it reuses MeterWatch's own writer through
|
|
120
|
+
# `label:`/`arguments:` rather than rendering plist XML here (row 3.5) - a
|
|
121
|
+
# second writer is exactly the drift D9 rules out. The label carries the
|
|
122
|
+
# intent id (row 3.4), so a second intent's timer never overwrites the
|
|
123
|
+
# first's job; `--dispatch --harness codex` rides the arguments only when
|
|
124
|
+
# `HarnessAdapter.unattended_start?` holds for the resolved harness (row
|
|
125
|
+
# 3.3), the same predicate `run_watch` itself already gates `--dispatch`
|
|
126
|
+
# on. `installer:` is `MeterWatch` by default so a test can inject a
|
|
127
|
+
# double that never touches a real home.
|
|
128
|
+
def install_timer(context, home:, harness_key:, installer: MeterWatch)
|
|
129
|
+
runner_path = File.expand_path(File.join(__dir__, "..", "runner"))
|
|
130
|
+
arguments = [RbConfig.ruby, runner_path, "watch", context.intent_dir.to_s]
|
|
131
|
+
arguments += ["--dispatch", "--harness", harness_key.to_s] if HarnessAdapter.unattended_start?(harness_key)
|
|
132
|
+
|
|
133
|
+
installer.install_timer(home: home, script_path: runner_path,
|
|
134
|
+
label: "com.plastic.delivery-watch.#{context.intent_id}", arguments: arguments)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# run_until_empty_dispatch(context, harness:, until_empty:) -> every node
|
|
138
|
+
# id the loop dispatched (C30). `until_empty.run` gets `step:` wrapped
|
|
139
|
+
# around `until_empty.step_once` so this method sees every turn's own
|
|
140
|
+
# `:dispatched` ids, the same shape RunnerDispatch.dispatch returns
|
|
141
|
+
# (graph.md D8); `until_empty.run` itself still owns spawning and waiting
|
|
142
|
+
# on the real `node-run` subprocesses (never duplicated here).
|
|
143
|
+
def run_until_empty_dispatch(context, harness:, until_empty:, dispatched_ids:)
|
|
144
|
+
wrapped_step = lambda do |ctx, harness:, returns:|
|
|
145
|
+
result = until_empty.step_once(ctx, harness: harness, returns: returns)
|
|
146
|
+
dispatched_ids.concat(Array(result[:dispatched]))
|
|
147
|
+
result
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
until_empty.run(context, harness: harness, step: wrapped_step)
|
|
151
|
+
end
|
|
152
|
+
private_class_method :run_until_empty_dispatch
|
|
153
|
+
|
|
154
|
+
# read_meter_state(context) -> "ok", "reduce", "stop", "resume", "stale"
|
|
155
|
+
# (whatever MeterWatch's own tick last wrote), or "unavailable" for a
|
|
156
|
+
# missing or unparseable file (D8) - read-only, at MeterWatch's own state
|
|
157
|
+
# path under `context.plastic_home`, never through a MeterWatch instance
|
|
158
|
+
# (that class computes a FRESH state from the rate-limit cache; this tick
|
|
159
|
+
# only ever reads what it already wrote).
|
|
160
|
+
def read_meter_state(context)
|
|
161
|
+
path = File.join(context.plastic_home.to_s, ".cache", "meter-state.json")
|
|
162
|
+
return "unavailable" unless File.file?(path)
|
|
163
|
+
|
|
164
|
+
state = JSON.parse(File.read(path))["state"]
|
|
165
|
+
blank?(state) ? "unavailable" : state.to_s
|
|
166
|
+
rescue StandardError
|
|
167
|
+
"unavailable"
|
|
168
|
+
end
|
|
169
|
+
private_class_method :read_meter_state
|
|
170
|
+
|
|
171
|
+
def blank?(value)
|
|
172
|
+
value.nil? || value.to_s.strip.empty?
|
|
173
|
+
end
|
|
174
|
+
private_class_method :blank?
|
|
175
|
+
|
|
176
|
+
# fingerprint(context, runner:) -> the SHA256 D4 defines: savepoint.md's
|
|
177
|
+
# current content plus the intent branch head, so a commit that lands no
|
|
178
|
+
# ledger line still counts as movement (row 1.13). Public so a caller (and
|
|
179
|
+
# this file's own tests) can compute the exact value a tick would compute
|
|
180
|
+
# without duplicating the hashing here.
|
|
181
|
+
def fingerprint(context, runner: Worktree::ShellRunner.new)
|
|
182
|
+
content = savepoint_content(context.intent_dir)
|
|
183
|
+
Digest::SHA256.hexdigest("#{content}\x1f#{branch_head_sha(context, runner)}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# --- the lock ----------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def acquire_lock(path)
|
|
189
|
+
handle = File.open(path, File::CREAT | File::RDWR, 0o644)
|
|
190
|
+
return handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
|
|
191
|
+
|
|
192
|
+
handle.close
|
|
193
|
+
nil
|
|
194
|
+
rescue SystemCallError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
private_class_method :acquire_lock
|
|
198
|
+
|
|
199
|
+
def release_lock(handle)
|
|
200
|
+
return unless handle
|
|
201
|
+
|
|
202
|
+
handle.flock(File::LOCK_UN)
|
|
203
|
+
rescue SystemCallError
|
|
204
|
+
nil
|
|
205
|
+
ensure
|
|
206
|
+
handle&.close
|
|
207
|
+
end
|
|
208
|
+
private_class_method :release_lock
|
|
209
|
+
|
|
210
|
+
def busy_result
|
|
211
|
+
{ class: nil, blockers: [], ready: [], reclaimed: [], dispatched: [], tick: nil, busy: true }
|
|
212
|
+
end
|
|
213
|
+
private_class_method :busy_result
|
|
214
|
+
|
|
215
|
+
# --- classification (D4), first match wins ------------------------------------
|
|
216
|
+
|
|
217
|
+
def classify(context, runner:, now:, intent_dir:, extended: false)
|
|
218
|
+
content = savepoint_content(intent_dir)
|
|
219
|
+
recorded_pairs = Savepoint.savepoint_recorded_pairs(intent_dir)
|
|
220
|
+
previous = read_snapshot(intent_dir)
|
|
221
|
+
tick_number = (previous ? previous[:tick] : 0) + 1
|
|
222
|
+
|
|
223
|
+
if closed?(recorded_pairs)
|
|
224
|
+
return settle("closed", blockers: [], ready_ids: [], previous: previous, tick_number: tick_number,
|
|
225
|
+
quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
unless context.graph[:ok]
|
|
229
|
+
errors = Array(context.graph[:errors])
|
|
230
|
+
errors = ["graph.md could not be parsed"] if errors.empty?
|
|
231
|
+
return settle("stalled", blockers: errors, ready_ids: [], previous: previous, tick_number: tick_number,
|
|
232
|
+
quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if RunnerCore.complete?(context)
|
|
236
|
+
return settle("done_unreported", blockers: [], ready_ids: [], previous: previous, tick_number: tick_number,
|
|
237
|
+
quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
ready_ids = ReadySet.analyze(intent_dir, now: now)[:ranked_ready].map { |r| r[:id] }
|
|
241
|
+
running = any_running?(content)
|
|
242
|
+
|
|
243
|
+
if !running && ready_ids.empty?
|
|
244
|
+
return settle("stalled", blockers: blocked_reasons(context), ready_ids: ready_ids, previous: previous,
|
|
245
|
+
tick_number: tick_number, quiet_ticks: 0, content: content, context: context, runner: runner,
|
|
246
|
+
now: now)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
current_fingerprint = fingerprint(context, runner: runner)
|
|
250
|
+
|
|
251
|
+
# B2: a reclaim that extended a lease is evidence of live work (the
|
|
252
|
+
# sweep extends only when the node branch has commits newer than the
|
|
253
|
+
# expiry), so it counts as movement here, at the fingerprint
|
|
254
|
+
# comparison, after closed/malformed-graph/done_unreported/the
|
|
255
|
+
# nothing-running-nothing-ready stall have already returned above.
|
|
256
|
+
if extended || previous.nil? || previous[:fingerprint] != current_fingerprint
|
|
257
|
+
return settle("moving", blockers: [], ready_ids: ready_ids, previous: previous, tick_number: tick_number,
|
|
258
|
+
quiet_ticks: 0, content: content, context: context, runner: runner, now: now,
|
|
259
|
+
fingerprint: current_fingerprint)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
next_quiet_ticks = previous[:quiet_ticks] + 1
|
|
263
|
+
if previous[:quiet_ticks] >= 1 && !unexpired_running_lease?(content, now)
|
|
264
|
+
klass = "stalled"
|
|
265
|
+
blockers = ["no observed movement for #{next_quiet_ticks} consecutive ticks and no unexpired running lease"]
|
|
266
|
+
else
|
|
267
|
+
klass = "quiet"
|
|
268
|
+
blockers = []
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
settle(klass, blockers: blockers, ready_ids: ready_ids, previous: previous, tick_number: tick_number,
|
|
272
|
+
quiet_ticks: next_quiet_ticks, content: content, context: context, runner: runner, now: now,
|
|
273
|
+
fingerprint: current_fingerprint)
|
|
274
|
+
end
|
|
275
|
+
private_class_method :classify
|
|
276
|
+
|
|
277
|
+
# Assembles the return view plus the snapshot that will be persisted, when
|
|
278
|
+
# `record:` is honored by the caller. `fingerprint:` defaults to a fresh
|
|
279
|
+
# computation so every class - not only moving/quiet/stalled-by-quiet -
|
|
280
|
+
# persists a value later ticks can compare against.
|
|
281
|
+
def settle(klass, blockers:, ready_ids:, previous:, tick_number:, quiet_ticks:, content:, context:, runner:,
|
|
282
|
+
now:, fingerprint: nil)
|
|
283
|
+
fp = fingerprint || RunnerWatch.fingerprint(context, runner: runner)
|
|
284
|
+
{
|
|
285
|
+
class: klass, blockers: blockers, ready: ready_ids, tick: tick_number,
|
|
286
|
+
snapshot: { fingerprint: fp, quiet_ticks: quiet_ticks, tick: tick_number, at: now.utc.iso8601 },
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
private_class_method :settle
|
|
290
|
+
|
|
291
|
+
def closed?(recorded_pairs)
|
|
292
|
+
recorded_pairs.include?(["Done", "delivered"]) || recorded_pairs.include?(["Done", "abandoned"])
|
|
293
|
+
end
|
|
294
|
+
private_class_method :closed?
|
|
295
|
+
|
|
296
|
+
def blocked_reasons(context)
|
|
297
|
+
rows = RunnerCore.status(context)
|
|
298
|
+
rows.values.reject { |v| ReadySet::TERMINAL_STATES.include?(v[:state]) || v[:ready] }
|
|
299
|
+
.flat_map { |v| v[:blockers] }
|
|
300
|
+
.uniq
|
|
301
|
+
end
|
|
302
|
+
private_class_method :blocked_reasons
|
|
303
|
+
|
|
304
|
+
def any_running?(content)
|
|
305
|
+
NodeLedger.status_from_content(content).value?("running")
|
|
306
|
+
end
|
|
307
|
+
private_class_method :any_running?
|
|
308
|
+
|
|
309
|
+
def unexpired_running_lease?(content, now)
|
|
310
|
+
status_map = NodeLedger.status_from_content(content)
|
|
311
|
+
entries = NodeLedger.entries_from_content(content)
|
|
312
|
+
status_map.any? do |subject, state|
|
|
313
|
+
next false unless state == "running"
|
|
314
|
+
|
|
315
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
|
|
316
|
+
next false unless last
|
|
317
|
+
|
|
318
|
+
at = parse_time((last[:fields] || {})["expires"])
|
|
319
|
+
at && now < at
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
private_class_method :unexpired_running_lease?
|
|
323
|
+
|
|
324
|
+
def branch_head_sha(context, runner)
|
|
325
|
+
worktree = context.worktree
|
|
326
|
+
return "" if worktree.nil? || worktree.to_s.strip.empty?
|
|
327
|
+
|
|
328
|
+
res = runner.run("-C", worktree, "rev-parse", "HEAD")
|
|
329
|
+
res.success? ? res.stdout.to_s.strip : ""
|
|
330
|
+
rescue StandardError
|
|
331
|
+
""
|
|
332
|
+
end
|
|
333
|
+
private_class_method :branch_head_sha
|
|
334
|
+
|
|
335
|
+
def parse_time(raw)
|
|
336
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
337
|
+
|
|
338
|
+
Time.iso8601(raw.to_s)
|
|
339
|
+
rescue ArgumentError
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
private_class_method :parse_time
|
|
343
|
+
|
|
344
|
+
def savepoint_content(intent_dir)
|
|
345
|
+
path = File.join(intent_dir.to_s, "savepoint.md")
|
|
346
|
+
File.exist?(path) ? File.read(path) : ""
|
|
347
|
+
end
|
|
348
|
+
private_class_method :savepoint_content
|
|
349
|
+
|
|
350
|
+
# --- the snapshot (D5) ---------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def read_snapshot(intent_dir)
|
|
353
|
+
path = File.join(intent_dir, STATE_FILENAME)
|
|
354
|
+
return nil unless File.file?(path)
|
|
355
|
+
|
|
356
|
+
data = JSON.parse(File.read(path))
|
|
357
|
+
{ fingerprint: data["fingerprint"], quiet_ticks: data["quiet_ticks"].to_i, tick: data["tick"].to_i,
|
|
358
|
+
at: data["at"] }
|
|
359
|
+
rescue StandardError
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
private_class_method :read_snapshot
|
|
363
|
+
|
|
364
|
+
def write_snapshot(intent_dir, snapshot)
|
|
365
|
+
path = File.join(intent_dir, STATE_FILENAME)
|
|
366
|
+
data = {
|
|
367
|
+
"fingerprint" => snapshot[:fingerprint], "quiet_ticks" => snapshot[:quiet_ticks],
|
|
368
|
+
"tick" => snapshot[:tick], "at" => snapshot[:at],
|
|
369
|
+
}
|
|
370
|
+
AtomicWrite.write(path, JSON.generate(data))
|
|
371
|
+
end
|
|
372
|
+
private_class_method :write_snapshot
|
|
373
|
+
|
|
374
|
+
# --- the record (D5) -----------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def append_record(intent_dir, now:, tick:, klass:, reclaimed:, ready:, dispatched:, harness: "-", meter: "-",
|
|
377
|
+
lock:)
|
|
378
|
+
line = "#{now.utc.iso8601} tick=#{tick} class=#{klass} reclaimed=#{list_or_dash(reclaimed)} " \
|
|
379
|
+
"ready=#{list_or_dash(ready)} dispatched=#{list_or_dash(dispatched)} harness=#{harness} " \
|
|
380
|
+
"meter=#{meter} lock=#{lock}\n"
|
|
381
|
+
File.open(File.join(intent_dir, RECORD_FILENAME), "a") { |f| f.write(line) }
|
|
382
|
+
end
|
|
383
|
+
private_class_method :append_record
|
|
384
|
+
|
|
385
|
+
def list_or_dash(list)
|
|
386
|
+
list.nil? || list.empty? ? "-" : list.join(",")
|
|
387
|
+
end
|
|
388
|
+
private_class_method :list_or_dash
|
|
389
|
+
end
|
package/scripts/lib/savepoint.rb
CHANGED
|
@@ -145,7 +145,7 @@ module Savepoint
|
|
|
145
145
|
["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
146
146
|
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
147
147
|
end
|
|
148
|
-
# Name the directory that actually exists (
|
|
148
|
+
# Name the directory that actually exists (review B3): a nodes-only intent
|
|
149
149
|
# must never claim the literal "actions/" artifact it does not have.
|
|
150
150
|
# Checks actions/ first, matching D15r's read order.
|
|
151
151
|
if has_real_files_in?("actions", intent_dir)
|
|
@@ -160,7 +160,7 @@ module Savepoint
|
|
|
160
160
|
ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
|
|
161
161
|
# A How-stage intent that already started a nodes/ directory is named
|
|
162
162
|
# accordingly, so the next-step hint never tells a node-graph intent to
|
|
163
|
-
# go make an actions/ directory it will never use (
|
|
163
|
+
# go make an actions/ directory it will never use (review B3). Mirrors
|
|
164
164
|
# has_real_files_in?'s actions-first order and its real-file requirement
|
|
165
165
|
# (post-execution review, non-blocking 4): an intent carrying real files
|
|
166
166
|
# in both directories, or a real actions/ file beside an empty or
|
|
@@ -177,7 +177,7 @@ module Savepoint
|
|
|
177
177
|
when "why" then ["spec.md"]
|
|
178
178
|
when "how"
|
|
179
179
|
# A node-shaped How intent (a real graph.md already) is named after its
|
|
180
|
-
# own two artifacts, never the three D41 removed from its path (
|
|
180
|
+
# own two artifacts, never the three D41 removed from its path (review
|
|
181
181
|
# B3, extended by 336 D13): checked via stage_file_present? alone, the
|
|
182
182
|
# same primitive derive_stage itself uses.
|
|
183
183
|
if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
|
|
@@ -245,7 +245,7 @@ module SessionGit
|
|
|
245
245
|
# The branch HEAD points to, even on an unborn branch (review R3: a fresh
|
|
246
246
|
# `git init`, zero commits). `git rev-parse --abbrev-ref HEAD` FAILS on an
|
|
247
247
|
# unborn branch (there is no commit for it to resolve yet), which the old
|
|
248
|
-
# implementation misread as "cannot determine a branch" and
|
|
248
|
+
# implementation misread as "cannot determine a branch" and merged into
|
|
249
249
|
# the detached-HEAD case. `git symbolic-ref --quiet --short HEAD` succeeds
|
|
250
250
|
# on both a normal AND an unborn branch (HEAD is a symbolic ref to
|
|
251
251
|
# `refs/heads/<name>` in both cases) and only fails when HEAD is
|
|
@@ -354,7 +354,7 @@ module SessionGit
|
|
|
354
354
|
|
|
355
355
|
# An unknown mode/workspace value, or workspace: worktree's degradation
|
|
356
356
|
# to checkout, is itself a degradation (spec D2, BLOCKER 3 ruling), so
|
|
357
|
-
# it always turns the outcome into a Note,
|
|
357
|
+
# it always turns the outcome into a Note, merging both facts into the
|
|
358
358
|
# single savepoint line spec D7 allows.
|
|
359
359
|
Result.new(message: "#{flow_notes.join('; ')}; #{result.message}", event: "Note")
|
|
360
360
|
end
|