@zalom/plastic 2.0.0-alpha.14 → 2.0.0-alpha.15

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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.14",
3
+ "version": "2.0.0-alpha.15",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
 
@@ -60,6 +60,35 @@ require_relative "screen_paint"
60
60
  # (D4): a lone fence in the engaging chunk's own prefix, and a lone closing
61
61
  # fence right after the painted region in `finalize`. Neither ever reaches
62
62
  # back into an earlier, already-displayed chunk.
63
+ #
64
+ # Intent 331a1: the decision marker (D1-D3). 331a's own comment above already
65
+ # names the concurrency; what it did not close is chunk 0's own boot time.
66
+ # Chunk 0's Ruby process takes on the order of 150 ms to boot before it ever
67
+ # writes SCREEN or NOSCREEN - long enough, under a fast real stream, for a
68
+ # dozen or more later chunks to be judged with nothing on disk at all, so
69
+ # every one of them fell back to the cheap shape test and, being ordinary
70
+ # non-table prose, passed straight through plain. The bash launcher (hooks/
71
+ # message-display) now stakes a PENDING file with builtins the instant
72
+ # chunk 0 is handed off, before Ruby ever starts, so a later chunk finds the
73
+ # message directory within microseconds instead of after Ruby's own boot.
74
+ # While PENDING exists, a later chunk polls for the real decision WHATEVER
75
+ # ITS OWN SHAPE looks like - `maybe_screen?` is not consulted at all, because
76
+ # a decision is certainly coming, and the cheap shape gate exists only for
77
+ # the "nothing at all exists yet, is a wait even worth paying for" case,
78
+ # which no longer applies once something IS on disk. A PENDING whose mtime
79
+ # is already older than THIS chunk's own poll budget reads as NOSCREEN (D2,
80
+ # fail open): chunk 0 must have died or hung, and waiting out a whole budget
81
+ # for a decision that is provably not coming would only delay every chunk
82
+ # behind it. That staleness check runs ONCE, before any polling, since a
83
+ # file's mtime never changes while this process looks at it. The poll
84
+ # budget itself scales with the chunk's own index (`budget_ms`, D3): base
85
+ # wait_ms plus index_wait_ms per index, capped at max_wait_ms, so the final
86
+ # chunk of a long streamed message (335 chunks, in the live capture that
87
+ # reproduced this) is allowed to wait for a decision that is certainly on
88
+ # its way, while chunk 1 of an ordinary short message still fails open
89
+ # quickly. Chunk 0 removes PENDING the moment it writes SCREEN or NOSCREEN
90
+ # (`write_screen`/`write_noscreen`), on both paths, so "a decision already
91
+ # exists" and "PENDING is still there" are never both true for long.
63
92
  class MessageDisplay
64
93
  # 317a (A4): engagement is grammar, not identity - any screen-family
65
94
  # opener engages, with NO intent-id resolution (the roster and delay
@@ -76,8 +105,13 @@ class MessageDisplay
76
105
  BUFFER_MAX_AGE_SECONDS = 3600
77
106
  SCREEN_FILE = "SCREEN"
78
107
  NOSCREEN_FILE = "NOSCREEN"
108
+ # 331a1 (D1): staked by the bash launcher, with builtins, the instant
109
+ # chunk 0 is handed off - before Ruby ever boots. Replaced by SCREEN or
110
+ # NOSCREEN (D2), never read by this class as a decision in its own right.
111
+ PENDING_FILE = "PENDING"
79
112
 
80
113
  def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
114
+ index_wait_ms: 20, max_wait_ms: 2000,
81
115
  sleeper: ->(seconds) { sleep(seconds) })
82
116
  @tmp_root = tmp_root
83
117
  @plastic_home = plastic_home
@@ -85,6 +119,8 @@ class MessageDisplay
85
119
  @now = now
86
120
  @wait_ms = wait_ms
87
121
  @poll_ms = poll_ms
122
+ @index_wait_ms = index_wait_ms
123
+ @max_wait_ms = max_wait_ms
88
124
  @sleeper = sleeper
89
125
  end
90
126
 
@@ -132,6 +168,13 @@ class MessageDisplay
132
168
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
133
169
  end
134
170
 
171
+ # 331a1 (matrix L1): the bash launcher (hooks/message-display) and this
172
+ # class must agree, byte for byte, on where PENDING lives - the same
173
+ # contract `buffer_path` already carries for SCREEN/NOSCREEN (matrix 40).
174
+ def self.pending_path(tmp_root:, session_id:, message_id:)
175
+ File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), PENDING_FILE)
176
+ end
177
+
135
178
  private
136
179
 
137
180
  # Chunk 0 decides, synchronously, before anything else touches this
@@ -159,7 +202,7 @@ class MessageDisplay
159
202
  split = split_at_opener(delta)
160
203
  return engage(dir, index, split, final) if split
161
204
 
162
- decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
205
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
163
206
 
164
207
  return nil unless decision == :screen
165
208
 
@@ -212,17 +255,34 @@ class MessageDisplay
212
255
  lines[0...-1].join
213
256
  end
214
257
 
215
- # Checks for an existing decision first (free) and only pays the cheap
216
- # shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
217
- # there yet. `gate_delta: nil` (the final chunk) skips the shape test
218
- # entirely and always polls for the decision.
219
- def wait_for_decision(dir, gate_delta:)
258
+ # Checks for an existing decision first (free). Then, 331a1 (D1/D2), the
259
+ # whole fix: when PENDING exists, a decision is certainly coming, so this
260
+ # chunk polls for it WHATEVER ITS OWN SHAPE looks like - `maybe_screen?`
261
+ # is never even consulted on this branch - unless PENDING is already
262
+ # stale (older than this chunk's own budget), which reads as NOSCREEN at
263
+ # once, fail open, without ever polling. Only when there is no PENDING
264
+ # AT ALL (chunk 0 has not even been handed off to the bash launcher yet,
265
+ # or this replay never wrote one) does today's original behavior apply:
266
+ # the cheap shape test gates whether a bounded poll is worth paying for.
267
+ # `gate_delta: nil` (the final chunk) skips that shape test entirely and
268
+ # always polls.
269
+ def wait_for_decision(dir, gate_delta:, index:)
220
270
  decision = read_decision_now(dir)
