@zalom/plastic 2.0.0-alpha.14 → 2.0.0-alpha.16
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/hooks/message-display
CHANGED
|
@@ -107,4 +107,28 @@ fi
|
|
|
107
107
|
|
|
108
108
|
[ "$handoff" = 1 ] || exit 0
|
|
109
109
|
|
|
110
|
+
# 331a1 (D1): chunk 0 is about to hand off to Ruby, which takes on the
|
|
111
|
+
# order of 150 ms to boot before it ever writes SCREEN or NOSCREEN. Claude
|
|
112
|
+
# Code fires the per-chunk hook PROCESSES CONCURRENTLY (see the header
|
|
113
|
+
# comment above), so a later chunk landing inside that window used to find
|
|
114
|
+
# no message directory at all, fail this script's own cheap shape test,
|
|
115
|
+
# and pass through as raw Markdown - measured at 15 to 21 chunks of every
|
|
116
|
+
# screen, against a stream-realistic 5 ms stagger. Staking a PENDING
|
|
117
|
+
# marker here, the instant the handoff gate above passes, closes that
|
|
118
|
+
# window down to microseconds: a later chunk's own "does the directory
|
|
119
|
+
# exist" check (below, unchanged) now finds it almost at once, and
|
|
120
|
+
# MessageDisplay polls for the real decision instead of judging the
|
|
121
|
+
# chunk's own shape. mkdir is the one non-builtin on this whole path - it
|
|
122
|
+
# forks, unlike everything else in this file - but it runs ONLY here,
|
|
123
|
+
# after the handoff gate, so the ordinary (non-candidate) chunk that is
|
|
124
|
+
# the overwhelming common case still forks nothing (matrix L8). A failed
|
|
125
|
+
# mkdir or printf (an unwritable tmp root, a race with another process) is
|
|
126
|
+
# silently swallowed: it must never change this script's own exit status
|
|
127
|
+
# or skip the Ruby handoff below - Ruby's own polling already fails open
|
|
128
|
+
# (D5) when no decision ever appears.
|
|
129
|
+
if [ "$is_index_zero" = 1 ]; then
|
|
130
|
+
mkdir -p "$MSGDIR" 2>/dev/null
|
|
131
|
+
printf '' > "$MSGDIR/PENDING" 2>/dev/null
|
|
132
|
+
fi
|
|
133
|
+
|
|
110
134
|
printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
|
package/package.json
CHANGED
|
@@ -51,11 +51,18 @@ begin
|
|
|
51
51
|
tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
|
|
52
52
|
plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
53
53
|
|
|
54
|
+
# PLASTIC_HOOK_TRACE=<file> (331a1): opt-in, off by default, one JSON
|
|
55
|
+
# object appended per chunk. This script is the only place allowed to
|
|
56
|
+
# read the environment; MessageDisplay takes the sink as an argument.
|
|
57
|
+
trace_path = ENV["PLASTIC_HOOK_TRACE"].to_s
|
|
58
|
+
trace = trace_path.empty? ? nil : MessageDisplay.file_trace(trace_path)
|
|
59
|
+
|
|
54
60
|
handler = MessageDisplay.new(
|
|
55
61
|
tmp_root: tmp_root,
|
|
56
62
|
plastic_home: plastic_home,
|
|
57
63
|
color: color_enabled?(plastic_home),
|
|
58
64
|
now: Time.now,
|
|
65
|
+
trace: trace,
|
|
59
66
|
)
|
|
60
67
|
result = handler.handle(payload)
|
|
61
68
|
|
|
@@ -56,6 +56,89 @@ module HookReplay
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# replay_concurrent (intent 331a1) - streams `text` through `hook_path` the
|
|
60
|
+
# way `replay` does, but fires every chunk in its OWN thread, staggered by
|
|
61
|
+
# `gap_ms` (plus up to half a gap of jitter when `jitter` is true) rather
|
|
62
|
+
# than run sequentially. This is what reproduces the decision-race defect
|
|
63
|
+
# 331a1 fixes: Claude Code fires the per-chunk hook processes CONCURRENTLY
|
|
64
|
+
# in production, and `replay`'s strictly-sequential default never puts two
|
|
65
|
+
# chunks in flight at once, so it could never have reproduced the race in
|
|
66
|
+
# the first place.
|
|
67
|
+
#
|
|
68
|
+
# `gap_ms: 5` plus jitter is the default on purpose, not "fire everything
|
|
69
|
+
# at once": firing all 335 chunks of the live session capture with no
|
|
70
|
+
# stagger at all takes about 8 s of wall clock on this 8-core machine,
|
|
71
|
+
# because EACH chunk boots its own real Ruby process and the completions
|
|
72
|
+
# cluster at the tail once every core is saturated - a load real streaming
|
|
73
|
+
# never produces (a real stream delivers a chunk every few tens of
|
|
74
|
+
# milliseconds, one at a time). One Ruby process per streamed chunk is the
|
|
75
|
+
# actual throughput ceiling here, not something this method works around.
|
|
76
|
+
#
|
|
77
|
+
# `replay`'s own signature and sequential default are UNCHANGED by this
|
|
78
|
+
# method's existence (`scripts/doctor.rb:2571` calls `replay` directly and
|
|
79
|
+
# must keep working exactly as it does today) - this is a sibling method
|
|
80
|
+
# in the same module, never a replacement.
|
|
81
|
+
#
|
|
82
|
+
# Returns the SAME result shape `replay` returns (one Hash per chunk, keys
|
|
83
|
+
# index/exitstatus/stdout/stderr/final), ordered by index regardless of
|
|
84
|
+
# the order the threads actually finish in.
|
|
85
|
+
def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
|
|
86
|
+
message_id: "replay", env: {}, gap_ms: 5, jitter: true)
|
|
87
|
+
chunks = text.scan(/.{1,#{chunk}}/m)
|
|
88
|
+
chunks = [""] if chunks.empty?
|
|
89
|
+
full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
|
|
90
|
+
gap = gap_ms / 1000.0
|
|
91
|
+
|
|
92
|
+
results = Array.new(chunks.length)
|
|
93
|
+
threads = chunks.each_with_index.map do |delta, i|
|
|
94
|
+
payload = {
|
|
95
|
+
"session_id" => session_id, "message_id" => message_id, "index" => i,
|
|
96
|
+
"final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
|
|
97
|
+
"hook_event_name" => "MessageDisplay",
|
|
98
|
+
}
|
|
99
|
+
Thread.new do
|
|
100
|
+
begin
|
|
101
|
+
delay = i * gap
|
|
102
|
+
delay += (rand * gap / 2.0) if jitter
|
|
103
|
+
sleep(delay)
|
|
104
|
+
out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
|
|
105
|
+
results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
# A raise inside a thread body is invisible until join, and an
|
|
108
|
+
# unrescued one aborts `threads.each(&:join)` at the first dead
|
|
109
|
+
# thread: every later thread is then never joined and outlives the
|
|
110
|
+
# call, racing whatever the caller does next (typically removing
|
|
111
|
+
# the very tmp root those threads are still writing under). Report
|
|
112
|
+
# the failure as this chunk's own result instead, so the array is
|
|
113
|
+
# always complete, every thread is always joined, and a replay
|
|
114
|
+
# tells its caller what went wrong rather than throwing at it.
|
|
115
|
+
results[i] = { index: i, exitstatus: nil, stdout: "", stderr: e.message,
|
|
116
|
+
final: payload["final"] }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
threads.each { |thread| thread.join }
|
|
121
|
+
results
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Indices of the chunks that reached the terminal as raw Markdown: a
|
|
125
|
+
# non-final chunk that emitted nothing at all, after the engaging chunk.
|
|
126
|
+
# A chunk "passed through" when its stdout is empty; a chunk was
|
|
127
|
+
# "blanked" (correctly buffered, not shown raw) when its stdout contains
|
|
128
|
+
# `"displayContent":""`. Only chunks with an index greater than the
|
|
129
|
+
# engaging chunk's own index count - the engaging chunk is the first one
|
|
130
|
+
# (at or after `start_index`) whose stdout is non-empty, and chunks
|
|
131
|
+
# before it already reached the terminal live, verbatim, through the
|
|
132
|
+
# ordinary passthrough path (they were never candidates for buffering at
|
|
133
|
+
# all, so an empty stdout from one of them is not this defect).
|
|
134
|
+
def passthrough_indices(outs, start_index: 0)
|
|
135
|
+
engaging = outs.find { |o| o[:index] >= start_index && !o[:stdout].to_s.empty? }
|
|
136
|
+
return [] unless engaging
|
|
137
|
+
|
|
138
|
+
outs.select { |o| o[:index] > engaging[:index] && o[:final] != true && o[:stdout].to_s.empty? }
|
|
139
|
+
.map { |o| o[:index] }
|
|
140
|
+
end
|
|
141
|
+
|
|
59
142
|
def run_one(hook_path, payload, full_env, tmp_root, timeout)
|
|
60
143
|
return capture(hook_path, payload, full_env) unless timeout
|
|
61
144
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "json"
|
|
5
6
|
require_relative "screen_paint"
|
|
6
7
|
|
|
7
8
|
# MessageDisplay (intent 316a, O4/O5, round 3 concurrency fix) - the Claude
|
|
@@ -60,6 +61,35 @@ require_relative "screen_paint"
|
|
|
60
61
|
# (D4): a lone fence in the engaging chunk's own prefix, and a lone closing
|
|
61
62
|
# fence right after the painted region in `finalize`. Neither ever reaches
|
|
62
63
|
# back into an earlier, already-displayed chunk.
|
|
64
|
+
#
|
|
65
|
+
# Intent 331a1: the decision marker (D1-D3). 331a's own comment above already
|
|
66
|
+
# names the concurrency; what it did not close is chunk 0's own boot time.
|
|
67
|
+
# Chunk 0's Ruby process takes on the order of 150 ms to boot before it ever
|
|
68
|
+
# writes SCREEN or NOSCREEN - long enough, under a fast real stream, for a
|
|
69
|
+
# dozen or more later chunks to be judged with nothing on disk at all, so
|
|
70
|
+
# every one of them fell back to the cheap shape test and, being ordinary
|
|
71
|
+
# non-table prose, passed straight through plain. The bash launcher (hooks/
|
|
72
|
+
# message-display) now stakes a PENDING file with builtins the instant
|
|
73
|
+
# chunk 0 is handed off, before Ruby ever starts, so a later chunk finds the
|
|
74
|
+
# message directory within microseconds instead of after Ruby's own boot.
|
|
75
|
+
# While PENDING exists, a later chunk polls for the real decision WHATEVER
|
|
76
|
+
# ITS OWN SHAPE looks like - `maybe_screen?` is not consulted at all, because
|
|
77
|
+
# a decision is certainly coming, and the cheap shape gate exists only for
|
|
78
|
+
# the "nothing at all exists yet, is a wait even worth paying for" case,
|
|
79
|
+
# which no longer applies once something IS on disk. A PENDING whose mtime
|
|
80
|
+
# is already older than THIS chunk's own poll budget reads as NOSCREEN (D2,
|
|
81
|
+
# fail open): chunk 0 must have died or hung, and waiting out a whole budget
|
|
82
|
+
# for a decision that is provably not coming would only delay every chunk
|
|
83
|
+
# behind it. That staleness check runs ONCE, before any polling, since a
|
|
84
|
+
# file's mtime never changes while this process looks at it. The poll
|
|
85
|
+
# budget itself scales with the chunk's own index (`budget_ms`, D3): base
|
|
86
|
+
# wait_ms plus index_wait_ms per index, capped at max_wait_ms, so the final
|
|
87
|
+
# chunk of a long streamed message (335 chunks, in the live capture that
|
|
88
|
+
# reproduced this) is allowed to wait for a decision that is certainly on
|
|
89
|
+
# its way, while chunk 1 of an ordinary short message still fails open
|
|
90
|
+
# quickly. Chunk 0 removes PENDING the moment it writes SCREEN or NOSCREEN
|
|
91
|
+
# (`write_screen`/`write_noscreen`), on both paths, so "a decision already
|
|
92
|
+
# exists" and "PENDING is still there" are never both true for long.
|
|
63
93
|
class MessageDisplay
|
|
64
94
|
# 317a (A4): engagement is grammar, not identity - any screen-family
|
|
65
95
|
# opener engages, with NO intent-id resolution (the roster and delay
|
|
@@ -76,16 +106,28 @@ class MessageDisplay
|
|
|
76
106
|
BUFFER_MAX_AGE_SECONDS = 3600
|
|
77
107
|
SCREEN_FILE = "SCREEN"
|
|
78
108
|
NOSCREEN_FILE = "NOSCREEN"
|
|
79
|
-
|
|
109
|
+
# 331a1 (D1): staked by the bash launcher, with builtins, the instant
|
|
110
|
+
# chunk 0 is handed off - before Ruby ever boots. Replaced by SCREEN or
|
|
111
|
+
# NOSCREEN (D2), never read by this class as a decision in its own right.
|
|
112
|
+
PENDING_FILE = "PENDING"
|
|
113
|
+
|
|
114
|
+
# `trace` (331a1): an optional callable taking one Hash per chunk. The class
|
|
115
|
+
# stays pure - it never reads PLASTIC_HOOK_TRACE, never opens a file of its
|
|
116
|
+
# own; the CLI reads the variable and injects `file_trace`. nil, the
|
|
117
|
+
# default, costs the common path one `unless` and nothing else.
|
|
80
118
|
def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
|
|
81
|
-
|
|
119
|
+
index_wait_ms: 20, max_wait_ms: 2000,
|
|
120
|
+
sleeper: ->(seconds) { sleep(seconds) }, trace: nil)
|
|
82
121
|
@tmp_root = tmp_root
|
|
83
122
|
@plastic_home = plastic_home
|
|
84
123
|
@color = color
|
|
85
124
|
@now = now
|
|
86
125
|
@wait_ms = wait_ms
|
|
87
126
|
@poll_ms = poll_ms
|
|
127
|
+
@index_wait_ms = index_wait_ms
|
|
128
|
+
@max_wait_ms = max_wait_ms
|
|
88
129
|
@sleeper = sleeper
|
|
130
|
+
@trace = trace
|
|
89
131
|
end
|
|
90
132
|
|
|
91
133
|
def handle(payload)
|
|
@@ -105,11 +147,28 @@ class MessageDisplay
|
|
|
105
147
|
|
|
106
148
|
dir = self.class.buffer_path(tmp_root: @tmp_root, session_id: session_id, message_id: message_id)
|
|
107
149
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
150
|
+
@trace_detail = {}
|
|
151
|
+
result =
|
|
152
|
+
if index == 0
|
|
153
|
+
handle_chunk_zero(dir, delta, cwd, final)
|
|
154
|
+
else
|
|
155
|
+
handle_later_chunk(dir, index, delta, final)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
emit_trace(index, final, result)
|
|
159
|
+
result
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# One row per chunk, only when a sink was injected. Never raises: a trace
|
|
163
|
+
# is a diagnostic and must not be able to change what the hook returns.
|
|
164
|
+
def emit_trace(index, final, result)
|
|
165
|
+
return unless @trace
|
|
166
|
+
|
|
167
|
+
row = { "index" => index, "final" => final,
|
|
168
|
+
"displayed_bytes" => result.is_a?(String) ? result.bytesize : nil }
|
|
169
|
+
@trace.call(row.merge(@trace_detail.to_h))
|
|
170
|
+
rescue StandardError
|
|
171
|
+
nil
|
|
113
172
|
end
|
|
114
173
|
|
|
115
174
|
# The message directory both this class and the bash launcher (hooks/
|
|
@@ -128,10 +187,28 @@ class MessageDisplay
|
|
|
128
187
|
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), SCREEN_FILE)
|
|
129
188
|
end
|
|
130
189
|
|
|
190
|
+
# A trace sink that appends one JSON object per chunk to `path`. Every
|
|
191
|
+
# failure is swallowed: an unwritable path must never turn a diagnostic
|
|
192
|
+
# into a broken hook (D5, fail open).
|
|
193
|
+
def self.file_trace(path)
|
|
194
|
+
lambda do |row|
|
|
195
|
+
File.open(path, "a") { |f| f.puts(JSON.generate(row)) }
|
|
196
|
+
rescue StandardError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
131
201
|
def self.noscreen_path(tmp_root:, session_id:, message_id:)
|
|
132
202
|
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
|
|
133
203
|
end
|
|
134
204
|
|
|
205
|
+
# 331a1 (matrix L1): the bash launcher (hooks/message-display) and this
|
|
206
|
+
# class must agree, byte for byte, on where PENDING lives - the same
|
|
207
|
+
# contract `buffer_path` already carries for SCREEN/NOSCREEN (matrix 40).
|
|
208
|
+
def self.pending_path(tmp_root:, session_id:, message_id:)
|
|
209
|
+
File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), PENDING_FILE)
|
|
210
|
+
end
|
|
211
|
+
|
|
135
212
|
private
|
|
136
213
|
|
|
137
214
|
# Chunk 0 decides, synchronously, before anything else touches this
|
|
@@ -143,10 +220,12 @@ class MessageDisplay
|
|
|
143
220
|
def handle_chunk_zero(dir, delta, _cwd, final)
|
|
144
221
|
split = split_at_opener(delta)
|
|
145
222
|
unless split
|
|
223
|
+
@trace_detail["decision"] = "noscreen"
|
|
146
224
|
write_noscreen(dir)
|
|
147
225
|
return nil
|
|
148
226
|
end
|
|
149
227
|
|
|
228
|
+
@trace_detail["decision"] = "engage"
|
|
150
229
|
engage(dir, 0, split, final)
|
|
151
230
|
end
|
|
152
231
|
|
|
@@ -156,10 +235,25 @@ class MessageDisplay
|
|
|
156
235
|
# is already on disk. Only once its own delta carries no opener does it
|
|
157
236
|
# fall back to the original decision-driven wait.
|
|
158
237
|
def handle_later_chunk(dir, index, delta, final)
|
|
238
|
+
# 331a1: an opener engages only a message that is NOT already a screen.
|
|
239
|
+
# 331a's late engagement exists for the prose-first reply, where chunk 0
|
|
240
|
+
# wrote NOSCREEN and a later chunk carries the title; it must not fire
|
|
241
|
+
# again once SCREEN is on disk. A reply can hold several screens back to
|
|
242
|
+
# back - the roster is a table then ten cards, the session report is many
|
|
243
|
+
# delivered screens - and re-engaging on each one returned that chunk's
|
|
244
|
+
# own prefix as raw Markdown and rewrote the start index, so the final
|
|
245
|
+
# chunk spliced from the LAST opener and everything before it reached the
|
|
246
|
+
# terminal unpainted. Inside an engaged message a later opener is simply
|
|
247
|
+
# content: it is buffered like any other line and the painter, which
|
|
248
|
+
# already understands a run of screens, lays all of them out.
|
|
159
249
|
split = split_at_opener(delta)
|
|
160
|
-
|
|
250
|
+
if split && !engaged?(dir)
|
|
251
|
+
@trace_detail["decision"] = "engage"
|
|
252
|
+
return engage(dir, index, split, final)
|
|
253
|
+
end
|
|
161
254
|
|
|
162
|
-
decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
|
|
255
|
+
decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
|
|
256
|
+
@trace_detail["decision"] = decision.to_s
|
|
163
257
|
|
|
164
258
|
return nil unless decision == :screen
|
|
165
259
|
|
|
@@ -167,6 +261,10 @@ class MessageDisplay
|
|
|
167
261
|
final ? finalize_final(dir, index) : ""
|
|
168
262
|
end
|
|
169
263
|
|
|
264
|
+
def engaged?(dir)
|
|
265
|
+
File.exist?(File.join(dir, SCREEN_FILE))
|
|
266
|
+
end
|
|
267
|
+
|
|
170
268
|
# Engages the message starting at THIS chunk (whatever its index): writes
|
|
171
269
|
# SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
|
|
172
270
|
# buffers the opener onward at this chunk's own index, and returns the
|
|
@@ -212,17 +310,34 @@ class MessageDisplay
|
|
|
212
310
|
lines[0...-1].join
|
|
213
311
|
end
|
|
214
312
|
|
|
215
|
-
# Checks for an existing decision first (free)
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
|
|
313
|
+
# Checks for an existing decision first (free). Then, 331a1 (D1/D2), the
|
|
314
|
+
# whole fix: when PENDING exists, a decision is certainly coming, so this
|
|
315
|
+
# chunk polls for it WHATEVER ITS OWN SHAPE looks like - `maybe_screen?`
|
|
316
|
+
# is never even consulted on this branch - unless PENDING is already
|
|
317
|
+
# stale (older than this chunk's own budget), which reads as NOSCREEN at
|
|
318
|
+
# once, fail open, without ever polling. Only when there is no PENDING
|
|
319
|
+
# AT ALL (chunk 0 has not even been handed off to the bash launcher yet,
|
|
320
|
+
# or this replay never wrote one) does today's original behavior apply:
|
|
321
|
+
# the cheap shape test gates whether a bounded poll is worth paying for.
|
|
322
|
+
# `gate_delta: nil` (the final chunk) skips that shape test entirely and
|
|
323
|
+
# always polls.
|
|
324
|
+
def wait_for_decision(dir, gate_delta:, index:)
|
|
220
325
|
decision = read_decision_now(dir)
|
|
221
326
|
return decision if decision
|
|
222
327
|
|
|
328
|
+
if pending?(dir)
|
|
329
|
+
return :noscreen if pending_stale?(dir, index)
|
|
330
|
+
|
|
331
|
+
return poll_for_decision(dir, index)
|
|
332
|
+
end
|
|
333
|
+
|
|
223
334
|
return :timeout if gate_delta && !maybe_screen?(gate_delta)
|
|
224
335
|
|
|
225
|
-
|
|
336
|
+
poll_for_decision(dir, index)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def poll_for_decision(dir, index)
|
|
340
|
+
max_polls_for_budget(index).times do
|
|
226
341
|
@sleeper.call(@poll_ms / 1000.0)
|
|
227
342
|
decision = read_decision_now(dir)
|
|
228
343
|
return decision if decision
|
|
@@ -238,6 +353,24 @@ class MessageDisplay
|
|
|
238
353
|
nil
|
|
239
354
|
end
|
|
240
355
|
|
|
356
|
+
def pending?(dir)
|
|
357
|
+
File.exist?(File.join(dir, PENDING_FILE))
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Checked ONCE, before any polling - a file's mtime never changes while
|
|
361
|
+
# this process is looking at it, so re-checking inside the poll loop
|
|
362
|
+
# would only ever repeat the same answer. Any error reading the mtime
|
|
363
|
+
# (a race where PENDING vanished between `pending?` and here, most
|
|
364
|
+
# likely because the real decision just landed) is NOT staleness: it
|
|
365
|
+
# falls through to the ordinary poll, which will pick up that decision
|
|
366
|
+
# on its very next read.
|
|
367
|
+
def pending_stale?(dir, index)
|
|
368
|
+
age_ms = (@now.to_f - File.mtime(File.join(dir, PENDING_FILE)).to_f) * 1000
|
|
369
|
+
age_ms > budget_ms(index)
|
|
370
|
+
rescue StandardError
|
|
371
|
+
false
|
|
372
|
+
end
|
|
373
|
+
|
|
241
374
|
# 331a (M5a): the start index crosses process boundaries through SCREEN's
|
|
242
375
|
# own content, never in-memory state - the final chunk is routinely a
|
|
243
376
|
# SEPARATE process from the one that engaged. An empty or missing file
|
|
@@ -288,21 +421,34 @@ class MessageDisplay
|
|
|
288
421
|
end
|
|
289
422
|
end
|
|
290
423
|
|
|
424
|
+
# 331a1 (D3): index-scaled too, same as the decision poll - the final
|
|
425
|
+
# chunk of a long streamed message (335 chunks, in the live capture that
|
|
426
|
+
# reproduced this) must be allowed to wait long enough for the earlier
|
|
427
|
+
# chunk files to land, not just the base wait_ms an ordinary short
|
|
428
|
+
# message gets by with.
|
|
291
429
|
def wait_for_chunk_files(dir, start_index, index)
|
|
292
430
|
return if index <= start_index
|
|
293
431
|
|
|
294
432
|
needed = (start_index...index).map(&:to_s)
|
|
295
|
-
max_polls_for_budget.times do
|
|
433
|
+
max_polls_for_budget(index).times do
|
|
296
434
|
return if needed.all? { |n| File.exist?(File.join(dir, n)) }
|
|
297
435
|
|
|
298
436
|
@sleeper.call(@poll_ms / 1000.0)
|
|
299
437
|
end
|
|
300
438
|
end
|
|
301
439
|
|
|
302
|
-
|
|
440
|
+
# 331a1 (D3): base wait_ms plus index_wait_ms per chunk index, capped at
|
|
441
|
+
# max_wait_ms - a chunk deep into a long streamed message is certainly
|
|
442
|
+
# going to see its decision eventually, so it is allowed to wait longer
|
|
443
|
+
# than chunk 1 of an ordinary short message.
|
|
444
|
+
def budget_ms(index)
|
|
445
|
+
[@wait_ms + @index_wait_ms * index.to_i, @max_wait_ms].min
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def max_polls_for_budget(index)
|
|
303
449
|
return 0 unless @poll_ms.to_f.positive?
|
|
304
450
|
|
|
305
|
-
(
|
|
451
|
+
(budget_ms(index) / @poll_ms.to_f).ceil
|
|
306
452
|
end
|
|
307
453
|
|
|
308
454
|
# Whatever chunk files exist FROM THE START INDEX onward, in index order,
|
|
@@ -335,6 +481,7 @@ class MessageDisplay
|
|
|
335
481
|
|
|
336
482
|
region_stop = ScreenPaint.region_end(lines, start)
|
|
337
483
|
painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
|
|
484
|
+
trace_region(buffered, lines, start, region_stop, painted)
|
|
338
485
|
return buffered unless painted
|
|
339
486
|
|
|
340
487
|
# 331a (D4): a lone CLOSING fence immediately after the painted region is
|
|
@@ -354,6 +501,26 @@ class MessageDisplay
|
|
|
354
501
|
out
|
|
355
502
|
end
|
|
356
503
|
|
|
504
|
+
# What the final chunk actually painted, for the opt-in trace: how much was
|
|
505
|
+
# buffered, where the region ran, and the first line the grammar rejected -
|
|
506
|
+
# the one fact a terminal capture cannot give you. Computed only when a
|
|
507
|
+
# sink was injected, so an ordinary run pays nothing.
|
|
508
|
+
def trace_region(buffered, lines, start, region_stop, painted)
|
|
509
|
+
return unless @trace
|
|
510
|
+
|
|
511
|
+
rejected = ScreenPaint.first_rejected(lines, start)
|
|
512
|
+
@trace_detail.merge!(
|
|
513
|
+
"buffered_bytes" => buffered.bytesize,
|
|
514
|
+
"buffered_lines" => lines.length,
|
|
515
|
+
"region_start" => start,
|
|
516
|
+
"region_stop" => region_stop,
|
|
517
|
+
"painted" => !painted.nil?,
|
|
518
|
+
"first_rejected_line" => rejected && { "index" => rejected[0], "text" => rejected[1][0, 200] },
|
|
519
|
+
)
|
|
520
|
+
rescue StandardError
|
|
521
|
+
nil
|
|
522
|
+
end
|
|
523
|
+
|
|
357
524
|
def write_chunk(dir, index, delta)
|
|
358
525
|
atomic_write(File.join(dir, index.to_s), delta)
|
|
359
526
|
end
|
|
@@ -363,12 +530,18 @@ class MessageDisplay
|
|
|
363
530
|
# separate process, in production) knows where to start waiting and
|
|
364
531
|
# splicing, and so `finalize_final` never touches chunks that were passed
|
|
365
532
|
# through untouched before engagement.
|
|
533
|
+
# 331a1 (D2): the decision REPLACES PENDING, on this path too, whichever
|
|
534
|
+
# chunk turns out to be the one that engages.
|
|
366
535
|
def write_screen(dir, index)
|
|
367
536
|
atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
|
|
537
|
+
remove_pending(dir)
|
|
368
538
|
end
|
|
369
539
|
|
|
540
|
+
# 331a1 (D2): same replacement on the NOSCREEN path, so a later chunk
|
|
541
|
+
# never finds both PENDING and NOSCREEN and has to choose between them.
|
|
370
542
|
def write_noscreen(dir)
|
|
371
543
|
atomic_write(File.join(dir, NOSCREEN_FILE), "")
|
|
544
|
+
remove_pending(dir)
|
|
372
545
|
end
|
|
373
546
|
|
|
374
547
|
# 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
|
|
@@ -378,6 +551,15 @@ class MessageDisplay
|
|
|
378
551
|
FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
|
|
379
552
|
end
|
|
380
553
|
|
|
554
|
+
# 331a1 (D2): removal must never raise - a decision was already written
|
|
555
|
+
# successfully by the time this runs, and a stray filesystem error here
|
|
556
|
+
# must never turn a successful decision into an unhandled exception.
|
|
557
|
+
def remove_pending(dir)
|
|
558
|
+
FileUtils.rm_f(File.join(dir, PENDING_FILE))
|
|
559
|
+
rescue StandardError
|
|
560
|
+
nil
|
|
561
|
+
end
|
|
562
|
+
|
|
381
563
|
def atomic_write(path, content)
|
|
382
564
|
FileUtils.mkdir_p(File.dirname(path))
|
|
383
565
|
tmp_path = "#{path}.tmp#{Process.pid}-#{rand(1_000_000)}"
|
|
@@ -197,9 +197,25 @@ module ReportScreen
|
|
|
197
197
|
budget = limit - (4 + 3 * (ncols - 1)) - overage
|
|
198
198
|
widths = ScreenPaint.shrink_column_widths(widths, budget, bar_columns: bar_column, floors: floors)
|
|
199
199
|
|
|
200
|
+
# 331f1a (D1/D2, plan-review ruling): a separator row passes through byte-identical
|
|
201
|
+
# whenever its OWN unfitted input already fits the bound - rebuilding it from the
|
|
202
|
+
# shrunk widths (with the `[w, 3].max` floor below) is what made it assemble wider
|
|
203
|
+
# than any data row in the first place, landing it as the only row the backstop ever
|
|
204
|
+
# cut (or, when the rebuilt form happened to still fit, wider than its own "---"
|
|
205
|
+
# input, which D1 forbids just as much). Only when even the unmodified input cannot
|
|
206
|
+
# fit does the old rebuild-and-backstop path apply - the bound wins there, which is
|
|
207
|
+
# exactly what test_fit_screen_backstops_an_unshrinkable_row and
|
|
208
|
+
# test_unshrinkable_data_table_is_still_bounded pin. A blank "| | | |" scaffold
|
|
209
|
+
# reaching this branch is classified as a separator by the same regex, so it gets
|
|
210
|
+
# the identical pass-through rule.
|
|
200
211
|
fitted_rows = raw_rows.each_with_index.map do |cells, ri|
|
|
201
212
|
if is_sep[ri]
|
|
202
|
-
|
|
213
|
+
original = rows[ri]
|
|
214
|
+
if ScreenPaint.display_columns(original) <= limit
|
|
215
|
+
original
|
|
216
|
+
else
|
|
217
|
+
"| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
|
|
218
|
+
end
|
|
203
219
|
else
|
|
204
220
|
rendered = cells.each_with_index.map do |c, ci|
|
|
205
221
|
next c.to_s.strip if ci >= ncols
|
|
@@ -215,7 +231,8 @@ module ReportScreen
|
|
|
215
231
|
# and the assembled row can still be over the limit; truncate the whole row on a word
|
|
216
232
|
# boundary rather than let it survive past 115 - a data table's separator row included
|
|
217
233
|
# (test_fit_screen_backstops_an_unshrinkable_row), unlike the field-table fitter's own
|
|
218
|
-
# separator, which always passes through untouched (W2).
|
|
234
|
+
# separator, which always passes through untouched (W2). A separator already passed
|
|
235
|
+
# through byte-identical above never trips this (it already fits by construction).
|
|
219
236
|
fitted_rows.map! { |r| ScreenPaint.display_columns(r) > limit ? truncate_on_word_boundary(r, limit) : r }
|
|
220
237
|
|
|
221
238
|
"#{fitted_rows.join("\n")}\n"
|
|
@@ -38,6 +38,19 @@ module ScreenPaint
|
|
|
38
38
|
STEP_LINE_RE = /\A(S\d+)\s+\[ (open|done) \]\s+(.*)\z/.freeze
|
|
39
39
|
TIMELINE_RE = /\A(\d\d:\d\d)\s{2}(\S+)\s{2}(.*)\z/.freeze
|
|
40
40
|
COUNT_LINE_RE = /\A\d+ open( · .*)?\z/.freeze
|
|
41
|
+
# 331a1: the session verb prints this note between the delivered screens and
|
|
42
|
+
# the roster (report_screen.rb, render_session). It is our own output, so the
|
|
43
|
+
# painter must not reject it - the live hook trace caught the region stopping
|
|
44
|
+
# dead on it, with the whole roster below reaching the terminal as plain
|
|
45
|
+
# Markdown. Pinned to the exact shape rather than "any sentence": region_end
|
|
46
|
+
# exists to stop at the model's own prose, and a loose rule would swallow it.
|
|
47
|
+
SKIP_NOTE_RE = /\A\d+ completed intents? skipped: .+\z/.freeze
|
|
48
|
+
# 331a2 (D5, S3): render_session's own rescue card when a directory's delivered screen
|
|
49
|
+
# raises (report_screen.rb:1466-1468) - our own output, not model prose. The census (331a2)
|
|
50
|
+
# proved it classified :unknown, orphaning the roster below it in a real reply. `:meta`, not
|
|
51
|
+
# a new kind: it is a one-line grey note under a title and paint's `:meta` arm reads only
|
|
52
|
+
# `line.strip`, with no positional dependency.
|
|
53
|
+
RESCUE_CARD_RE = /\A## \S+ · could not render \(.*\)\z/.freeze
|
|
41
54
|
BOLD_LEAD_RE = /\A\*\*([^*]+)\*\*(.*)\z/.freeze
|
|
42
55
|
|
|
43
56
|
# Intent 317a1 (D3, D4, D5): the data-table palette. Kind and note columns
|
|
@@ -195,11 +208,12 @@ module ScreenPaint
|
|
|
195
208
|
return :table if text.lstrip.start_with?("|")
|
|
196
209
|
return :bold if BOLD_LEAD_RE.match?(stripped) && text == stripped
|
|
197
210
|
return :meta if idx && opener_idx && idx == opener_idx + 1 && stripped.include?(" · ")
|
|
211
|
+
return :meta if RESCUE_CARD_RE.match?(stripped)
|
|
198
212
|
return :indented if text.start_with?(" ")
|
|
199
213
|
return :field if FIELD_LINE_RE.match?(text)
|
|
200
214
|
return :step if STEP_LINE_RE.match?(text)
|
|
201
215
|
return :timeline if TIMELINE_RE.match?(text)
|
|
202
|
-
return :count if COUNT_LINE_RE.match?(stripped)
|
|
216
|
+
return :count if COUNT_LINE_RE.match?(stripped) || SKIP_NOTE_RE.match?(stripped)
|
|
203
217
|
return :closer if ["None", "not recorded", "No intents in delivery.", "No intents delivered in this session."].include?(stripped)
|
|
204
218
|
:unknown
|
|
205
219
|
end
|
|
@@ -207,11 +221,22 @@ module ScreenPaint
|
|
|
207
221
|
# Where the screen region ends inside a larger message (B10): walk from the
|
|
208
222
|
# opener while every line classifies; the first unknown line - ordinary
|
|
209
223
|
# prose, a prose bullet - is the boundary. Never consumes past the screen.
|
|
224
|
+
#
|
|
225
|
+
# 331a1: `opener_idx` tracks the NEAREST preceding opener, not the first
|
|
226
|
+
# one in the message. A reply can carry several screens back to back - the
|
|
227
|
+
# roster is a table then ten cards, the session report is many delivered
|
|
228
|
+
# screens - and `classify`'s positional rule is "the line right after a
|
|
229
|
+
# title", which means the title above it. Testing every line against the
|
|
230
|
+
# message's first opener made the timestamp under the SECOND screen
|
|
231
|
+
# :unknown, and the region stopped there: 30 lines of a 350-line session
|
|
232
|
+
# report painted, the rest reaching the terminal as plain Markdown.
|
|
210
233
|
def region_end(lines, start_idx)
|
|
211
234
|
i = start_idx + 1
|
|
235
|
+
opener_idx = start_idx
|
|
212
236
|
while i < lines.length
|
|
213
|
-
kind = classify(lines[i], idx: i, opener_idx:
|
|
237
|
+
kind = classify(lines[i], idx: i, opener_idx: opener_idx)
|
|
214
238
|
break if kind == :unknown
|
|
239
|
+
opener_idx = i if kind == :opener
|
|
215
240
|
# A bare "**Section**" head belongs to the screen only when what follows
|
|
216
241
|
# is still grammar; "**What this means**" over prose bullets is the
|
|
217
242
|
# model's own commentary and stays outside, unsplit (B10).
|
|
@@ -225,6 +250,22 @@ module ScreenPaint
|
|
|
225
250
|
i
|
|
226
251
|
end
|
|
227
252
|
|
|
253
|
+
# The first line the grammar rejects, walking from the opener under the same
|
|
254
|
+
# nearest-opener rule region_end uses. Returns [index, line] or nil when the
|
|
255
|
+
# whole run classifies. 331a1: this is what the opt-in hook trace reports,
|
|
256
|
+
# so a live run says which line stopped the region instead of leaving it to
|
|
257
|
+
# be guessed from a terminal capture.
|
|
258
|
+
def first_rejected(lines, start_idx)
|
|
259
|
+
opener_idx = start_idx
|
|
260
|
+
((start_idx + 1)...lines.length).each do |i|
|
|
261
|
+
kind = classify(lines[i], idx: i, opener_idx: opener_idx)
|
|
262
|
+
return [i, lines[i].to_s.rstrip] if kind == :unknown
|
|
263
|
+
|
|
264
|
+
opener_idx = i if kind == :opener
|
|
265
|
+
end
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
228
269
|
def bare_bold?(line)
|
|
229
270
|
m = BOLD_LEAD_RE.match(line.strip)
|
|
230
271
|
m && m[2].to_s.strip.empty?
|
|
@@ -271,8 +312,14 @@ module ScreenPaint
|
|
|
271
312
|
table.clear
|
|
272
313
|
end
|
|
273
314
|
|
|
315
|
+
# 331a1: the same nearest-opener rule region_end uses - a reply carrying
|
|
316
|
+
# several screens must classify each one's meta line against its own
|
|
317
|
+
# title, not against the first title in the message.
|
|
318
|
+
opener_idx = first_idx
|
|
319
|
+
|
|
274
320
|
lines.each_with_index do |line, idx|
|
|
275
|
-
kind = classify(line, idx: idx, opener_idx:
|
|
321
|
+
kind = classify(line, idx: idx, opener_idx: opener_idx)
|
|
322
|
+
opener_idx = idx if kind == :opener
|
|
276
323
|
# Intent 317a1 (O6, D8): the run counter tracks consecutive :indented
|
|
277
324
|
# lines so only the SECOND and later lines under a heading like
|
|
278
325
|
# "**Asked**" grey as a note; a :table line (which `next`s below) must
|