@zalom/plastic 2.0.0-alpha.13 → 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.
Files changed (41) hide show
  1. package/hooks/message-display +55 -2
  2. package/package.json +1 -1
  3. package/scripts/dashboard.rb +238 -8
  4. package/scripts/doctor.rb +291 -4
  5. package/scripts/lib/dashboard_screen.rb +40 -0
  6. package/scripts/lib/doctor_core.rb +97 -2
  7. package/scripts/lib/hook_replay.rb +211 -0
  8. package/scripts/lib/installer_core.rb +23 -3
  9. package/scripts/lib/message_display.rb +267 -47
  10. package/scripts/lib/report_screen.rb +837 -18
  11. package/scripts/lib/roadmap_queue.rb +19 -2
  12. package/scripts/lib/roadmap_savepoint.rb +36 -7
  13. package/scripts/lib/savepoint.rb +12 -0
  14. package/scripts/lib/screen_paint.rb +240 -11
  15. package/scripts/lib/screens/dashboard.rb +20 -0
  16. package/scripts/lib/screens/plan.rb +18 -0
  17. package/scripts/lib/screens/roadmap.rb +15 -0
  18. package/scripts/lib/verify_intent.rb +33 -0
  19. package/scripts/report-screen +41 -7
  20. package/scripts/savepoint-note +11 -9
  21. package/skills/auto/SKILL.md +9 -9
  22. package/skills/auto/references/human-report-contract.md +79 -8
  23. package/skills/dashboard/SKILL.md +13 -2
  24. package/skills/dashboard/templates/dashboard-global.md +1 -1
  25. package/skills/dashboard/templates/dashboard-project.md +2 -2
  26. package/skills/doctor/SKILL.md +10 -4
  27. package/skills/intent-continuing/SKILL.md +19 -21
  28. package/skills/intent-continuing/references/board-fill.md +9 -0
  29. package/skills/intent-ending/SKILL.md +6 -4
  30. package/skills/intent-executing/SKILL.md +2 -0
  31. package/skills/intent-speccing/SKILL.md +7 -4
  32. package/skills/roadmap/SKILL.md +9 -0
  33. package/skills/roadmap/references/file-format.md +10 -0
  34. package/templates/dashboard-screen.md +22 -0
  35. package/templates/display-fixture.md +21 -0
  36. package/templates/intent-screen.md +1 -1
  37. package/templates/report-plan.md +15 -0
  38. package/templates/report-roadmap-delivered.md +10 -0
  39. package/templates/report-roadmap-plan.md +9 -0
  40. package/templates/report-roadmap-state.md +9 -0
  41. package/templates/report-state.md +1 -1
@@ -46,17 +46,72 @@ require_relative "screen_paint"
46
46
  # buffered or blanked. D10 (any failure while finalizing returns the
47
47
  # buffered original, never nil, never "") and D12 (color: false never
48
48
  # buffers or blanks anything) are unchanged.
49
+ #
50
+ # Intent 331a: engagement is late-capable. A chunk carrying a screen opener
51
+ # engages the message from that chunk on, whatever its own index - not only
52
+ # chunk 0. Chunks before it pass through untouched (they already reached the
53
+ # terminal live, via the ordinary passthrough path). The engaging chunk
54
+ # returns the text before the opener as its displayContent and buffers the
55
+ # opener onward at its OWN index; SCREEN now carries that index (a decimal
56
+ # integer, not an empty marker) so the final chunk - a separate process in
57
+ # production - knows where to start waiting and splicing (D3/D6), and so
58
+ # NOSCREEN, no longer a final answer (D2), can be replaced once a later
59
+ # chunk engages. A fence line immediately wrapping the opener is dropped
60
+ # (D4): a lone fence in the engaging chunk's own prefix, and a lone closing
61
+ # fence right after the painted region in `finalize`. Neither ever reaches
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.
49
92
  class MessageDisplay
