kairos-chain 3.77.0 → 3.78.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca968a084e8868b9cbeaa3283850dd7e56095a503d661ce6e74158ec7555f0e3
4
- data.tar.gz: 7a410b2edb98cd9bf02c8396f11d1d411e9b190456ea87402789d9a95aea5f16
3
+ metadata.gz: 3bdf5a0932b91258ccacf0520ca20eb252b7080c7ef3682f87edb8fdb07144c6
4
+ data.tar.gz: '09f911f59d0aba91f246f5ec6721cc4ee11f8534bd96ed73fc31edd3df1a78ff'
5
5
  SHA512:
6
- metadata.gz: 5cea432521cb70bcc7ed1f354bb0a4abe1165d16a25ed185d8b3c46af5db0d00d57c459737e3a52f290a356d29356033cce8e6ef4c56e7526438eb6f7eba30d3
7
- data.tar.gz: cf536e54e866a943d95d0209741c1a6658e6df62421f9a5e0e082983e98025057aa4e4dfc04ad08a4f96e7acfd9a982e69bde253d6f3986302325d9dd7ab65f3
6
+ metadata.gz: 884fe864157b0c5ea27cc9249de0378f4c8e53d0b724ef6c92e9adc85dd1a092e8c7291c1e0e778a20d3edf4058da92801968733f38017a73df60eb7b552ade3
7
+ data.tar.gz: c9061d247bee27b8525dae1d14f4acd1036e27d2c024b74228c43bc23211329285d418689aa6314c70f2e5f6060b9df4ad68e118870b680f6c411ab5d2de5cc7
data/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.78.0] - 2026-08-26
8
+
9
+ ### Fixed
10
+
11
+ - **`readable_gate` — a failed note write no longer leaves an earlier turn's
12
+ note in place.** The carry-over note (shipped in 3.77.0) records which
13
+ record the gate blocked, so the recheck measures only what came after it. A
14
+ leftover note from an interrupted turn — 28 of 240 blocks draw no recheck —
15
+ survived the next block's failed note write, checked out as valid, and the
16
+ recheck judged the message that block had just made. A record with no uuid
17
+ was input enough. Every write failure now spends the leftover: deleted, or —
18
+ when deletion is refused — truncated in place, since deletion needs
19
+ directory-write while truncation needs only file-write, and an emptied note
20
+ fails the parse on every later read whatever the directory permits by then.
21
+ Only a leftover refusing both deletion and truncation survives, and the
22
+ design declares that state rather than claiming it closed. Found by
23
+ conformance review rounds 2-4: six reading contexts per round answering a
24
+ fixed claim list (21 → 4 → 2 → 1 claims), each finding reproduced end-to-end
25
+ before its fix, and round 4 closing with zero claims disputed. 133 tests /
26
+ 815 assertions; 54 mutations killed 54/54; first-read stdout byte-identical
27
+ to the 3.74.0 baseline across up to 2,520 inputs measured by four
28
+ independent contexts.
29
+
7
30
  ## [3.77.0] - 2026-08-26
8
31
 
9
32
  ### Added
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.77.0"
2
+ VERSION = "3.78.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -431,27 +431,61 @@ module KairosHookProjector
431
431
  # Written to a temporary name and renamed, so a reader can never see a
432
432
  # half-written note. The pid is in the temporary name because two modes'
433
433
  # gates can run against one session at the same moment.
434
+ #
435
+ # After a block, the note is this block's or absent. A write that succeeds
436
+ # replaces any earlier note through the rename; a write that fails must not
437
+ # leave one either, because a leftover from an interrupted turn checks out
438
+ # as valid — fresh, same session, deletable — and the recheck then measures
439
+ # against the wrong block: round 2 of the conformance review demonstrated
440
+ # the message this turn blocked being judged a second time. So every
441
+ # failure path spends whatever note is already at the key.
434
442
  def write_note(transcript_path, cfg, uuid)