221
271
  return decision if decision
222
272
 
273
+ if pending?(dir)
274
+ return :noscreen if pending_stale?(dir, index)
275
+
276
+ return poll_for_decision(dir, index)
277
+ end
278
+
223
279
  return :timeout if gate_delta && !maybe_screen?(gate_delta)
224
280
 
225
- max_polls_for_budget.times do
281
+ poll_for_decision(dir, index)
282
+ end
283
+
284
+ def poll_for_decision(dir, index)
285
+ max_polls_for_budget(index).times do
226
286
  @sleeper.call(@poll_ms / 1000.0)
227
287
  decision = read_decision_now(dir)
228
288
  return decision if decision
@@ -238,6 +298,24 @@ class MessageDisplay
238
298
  nil
239
299
  end
240
300
 
301
+ def pending?(dir)
302
+ File.exist?(File.join(dir, PENDING_FILE))
303
+ end
304
+
305
+ # Checked ONCE, before any polling - a file's mtime never changes while
306
+ # this process is looking at it, so re-checking inside the poll loop
307
+ # would only ever repeat the same answer. Any error reading the mtime
308
+ # (a race where PENDING vanished between `pending?` and here, most
309
+ # likely because the real decision just landed) is NOT staleness: it
310
+ # falls through to the ordinary poll, which will pick up that decision
311
+ # on its very next read.
312
+ def pending_stale?(dir, index)
313
+ age_ms = (@now.to_f - File.mtime(File.join(dir, PENDING_FILE)).to_f) * 1000
314
+ age_ms > budget_ms(index)
315
+ rescue StandardError
316
+ false
317
+ end
318
+
241
319
  # 331a (M5a): the start index crosses process boundaries through SCREEN's
242
320
  # own content, never in-memory state - the final chunk is routinely a
243
321
  # SEPARATE process from the one that engaged. An empty or missing file
@@ -288,21 +366,34 @@ class MessageDisplay
288
366
  end
289
367
  end
290
368
 
369
+ # 331a1 (D3): index-scaled too, same as the decision poll - the final
370
+ # chunk of a long streamed message (335 chunks, in the live capture that
371
+ # reproduced this) must be allowed to wait long enough for the earlier
372
+ # chunk files to land, not just the base wait_ms an ordinary short
373
+ # message gets by with.
291
374
  def wait_for_chunk_files(dir, start_index, index)
292
375
  return if index <= start_index
293
376
 
294
377
  needed = (start_index...index).map(&:to_s)
295
- max_polls_for_budget.times do
378
+ max_polls_for_budget(index).times do
296
379
  return if needed.all? { |n| File.exist?(File.join(dir, n)) }
297
380
 
298
381
  @sleeper.call(@poll_ms / 1000.0)
299
382
  end
300
383
  end
301
384
 
302
- def max_polls_for_budget
385
+ # 331a1 (D3): base wait_ms plus index_wait_ms per chunk index, capped at
386
+ # max_wait_ms - a chunk deep into a long streamed message is certainly
387
+ # going to see its decision eventually, so it is allowed to wait longer
388
+ # than chunk 1 of an ordinary short message.
389
+ def budget_ms(index)
390
+ [@wait_ms + @index_wait_ms * index.to_i, @max_wait_ms].min
391
+ end
392
+
393
+ def max_polls_for_budget(index)
303
394
  return 0 unless @poll_ms.to_f.positive?
304
395
 
305
- (@wait_ms / @poll_ms.to_f).ceil
396
+ (budget_ms(index) / @poll_ms.to_f).ceil
306
397
  end
307
398
 
308
399
  # Whatever chunk files exist FROM THE START INDEX onward, in index order,
@@ -363,12 +454,18 @@ class MessageDisplay
363
454
  # separate process, in production) knows where to start waiting and
364
455
  # splicing, and so `finalize_final` never touches chunks that were passed
365
456
  # through untouched before engagement.
457
+ # 331a1 (D2): the decision REPLACES PENDING, on this path too, whichever
458
+ # chunk turns out to be the one that engages.
366
459
  def write_screen(dir, index)
367
460
  atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
461
+ remove_pending(dir)
368
462
  end
369
463
 
464
+ # 331a1 (D2): same replacement on the NOSCREEN path, so a later chunk
465
+ # never finds both PENDING and NOSCREEN and has to choose between them.
370
466
  def write_noscreen(dir)
371
467
  atomic_write(File.join(dir, NOSCREEN_FILE), "")
468
+ remove_pending(dir)
372
469
  end
373
470
 
374
471
  # 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
@@ -378,6 +475,15 @@ class MessageDisplay
378
475
  FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
379
476
  end
380
477
 
478
+ # 331a1 (D2): removal must never raise - a decision was already written
479
+ # successfully by the time this runs, and a stray filesystem error here
480
+ # must never turn a successful decision into an unhandled exception.
481
+ def remove_pending(dir)
482
+ FileUtils.rm_f(File.join(dir, PENDING_FILE))
483
+ rescue StandardError
484
+ nil
485
+ end
486
+
381
487
  def atomic_write(path, content)
382
488
  FileUtils.mkdir_p(File.dirname(path))
383
489
  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
- "| #{widths.map { |w| "-" * [w, 3].max }.join(" | ")} |"
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"