50
93
  # 317a (A4): engagement is grammar, not identity - any screen-family
51
94
  # opener engages, with NO intent-id resolution (the roster and delay
52
- # screens have none to resolve). ScreenPaint owns the full grammar.
95
+ # screens have none to resolve). ScreenPaint owns the full grammar. Used
96
+ # per LINE (331a), not only against the start of a whole delta: a chunk's
97
+ # own text is scanned line by line for the first line that opens a screen,
98
+ # wherever it falls.
53
99
  ENGAGE_RE = /\A(?:##? )?[▶✔] /.freeze
100
+ # 331a (D4): a lone fence line, opening (optional info string) or closing
101
+ # (never one), by itself on its own line.
102
+ FENCE_OPEN_RE = /\A```[^\n]*\z/.freeze
103
+ FENCE_CLOSE_RE = /\A```\z/.freeze
54
104
  BUFFER_DIR_NAME = "plastic-message-display"
55
105
  BUFFER_MAX_AGE_SECONDS = 3600
56
106
  SCREEN_FILE = "SCREEN"
57
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"
58
112
 
59
113
  def initialize(tmp_root:, plastic_home:, color:, now:, wait_ms: 300, poll_ms: 20,
114
+ index_wait_ms: 20, max_wait_ms: 2000,
60
115
  sleeper: ->(seconds) { sleep(seconds) })
61
116
  @tmp_root = tmp_root
62
117
  @plastic_home = plastic_home
@@ -64,6 +119,8 @@ class MessageDisplay
64
119
  @now = now
65
120
  @wait_ms = wait_ms
66
121
  @poll_ms = poll_ms
122
+ @index_wait_ms = index_wait_ms
123
+ @max_wait_ms = max_wait_ms
67
124
  @sleeper = sleeper
68
125
  end
69
126
 
@@ -111,31 +168,41 @@ class MessageDisplay
111
168
  File.join(buffer_path(tmp_root: tmp_root, session_id: session_id, message_id: message_id), NOSCREEN_FILE)
112
169
  end
113
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
+
114
178
  private
115
179
 
116
180
  # Chunk 0 decides, synchronously, before anything else touches this
117
- # message: recognize the marker (after leading whitespace only) AND
118
- # resolve the id, both before anything is buffered or blanked (F4). Either
119
- # failure writes NOSCREEN so every later chunk can decide instantly rather
120
- # than waiting out its own budget for a decision that will never arrive.
181
+ # message: does ITS OWN delta carry an opener anywhere (331a; used to be
182
+ # only at the very start)? No opener writes NOSCREEN so every later chunk
183
+ # can decide instantly rather than waiting out its own budget for a
184
+ # decision that will never arrive - but NOSCREEN is no longer final (D2):
185
+ # a later chunk carrying an opener still replaces it.
121
186
  def handle_chunk_zero(dir, delta, _cwd, final)
122
- stripped = delta.sub(/\A[ \t]+/, "")
123
- unless ENGAGE_RE.match?(stripped)
187
+ split = split_at_opener(delta)
188
+ unless split
124
189
  write_noscreen(dir)
125
190
  return nil
126
191
  end
127
192
 
128
- write_screen(dir)
129
- write_chunk(dir, 0, delta)
130
- final ? finalize_final(dir, 0) : ""
193
+ engage(dir, 0, split, final)
131
194
  end
132
195
 
133
- # A later chunk (index > 0) never redoes chunk 0's work: it only asks
134
- # whether a decision already exists, waiting for one (bounded) when it
135
- # does not and the chunk looks like it could matter. The final chunk
136
- # always waits for the decision regardless of its own shape.
196
+ # A later chunk (index > 0) tests its OWN delta for an opener FIRST,
197
+ # before consulting any existing decision (331a, D2): an opener engages
198
+ # the message whatever the current decision says, including when NOSCREEN
199
+ # is already on disk. Only once its own delta carries no opener does it
200
+ # fall back to the original decision-driven wait.
137
201
  def handle_later_chunk(dir, index, delta, final)
138
- decision = wait_for_decision(dir, gate_delta: final ? nil : delta)
202
+ split = split_at_opener(delta)
203
+ return engage(dir, index, split, final) if split
204
+
205
+ decision = wait_for_decision(dir, gate_delta: final ? nil : delta, index: index)
139
206
 
140
207
  return nil unless decision == :screen
141
208
 
@@ -143,17 +210,79 @@ class MessageDisplay
143
210
  final ? finalize_final(dir, index) : ""
144
211
  end
145
212
 
146
- # Checks for an existing decision first (free) and only pays the cheap
147
- # shape test, then the bounded poll, when neither SCREEN nor NOSCREEN is
148
- # there yet. `gate_delta: nil` (the final chunk) skips the shape test
149
- # entirely and always polls for the decision.
150
- def wait_for_decision(dir, gate_delta:)
213
+ # Engages the message starting at THIS chunk (whatever its index): writes
214
+ # SCREEN carrying this chunk's index (replacing any NOSCREEN, D2/D6),
215
+ # buffers the opener onward at this chunk's own index, and returns the
216
+ # text before the opener (fence-stripped, D4) as the displayContent. When
217
+ # this chunk is also final, the prefix is prepended to whatever `finalize`
218
+ # produces (painted, or the buffered original on a fail-open) rather than
219
+ # dropped.
220
+ def engage(dir, index, split, final)
221
+ prefix, rest = split
222
+ prefix = strip_preceding_fence(prefix)
223
+ write_screen(dir, index)
224
+ remove_noscreen(dir)
225
+ write_chunk(dir, index, rest)
226
+
227
+ return prefix unless final
228
+
229
+ finalized = finalize_final(dir, index)
230
+ prefix.empty? ? finalized : "#{prefix}#{finalized}"
231
+ end
232
+
233
+ # Scans `delta` line by line for the first line that opens a screen
234
+ # (331a: an opener can fall anywhere in a chunk's own delta, not only at
235
+ # its start). Returns [prefix, rest] - the text before that line, and the
236
+ # line onward - or nil when no line in this delta engages.
237
+ def split_at_opener(delta)
238
+ lines = delta.each_line.to_a
239
+ idx = lines.index { |line| ENGAGE_RE.match?(line.sub(/\A[ \t]+/, "")) }
240
+ return nil unless idx
241
+
242
+ [lines[0...idx].join, lines[idx..].join]
243
+ end
244
+
245
+ # 331a (D4): a lone fence line immediately preceding the opener, inside
246
+ # THIS SAME chunk's own prefix, is dropped - it never reaches the screen
247
+ # (it would otherwise print directly above the painted block). A fence in
248
+ # an earlier chunk is never touched: it was already displayed, verbatim,
249
+ # by that earlier chunk's own return value.
250
+ def strip_preceding_fence(prefix)
251
+ lines = prefix.each_line.to_a
252
+ return prefix if lines.empty?
253
+ return prefix unless FENCE_OPEN_RE.match?(lines.last.strip)
254
+
255
+ lines[0...-1].join
256
+ end
257
+
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:)
151
270
  decision = read_decision_now(dir)