435
- return 'no uuid to record' if uuid.nil? || uuid.to_s.empty?
436
-
437
443
  path = note_path(transcript_path, cfg)
438
444
  return nil if path.nil? # no log declared: not a failure, a declared absence
439
445
 
440
- FileUtils.mkdir_p(File.dirname(path))
441
- tmp = "#{path}.#{Process.pid}.tmp"
442
- File.write(
443
- tmp,
444
- JSON.generate(
445
- 'transcript' => note_key_path(transcript_path).scrub,
446
- 'mode' => cfg.mode_name.to_s.scrub,
447
- 'blocked_uuid' => uuid.to_s.scrub,
448
- 'at' => Time.now.to_f
446
+ return spend_stale_note(path, 'no uuid to record') if uuid.nil? || uuid.to_s.empty?
447
+
448
+ begin
449
+ FileUtils.mkdir_p(File.dirname(path))
450
+ tmp = "#{path}.#{Process.pid}.tmp"
451
+ File.write(
452
+ tmp,
453
+ JSON.generate(
454
+ 'transcript' => note_key_path(transcript_path).scrub,
455
+ 'mode' => cfg.mode_name.to_s.scrub,
456
+ 'blocked_uuid' => uuid.to_s.scrub,
457
+ 'at' => Time.now.to_f
458
+ )
449
459
  )
450
- )
451
- File.rename(tmp, path)
452
- nil
453
- rescue StandardError => e
454
- "#{e.class}: #{e.message}"
460
+ File.rename(tmp, path)
461
+ nil
462
+ rescue StandardError => e
463
+ spend_stale_note(path, "#{e.class}: #{e.message}")
464
+ end
465
+ end
466
+
467
+ # Deletion first, truncation as the fallback. "A leftover that cannot be
468
+ # deleted here cannot be spent at the recheck either" was round 3's found
469
+ # assumption: it identifies write-time state with read-time state, and a
470
+ # directory made writable again inside the block-to-recheck window let the
471
+ # recheck spend a leftover this path had already failed to delete. Deleting
472
+ # needs directory-write; truncating needs only file-write, so the fallback
473
+ # usually works exactly where the unlink fails, and an emptied note fails
474
+ # the parse on every later read whatever the directory permits by then.
475
+ # Only when both are refused does the leftover stay valid, and that state
476
+ # is declared in design §7 rather than closed.
477
+ def spend_stale_note(path, reason)
478
+ File.unlink(path)
479
+ "#{reason}; a stale note was deleted"
480
+ rescue Errno::ENOENT
481
+ reason
482
+ rescue StandardError
483
+ begin
484
+ File.write(path, '')
485
+ "#{reason}; a stale note could not be deleted and was emptied instead"
486
+ rescue StandardError => e
487
+ "#{reason}; a stale note could not be deleted (#{e.class})"
488
+ end
455
489
  end
456
490
 
457
491
  # [uuid this gate blocked, nil] or [nil, why not].
@@ -233,8 +233,10 @@ MUTATIONS = [
233
233
  note(cfg, "NOTE-WRITE-FAILED: #{failure}")
234
234
  out.delete('decision')
235
235
  end}],
236
+ # Retargeted after round 2: the no-uuid return now spends the leftover first,
237
+ # so the anchor moved with it. The mutation is unchanged in spirit.
236
238
  ['N18 write_note records a uuid it was not given',
237
- %q{ return 'no uuid to record' if uuid.nil? || uuid.to_s.empty?},
239
+ %q{ return spend_stale_note(path, 'no uuid to record') if uuid.nil? || uuid.to_s.empty?},
238
240
  %q{ uuid = 'unknown' if uuid.nil? || uuid.to_s.empty?}],
239
241
  ['N19 the note is written whole rather than renamed into place',
240
242
  %q{ File.rename(tmp, path)},
@@ -272,6 +274,47 @@ MUTATIONS = [
272
274
  blocked_uuid, why_not = take_note(transcript_path, cfg)
273
275
  blocked_uuid, why_not = take_note(transcript_path, cfg) if blocked_uuid},
274
276
  ],
