@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
|
@@ -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
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Savepoint: the intent-directory savepoint ledger and the stage derivation it
|
|
6
|
-
# rests on (intent 303
|
|
6
|
+
# rests on (intent 303). Everything here is derived from
|
|
7
7
|
# the files on disk inside one intent directory: which lifecycle files are real
|
|
8
8
|
# (not the placeholder sentinel), which stage that makes, and the append-only
|
|
9
|
-
# savepoint.md ledger that records each milestone. No session, no
|
|
9
|
+
# savepoint.md ledger that records each milestone. No session, no lock, no
|
|
10
10
|
# lock: a caller that only wants to append a ledger line or ask "which stage is
|
|
11
|
-
# this intent at" loads this file and nothing else.
|
|
12
|
-
#
|
|
13
|
-
# requires the bridge.
|
|
11
|
+
# this intent at" loads this file and nothing else. `scripts/lib/arm.rb` (arm,
|
|
12
|
+
# disarm, lock repair) requires this file; this file never requires arm.rb.
|
|
14
13
|
require "fileutils"
|
|
15
14
|
require "time" # Time#iso8601 for the ledger timestamps
|
|
16
15
|
|
|
@@ -60,7 +59,7 @@ module Savepoint
|
|
|
60
59
|
|
|
61
60
|
# Walk up from file_path; return the first ancestor that looks like an intent
|
|
62
61
|
# directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
|
|
63
|
-
# target without needing
|
|
62
|
+
# target without needing the lock or arm machinery. The input is always a file inside the intent
|
|
64
63
|
# dir (never the dir itself), so the walk-up starts at its parent.
|
|
65
64
|
def self.intent_dir_for(file_path)
|
|
66
65
|
dir = File.expand_path(file_path)
|
|
@@ -145,7 +144,7 @@ module Savepoint
|
|
|
145
144
|
["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
146
145
|
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
147
146
|
end
|
|
148
|
-
# Name the directory that actually exists (
|
|
147
|
+
# Name the directory that actually exists (review B3): a nodes-only intent
|
|
149
148
|
# must never claim the literal "actions/" artifact it does not have.
|
|
150
149
|
# Checks actions/ first, matching D15r's read order.
|
|
151
150
|
if has_real_files_in?("actions", intent_dir)
|
|
@@ -160,7 +159,7 @@ module Savepoint
|
|
|
160
159
|
ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
|
|
161
160
|
# A How-stage intent that already started a nodes/ directory is named
|
|
162
161
|
# accordingly, so the next-step hint never tells a node-graph intent to
|
|
163
|
-
# go make an actions/ directory it will never use (
|
|
162
|
+
# go make an actions/ directory it will never use (review B3). Mirrors
|
|
164
163
|
# has_real_files_in?'s actions-first order and its real-file requirement
|
|
165
164
|
# (post-execution review, non-blocking 4): an intent carrying real files
|
|
166
165
|
# in both directories, or a real actions/ file beside an empty or
|
|
@@ -177,7 +176,7 @@ module Savepoint
|
|
|
177
176
|
when "why" then ["spec.md"]
|
|
178
177
|
when "how"
|
|
179
178
|
# 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 (
|
|
179
|
+
# own two artifacts, never the three D41 removed from its path (review
|
|
181
180
|
# B3, extended by 336 D13): checked via stage_file_present? alone, the
|
|
182
181
|
# same primitive derive_stage itself uses.
|
|
183
182
|
if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
|
|
@@ -384,7 +383,7 @@ module Savepoint
|
|
|
384
383
|
|
|
385
384
|
# --- Phantom-line detection (intent 134) ------------------------------------
|
|
386
385
|
#
|
|
387
|
-
# A companion to the ledger, not a new writer: pure, disk-only, hermetic (no
|
|
386
|
+
# A companion to the ledger, not a new writer: pure, disk-only, hermetic (no lock or
|
|
388
387
|
# session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
|
|
389
388
|
# a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
|
|
390
389
|
# can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "worktree"
|
|
6
|
-
require_relative "bridge"
|
|
7
6
|
require_relative "savepoint"
|
|
8
7
|
require_relative "intent_validator"
|
|
9
8
|
|
|
@@ -21,7 +20,7 @@ require_relative "intent_validator"
|
|
|
21
20
|
# copy or a mechanical rendering of a committed artifact.
|
|
22
21
|
#
|
|
23
22
|
# Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
|
|
24
|
-
# `ENV` directly (only via the ambient `Dir.home` default, matching
|
|
23
|
+
# `ENV` directly (only via the ambient `Dir.home` default, matching this module's own
|
|
25
24
|
# convention). A git seam is injected as `runner:`, defaulting to
|
|
26
25
|
# `Worktree::ShellRunner.new`, so tests drive this in process with a fake runner and
|
|
27
26
|
# never touch real git. Every method returns a value; `scripts/scaffold-intent` maps the
|
|
@@ -29,6 +28,10 @@ require_relative "intent_validator"
|
|
|
29
28
|
module ScaffoldIntent
|
|
30
29
|
module_function
|
|
31
30
|
|
|
31
|
+
def blank?(value)
|
|
32
|
+
value.nil? || value.to_s.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
32
35
|
# --- path resolution (pure) --------------------------------------------------
|
|
33
36
|
|
|
34
37
|
def expand(path)
|
|
@@ -211,7 +214,7 @@ module ScaffoldIntent
|
|
|
211
214
|
end
|
|
212
215
|
end
|
|
213
216
|
|
|
214
|
-
unless
|
|
217
|
+
unless blank?(test_summary)
|
|
215
218
|
out << "\n"
|
|
216
219
|
out << "Test summary from #{test_summary}:\n"
|
|
217
220
|
out << "```\n"
|
|
@@ -17,8 +17,9 @@ module SessionClose
|
|
|
17
17
|
NOOP_REASONS = %w[clear resume].freeze
|
|
18
18
|
|
|
19
19
|
# The default hand-off writer (intent 311, spec D6): renders this session's
|
|
20
|
-
# share of
|
|
21
|
-
# goes. The hook script builds it
|
|
20
|
+
# share of its session day (SessionLedger.session_day, graph.md D7) into
|
|
21
|
+
# handoff--<session>.md before the tmp dir goes. The hook script builds it
|
|
22
|
+
# with the shipped templates dir.
|
|
22
23
|
def default_handoff(templates)
|
|
23
24
|
lambda do |store, day, session|
|
|
24
25
|
Handoff.write(store: store, day: day, session: session, trigger: "close", templates: templates)
|
|
@@ -45,26 +46,33 @@ module SessionClose
|
|
|
45
46
|
session = SessionLedger.short_session_id(payload.is_a?(Hash) ? payload["session_id"] : nil, nil)
|
|
46
47
|
return report if session.empty?
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
days = safely { SessionLedger.session_days(store, session, today: today) } || []
|
|
50
|
+
days = [today] if days.empty?
|
|
51
|
+
session_day = days.last
|
|
49
52
|
project = "global"
|
|
50
53
|
|
|
54
|
+
# Intent 344 (G11, D13): a session that crossed midnight carries pending
|
|
55
|
+
# lines on more than one day ledger; every one of them drops, not only
|
|
56
|
+
# the newest, so a close never leaves an earlier day's lines pending.
|
|
51
57
|
report[:dropped] = safely do
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
days.sum do |day|
|
|
59
|
+
checklist = SessionLedger.checklist_path(store, day)
|
|
60
|
+
count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
|
|
61
|
+
if count.positive?
|
|
62
|
+
SessionLedger.append_line(SessionLedger.savepoint_path(store, day),
|
|
63
|
+
SessionLedger.savepoint_line("Note", session, project,
|
|
64
|
+
"dropped #{count} pending lines at close", now: now))
|
|
65
|
+
end
|
|
66
|
+
count
|
|
58
67
|
end
|
|
59
|
-
count
|
|
60
68
|
end || 0
|
|
61
69
|
|
|
62
70
|
# The hand-off (intent 311, spec D6) is written after the drop, so it
|
|
63
|
-
# reflects it, and before the tmp dir goes
|
|
64
|
-
#
|
|
71
|
+
# reflects it, and before the tmp dir goes. A session with no line in the
|
|
72
|
+
# day ledger's window falls back to today, the same as the drop above.
|
|
65
73
|
if handoff
|
|
66
74
|
report[:handoff] = safely do
|
|
67
|
-
handoff.call(store,
|
|
75
|
+
handoff.call(store, session_day, session)
|
|
68
76
|
true
|
|
69
77
|
end || false
|
|
70
78
|
end
|
|
@@ -75,27 +83,21 @@ module SessionClose
|
|
|
75
83
|
true
|
|
76
84
|
end || false
|
|
77
85
|
|
|
78
|
-
|
|
86
|
+
# One carry spawns per day earlier than today, oldest first; a single
|
|
87
|
+
# earlier day keeps reporting its own arg array (not a one-element
|
|
88
|
+
# list) so an existing caller reading report[:spawned] as one array is
|
|
89
|
+
# unaffected.
|
|
90
|
+
earlier_days = days.select { |day| day < today }
|
|
91
|
+
unless earlier_days.empty?
|
|
79
92
|
report[:spawned] = safely do
|
|
80
|
-
|
|
81
|
-
spawner.call(args)
|
|
82
|
-
|
|
93
|
+
arg_lists = earlier_days.map { |day| ["--day", day, "--carry-to", today, "--store", store] }
|
|
94
|
+
arg_lists.each { |args| spawner.call(args) }
|
|
95
|
+
arg_lists.length == 1 ? arg_lists.first : arg_lists
|
|
83
96
|
end
|
|
84
97
|
end
|
|
85
98
|
report
|
|
86
99
|
end
|
|
87
100
|
|
|
88
|
-
# The pointer names the intent this session records into; a day id means
|
|
89
|
-
# the day ledger. Anything else (an intent id) means an auto team owns the
|
|
90
|
-
# record and the close touches no day ledger.
|
|
91
|
-
def read_pointer_day(store, session)
|
|
92
|
-
path = SessionLedger.pointer_path(store, session)
|
|
93
|
-
return nil unless File.exist?(path)
|
|
94
|
-
|
|
95
|
-
value = File.read(path).strip
|
|
96
|
-
SessionLedger.valid_day_id?(value) ? value : nil
|
|
97
|
-
end
|
|
98
|
-
|
|
99
101
|
def safely
|
|
100
102
|
yield
|
|
101
103
|
rescue StandardError
|
|
@@ -5,6 +5,7 @@ require "yaml"
|
|
|
5
5
|
require_relative "worktree"
|
|
6
6
|
require_relative "scaffold_intent"
|
|
7
7
|
require_relative "session_ledger"
|
|
8
|
+
require_relative "active_delivery"
|
|
8
9
|
|
|
9
10
|
# SessionGit - the branch model and per-repo flow settings behind
|
|
10
11
|
# `scripts/session-commit` (intent 300), which is how a checklist item
|
|
@@ -185,24 +186,29 @@ module SessionGit
|
|
|
185
186
|
text.to_s.downcase.scan(/[a-z0-9]+/).first(max_words).join("-")
|
|
186
187
|
end
|
|
187
188
|
|
|
188
|
-
# The intent
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
# not an intent name, and falls back to the day id here too, which is the
|
|
194
|
-
# same value either way.
|
|
195
|
-
def resolve_ticket(day:, store:, session:, ticket_source:)
|
|
189
|
+
# The delivering intent's id when `ticket_source` is `intent_id`, else the
|
|
190
|
+
# day id (344 n2, D4-D7, D12). `resolver` is injected so tests need no
|
|
191
|
+
# real ~/.plastic; it defaults to ActiveDelivery.resolve_by_short_session
|
|
192
|
+
# against the store's own project roots.
|
|
193
|
+
def resolve_ticket(day:, store:, session:, ticket_source:, resolver: nil)
|
|
196
194
|
return day.to_s unless ticket_source.to_s == "intent_id"
|
|
197
195
|
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
resolver ||= lambda { |short_session|
|
|
197
|
+
ActiveDelivery.resolve_by_short_session(
|
|
198
|
+
global_store: store,
|
|
199
|
+
project_roots: ActiveDelivery.project_roots(File.dirname(store)),
|
|
200
|
+
short_session: short_session
|
|
201
|
+
)
|
|
202
|
+
}
|
|
200
203
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
dir = begin
|
|
205
|
+
resolver.call(session)
|
|
206
|
+
rescue StandardError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
return day.to_s if dir.to_s.empty?
|
|
204
210
|
|
|
205
|
-
|
|
211
|
+
File.basename(dir).split("--", 2).first
|
|
206
212
|
end
|
|
207
213
|
|
|
208
214
|
# --- commit message ------------------------------------------------------------
|
|
@@ -245,7 +251,7 @@ module SessionGit
|
|
|
245
251
|
# The branch HEAD points to, even on an unborn branch (review R3: a fresh
|
|
246
252
|
# `git init`, zero commits). `git rev-parse --abbrev-ref HEAD` FAILS on an
|
|
247
253
|
# unborn branch (there is no commit for it to resolve yet), which the old
|
|
248
|
-
# implementation misread as "cannot determine a branch" and
|
|
254
|
+
# implementation misread as "cannot determine a branch" and merged into
|
|
249
255
|
# the detached-HEAD case. `git symbolic-ref --quiet --short HEAD` succeeds
|
|
250
256
|
# on both a normal AND an unborn branch (HEAD is a symbolic ref to
|
|
251
257
|
# `refs/heads/<name>` in both cases) and only fails when HEAD is
|
|
@@ -354,7 +360,7 @@ module SessionGit
|
|
|
354
360
|
|
|
355
361
|
# An unknown mode/workspace value, or workspace: worktree's degradation
|
|
356
362
|
# to checkout, is itself a degradation (spec D2, BLOCKER 3 ruling), so
|
|
357
|
-
# it always turns the outcome into a Note,
|
|
363
|
+
# it always turns the outcome into a Note, merging both facts into the
|
|
358
364
|
# single savepoint line spec D7 allows.
|
|
359
365
|
Result.new(message: "#{flow_notes.join('; ')}; #{result.message}", event: "Note")
|
|
360
366
|
end
|