152
271
  return decision if decision
153
272
 
273
+ if pending?(dir)
274
+ return :noscreen if pending_stale?(dir, index)
275
+
276
+ return poll_for_decision(dir, index)
277
+ end
278
+
154
279
  return :timeout if gate_delta && !maybe_screen?(gate_delta)
155
280
 
156
- 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
157
286
  @sleeper.call(@poll_ms / 1000.0)
158
287
  decision = read_decision_now(dir)
159
288
  return decision if decision
@@ -169,6 +298,38 @@ class MessageDisplay
169
298
  nil
170
299
  end
171
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
+
319
+ # 331a (M5a): the start index crosses process boundaries through SCREEN's
320
+ # own content, never in-memory state - the final chunk is routinely a
321
+ # SEPARATE process from the one that engaged. An empty or missing file
322
+ # reads back as 0 (chunk 0 engaged, today's shape, so nothing that ever
323
+ # wrote an empty SCREEN breaks).
324
+ def read_start_index(dir)
325
+ path = File.join(dir, SCREEN_FILE)
326
+ return 0 unless File.exist?(path)
327
+
328
+ File.read(path).to_i
329
+ rescue StandardError
330
+ 0
331
+ end
332
+
172
333
  # Cheap, local, no file I/O: could this chunk's own delta plausibly be