277
+
278
+ # --- what round 2 of the conformance review found --------------------------
279
+ #
280
+ # A leftover note from an interrupted turn is valid on its face, so a block
281
+ # whose own note write fails must spend it — otherwise the recheck reads the
282
+ # leftover and judges the message this turn just blocked. One seat of six
283
+ # found it, by composing two declared behaviours (the 11.7% unconsumed-note
284
+ # population and the no-uuid write failure) that every fixture had exercised
285
+ # only in isolation.
286
+ ['N29 the spend is a no-op that still reports success',
287
+ %q{ def spend_stale_note(path, reason)
288
+ File.unlink(path)},
289
+ %q{ def spend_stale_note(path, reason)
290
+ return reason if path
291
+ File.unlink(path)}],
292
+ ['N30 the no-uuid failure path stops spending the leftover',
293
+ %q{ return spend_stale_note(path, 'no uuid to record') if uuid.nil? || uuid.to_s.empty?},
294
+ %q{ return 'no uuid to record' if uuid.nil? || uuid.to_s.empty?}],
295
+ ['N31 the write\'s exception path stops spending the leftover',
296
+ %q{ spend_stale_note(path, "#{e.class}: #{e.message}")},
297
+ %q{ "#{e.class}: #{e.message}"}],
298
+
299
+ # --- what round 3 of the conformance review found --------------------------
300
+ #
301
+ # Unspendability is not stable: a directory that refuses the unlink at block
302
+ # time and permits it again before the recheck let the recheck spend the
303
+ # leftover this block had failed to delete. Three of five verifying contexts
304
+ # reached the same window independently. The truncation fallback is what
305
+ # closes it; this deletes the fallback and the window reopens.
306
+ ['N32 the truncation fallback is deleted, so a lifted window revives the leftover',
307
+ %q{ rescue StandardError
308
+ begin
309
+ File.write(path, '')
310
+ "#{reason}; a stale note could not be deleted and was emptied instead"
311
+ rescue StandardError => e
312
+ "#{reason}; a stale note could not be deleted (#{e.class})"
313
+ end
314
+ end},
315
+ %q{ rescue StandardError => e
316
+ "#{reason}; a stale note could not be deleted (#{e.class})"
317
+ end}],
275
318
  ].freeze
276
319
 
277
320
  def run_suite(root)
@@ -2479,4 +2479,148 @@ class TestReadableGate < Minitest::Test
2479
2479
  assert_includes message, 'stamped ahead of the clock', message
2480
2480
  end
2481
2481
  end