173
334
  # part of an intent screen (ignoring leading whitespace)? Every chunk of
174
335
  # every ordinary prose message answers no, at zero cost.
@@ -177,46 +338,70 @@ class MessageDisplay
177
338
  stripped.empty? || stripped.start_with?("|") || stripped.start_with?("**")
178
339
  end
179
340
 
180
- # The final chunk additionally waits (same budget) for every earlier chunk
181
- # file to exist before it reassembles and splices. On timeout it proceeds
182
- # anyway with whatever is there (matrix, lead's guard): never nil, never
183
- # swallowed.
341
+ # The final chunk additionally waits (same budget) for every chunk file
342
+ # from the start index onward to exist before it reassembles and splices
343
+ # (331a, D3/M5: from the start index, not from 0 - chunks before the
344
+ # engaging one were never buffered at all, so waiting for them would only
345
+ # ever burn the whole budget for files that will never appear). On
346
+ # timeout it proceeds anyway with whatever is there (matrix, lead's
347
+ # guard): never nil, never swallowed.
184
348
  def finalize_final(dir, index)
185
- wait_for_chunk_files(dir, index)
349
+ start_index = read_start_index(dir)
350
+ wait_for_chunk_files(dir, start_index, index)
186
351
 
187
352
  buffered = nil
188
353
  begin
189
- buffered = read_buffered_chunks(dir, index)
354
+ buffered = read_buffered_chunks(dir, start_index, index)
190
355
  finalize(buffered, nil)
191
356
  rescue StandardError
192
- buffered
357
+ # A read failing inside the assignment above leaves `buffered` at its
358
+ # nil default (the assignment never completes), which the review pass
359
+ # caught: D8/D10 promise the buffered original on any finalize
360
+ # failure, never nil, once chunks were blanked. `||=` covers exactly
361
+ # that gap without touching the ordinary case (buffered already holds
362
+ # the real chunks read before `finalize` itself raised).
363
+ buffered ||= ""
193
364
  ensure
194
365
  FileUtils.rm_rf(dir)
195
366
  end
196
367
  end
197
368
 
198
- def wait_for_chunk_files(dir, index)
199
- return if index <= 0
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.
374
+ def wait_for_chunk_files(dir, start_index, index)
375
+ return if index <= start_index
200
376
 
201
- needed = (0...index).map(&:to_s)
202
- max_polls_for_budget.times do
377
+ needed = (start_index...index).map(&:to_s)
378
+ max_polls_for_budget(index).times do
203
379
  return if needed.all? { |n| File.exist?(File.join(dir, n)) }
204
380
 
205
381
  @sleeper.call(@poll_ms / 1000.0)
206
382
  end
207
383
  end
208
384
 
209
- 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)
210
394
  return 0 unless @poll_ms.to_f.positive?
211
395
 
212
- (@wait_ms / @poll_ms.to_f).ceil
396
+ (budget_ms(index) / @poll_ms.to_f).ceil
213
397
  end
214
398
 
215
- # Whatever chunk files exist, in index order, concatenated -- gaps (a
216
- # chunk that never arrived, or arrived too late) are skipped rather than
217
- # blocking reassembly (lead's guard: never return nothing).
218
- def read_buffered_chunks(dir, index)
219
- (0..index).filter_map do |i|
399
+ # Whatever chunk files exist FROM THE START INDEX onward, in index order,
400
+ # concatenated -- gaps (a chunk that never arrived, or arrived too late)
401
+ # are skipped rather than blocking reassembly (lead's guard: never return
402
+ # nothing).
403
+ def read_buffered_chunks(dir, start_index, index)
404
+ (start_index..index).filter_map do |i|
220
405
  path = File.join(dir, i.to_s)
221
406
  File.exist?(path) ? File.read(path) : nil
222
407
  end.join
@@ -239,11 +424,22 @@ class MessageDisplay
239
424
  start = lines.index { |l| ScreenPaint.classify(l) == :opener }
240
425
  return buffered unless start
241
426
 
242
- stop = ScreenPaint.region_end(lines, start)
243
- painted = ScreenPaint.paint(lines[start...stop].join, color: true, markdown_safe: true)
427
+ region_stop = ScreenPaint.region_end(lines, start)
428
+ painted = ScreenPaint.paint(lines[start...region_stop].join, color: true, markdown_safe: true)
244
429
  return buffered unless painted
245
430
 
246
- suffix = lines[stop..].to_a.join.sub(/\A\n+/, "")
431
+ # 331a (D4): a lone CLOSING fence immediately after the painted region is
432
+ # dropped - never the painting boundary itself (region_stop, used above,
433
+ # is untouched), only where the suffix starts. An unrelated fenced code
434
+ # block further down, with prose or a blank line between it and the
435
+ # region, is never adjacent, so it always survives verbatim (M8b); a
436
+ # closing fence never carries an info string, so an adjacent OPENING
437
+ # fence of a real code block (which usually does) is never mistaken for
438
+ # it either.
439
+ suffix_start = region_stop
440
+ suffix_start += 1 if suffix_start < lines.length && FENCE_CLOSE_RE.match?(lines[suffix_start].strip)
441
+
442
+ suffix = lines[suffix_start..].to_a.join.sub(/\A\n+/, "")
247
443
  out = +"#{lines[0...start].join}#{painted.rstrip}\n"
248
444
  out << "\n#{suffix}" unless suffix.empty?
249
445
  out
@@ -253,15 +449,39 @@ class MessageDisplay
253
449
  atomic_write(File.join(dir, index.to_s), delta)
254
450
  end
255
451
 
256
- # IntentScreen/IntentScreenAnsi's store_root: is the TIER root (what HOLDS
257
- # store/ e.g. .../projects/<slug> or plastic_home itself), never the
258
- # store/ directory itself; resolve_intent_dir's `root:` is already that.
259
- def write_screen(dir)
260
- atomic_write(File.join(dir, SCREEN_FILE), "")
452
+ # SCREEN's content is the engaging chunk's own index, as a decimal integer
453
+ # (331a, D6) - read back by `read_start_index` so the final chunk (a
454
+ # separate process, in production) knows where to start waiting and
455
+ # splicing, and so `finalize_final` never touches chunks that were passed
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.
459
+ def write_screen(dir, index)
460
+ atomic_write(File.join(dir, SCREEN_FILE), "#{index}\n")
461
+ remove_pending(dir)
261
462
  end
262
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.
263
466
  def write_noscreen(dir)
264
467
  atomic_write(File.join(dir, NOSCREEN_FILE), "")
468
+ remove_pending(dir)
469
+ end
470
+
471
+ # 331a (D2/D6): NOSCREEN is no longer a final answer - a later chunk that
472
+ # engages replaces it with SCREEN and, for safety, removes NOSCREEN so a
473
+ # stale marker can never be read back once the real decision exists.
474
+ def remove_noscreen(dir)
475
+ FileUtils.rm_f(File.join(dir, NOSCREEN_FILE))
476
+ end
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
265
485
  end
266
486
 
267
487
  def atomic_write(path, content)