2482
+
2483
+ # --- what round 2 of the conformance review found ---------------------------
2484
+ #
2485
+ # A leftover note from an interrupted turn checks out as valid: fresh, same
2486
+ # session, deletable. When the next block's own note write fails, the recheck
2487
+ # read that leftover and judged the message this turn had just blocked — no
2488
+ # foreign hook involved, no filesystem failure needed (a record with no uuid
2489
+ # is enough). After a block, the note must be this block's or absent.
2490
+
2491
+ def test_a_failed_note_write_also_spends_the_note_an_earlier_turn_left
2492
+ Dir.mktmpdir do |tmp|
2493
+ cfg_path = File.join(tmp, 'cfg.json')
2494
+ tx_path = File.join(tmp, 't.jsonl')
2495
+ log_path = File.join(tmp, 'gate.log')
2496
+ raw = { 'mode_name' => 'test', 'log_path' => log_path, 'max_headings' => 3 }
2497
+ File.write(cfg_path, JSON.generate(raw), encoding: 'UTF-8')
2498
+ File.write(tx_path, rows_json([row_for('assistant', text: BLOCKED, uuid: 'AAA'),
2499
+ row_for('assistant', text: BLOCKED)]),
2500
+ encoding: 'UTF-8')
2501
+ cfg_obj = G::Config.new(raw, cfg_path)
2502
+ assert_nil G.write_note(tx_path, cfg_obj, 'AAA'), 'the interrupted turn left its note'
2503
+
2504
+ out, = run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
2505
+ 'stop_hook_active' => false))
2506
+ assert_equal 'block', JSON.parse(out)['decision'], 'the turn is still blocked'
2507
+ log = File.read(log_path, encoding: 'UTF-8')
2508
+ assert_includes log, 'NOTE-WRITE-FAILED: no uuid to record; a stale note was deleted', log
2509
+ assert_empty Dir.glob(File.join(File.dirname(G.note_path(tx_path, cfg_obj)), '*')),
2510
+ 'the leftover would name a block this turn did not make'
2511
+
2512
+ _out2, = run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
2513
+ 'stop_hook_active' => true))
2514
+ log = File.read(log_path, encoding: 'UTF-8')
2515
+ assert_includes log, 'SKIP-nonote', "the recheck must decline, not judge: #{log}"
2516
+ refute_includes log, 'RECHECK-',
2517
+ "a verdict here re-judges the message this turn blocked: #{log}"
2518
+ end
2519
+ end
2520
+
2521
+ # When the leftover cannot be spent either, the two failures must compose to
2522
+ # the same refusal: the leftover stays, and the recheck refuses it as the
2523
+ # note it cannot delete.
2524
+ def test_a_leftover_the_failed_write_cannot_spend_is_refused_at_the_recheck
2525
+ Dir.mktmpdir do |tmp|
2526
+ cfg_path = File.join(tmp, 'cfg.json')
2527
+ tx_path = File.join(tmp, 't.jsonl')
2528
+ logs = File.join(tmp, 'logs')
2529
+ FileUtils.mkdir_p(logs)
2530
+ log_path = File.join(logs, 'gate.log')
2531
+ raw = { 'mode_name' => 'test', 'log_path' => log_path, 'max_headings' => 3 }
2532
+ File.write(cfg_path, JSON.generate(raw), encoding: 'UTF-8')
2533
+ File.write(tx_path, rows_json([row_for('assistant', text: BLOCKED, uuid: 'AAA'),
2534
+ row_for('assistant', text: BLOCKED, uuid: 'BBB')]),
2535
+ encoding: 'UTF-8')
2536
+ cfg_obj = G::Config.new(raw, cfg_path)
2537
+ assert_nil G.write_note(tx_path, cfg_obj, 'AAA'), 'the interrupted turn left its note'
2538
+ notes = File.dirname(G.note_path(tx_path, cfg_obj))
2539
+
2540
+ File.chmod(0o555, notes) # neither the new write nor the spend can touch it
2541
+ begin
2542
+ out, = run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
2543
+ 'stop_hook_active' => false))
2544
+ assert_equal 'block', JSON.parse(out)['decision'], 'the turn is still blocked'
2545
+ log = File.read(log_path, encoding: 'UTF-8')
2546
+ assert_includes log, 'a stale note could not be deleted',
2547
+ "the second failure must be visible too: #{log}"
2548
+
2549
+ run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
2550
+ 'stop_hook_active' => true))
2551
+ ensure
2552
+ File.chmod(0o755, notes)
2553
+ end
2554
+
2555
+ log = File.read(log_path, encoding: 'UTF-8')
2556
+ assert_includes log, 'SKIP-nonote-unspent',
2557
+ "the unspendable leftover must be refused: #{log}"
2558
+ refute_includes log, 'RECHECK-',
2559
+ "a verdict here re-judges the message this turn blocked: #{log}"
2560
+ end
2561
+ end
2562
+
2563
+ # Round 3: unspendability is not stable. A directory that refuses the unlink
2564
+ # at block time and permits it again before the recheck let the recheck spend
2565
+ # the leftover this block had failed to delete — round 2's defect through a
2566
+ # transient window. Deleting needs directory-write; truncating needs only
2567
+ # file-write; an emptied note fails the parse on every later read, whatever
2568
+ # the directory permits by then.
2569
+ def test_a_leftover_spent_by_truncation_stays_spent_after_the_window_lifts
2570
+ Dir.mktmpdir do |tmp|
2571
+ cfg_path = File.join(tmp, 'cfg.json')
2572
+ tx_path = File.join(tmp, 't.jsonl')
2573
+ logs = File.join(tmp, 'logs')
2574
+ FileUtils.mkdir_p(logs)
2575
+ log_path = File.join(logs, 'gate.log')
2576
+ raw = { 'mode_name' => 'test', 'log_path' => log_path, 'max_headings' => 3 }
2577
+ File.write(cfg_path, JSON.generate(raw), encoding: 'UTF-8')
2578
+ File.write(tx_path, rows_json([row_for('assistant', text: BLOCKED, uuid: 'AAA'),
2579
+ row_for('assistant', text: BLOCKED, uuid: 'BBB')]),
2580
+ encoding: 'UTF-8')
2581
+ cfg_obj = G::Config.new(raw, cfg_path)
2582
+ assert_nil G.write_note(tx_path, cfg_obj, 'AAA'), 'the interrupted turn left its note'
2583
+ notes = File.dirname(G.note_path(tx_path, cfg_obj))
2584
+
2585
+ File.chmod(0o555, notes) # the write and the unlink both fail; file-write does not
2586
+ begin
2587
+ out, = run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
2588
+ 'stop_hook_active' => false))
2589
+ assert_equal 'block', JSON.parse(out)['decision'], 'the turn is still blocked'
2590
+ ensure
2591
+ File.chmod(0o755, notes) # the window lifts before the recheck
2592
+ end
2593
+ log = File.read(log_path, encoding: 'UTF-8')
2594
+ assert_includes log, 'a stale note could not be deleted and was emptied instead', log
2595
+
2596
+ run_script(cfg_path, JSON.generate('transcript_path' => tx_path,
2597
+ 'stop_hook_active' => true))
2598
+ log = File.read(log_path, encoding: 'UTF-8')
2599
+ refute_includes log, 'RECHECK-',
2600
+ "the emptied leftover must not yield a verdict once the window lifts: #{log}"
2601
+ assert_includes log, 'SKIP-nonote-mismatch',
2602
+ "an emptied note fails the parse and is refused by name: #{log}"
2603
+ end
2604
+ end
2605
+
2606
+ # The exception path of the write — not just the no-uuid return — must spend
2607
+ # the leftover too. Driven in-process because the deterministic failure is a
2608
+ # directory squatting the temporary name, and that name carries the pid.
2609
+ def test_a_write_that_raises_spends_the_leftover_before_reporting
2610
+ Dir.mktmpdir do |tmp|
2611
+ c = G::Config.new({ 'mode_name' => 'test', 'log_path' => File.join(tmp, 'gate.log') },
2612
+ '<inline>')
2613
+ tx = File.join(tmp, 't.jsonl')
2614
+ assert_nil G.write_note(tx, c, 'AAA'), 'the interrupted turn left its note'
2615
+ path = G.note_path(tx, c)
2616
+ FileUtils.mkdir_p("#{path}.#{Process.pid}.tmp") # the write raises, the spend can work
2617
+
2618
+ failure = G.write_note(tx, c, 'BBB')
2619
+ refute_nil failure, 'the write must report its failure'
2620
+ assert_includes failure, 'a stale note was deleted', failure
2621
+ refute File.exist?(path), 'the leftover would name a block this turn did not make'
2622
+ assert_equal [nil, 'nonote'], G.take_note(tx, c),
2623
+ 'the recheck must find nothing rather than the wrong block'
2624
+ end
2625
+ end
2482
2626
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.77.0
4
+ version: 3.78.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama