kairos-chain 3.83.0 → 3.84.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: e601238b25375836907f7eec155beb0297e940cd5fd419ea855b7d48a3335993
4
- data.tar.gz: 1c720eaa88d3204eedc436e38cdabaa3e79df3b9b5aca6260c925ec4173c6e62
3
+ metadata.gz: 1ebfc6fe1ff6c901c620512c56c698f6dca7bd058143e611547864a0a5460e29
4
+ data.tar.gz: caa3229fcb55a9f057a88be84a7a7694e514aff6e9b31ec5a8796d5e1ca93138
5
5
  SHA512:
6
- metadata.gz: d13dbb392110ebc2f5f913bc38e370f7c4c36ee8067d69d060ab4379f1a34cc9db98a37ee09b845d633a4c76821194bb5aa8eaeef6db6c77e43784f7831150f7
7
- data.tar.gz: 34eca89babce68b01e805b6510967f0d1e2dca0faa0a856d1ec7a8881fd7e4a00cf90932c69ed7685cefe89e482144566ca4733d1efc433c2bf0b0038d9080af
6
+ metadata.gz: 91588bb7de5f9f211409c77e90ba688432415dae81a142341481201ea412d2769d597ed1d10382a726b7ef301fbfdda77439bfa7f27e870102a296f520957656
7
+ data.tar.gz: a7a9bd001776163b148e690508f6aa6f44639f01974f00da552f5264676f37f4a25a286c9acb6e502edd6c77f07f9c5de2afd963a6053a89a8d0999721bf55a4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@ 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.84.0] - 2026-09-03
8
+
9
+ ### Fixed — agent: second field-defect bundle (D5-b, D6, exit-record housekeeping), real-process worker tests
10
+
11
+ `agent` SkillSet templates only; the at-most-once advance gate (`lib/agent/advance_gate.rb`) is
12
+ byte-identical to 3.83.0 and the normal path writes the same records with the same content.
13
+
14
+ **D5-b — the review-verdict parser lost to nested objects and braces inside strings.** The old
15
+ candidate scan walked back to the nearest `{` before `"overall_verdict"` and counted braces with no
16
+ notion of strings, so `{"summary": {"a": 1}, "overall_verdict": …}` yielded the sibling `{"a": 1}`
17
+ and a `}` inside a string value corrupted the depth count. Candidates now come from one string-aware
18
+ forward pass over the reply; for each key occurrence they are emitted by enclosing depth ascending
19
+ (so a panel's top-level verdict beats a per-persona one), with a size tie-break within a depth; an
20
+ occurrence no span encloses (an odd quote on the same line) gets a line-anchored string-aware scan,
21
+ and the old nearest-brace walk-back only as the candidate of last resort. Fence and crude fallbacks
22
+ are unchanged.
23
+
24
+ **D6 — a TERM/INT/HUP during the gated call left no record.** The shutdown flag was read once before
25
+ the call; a signal landing during a minutes-long call was swallowed and, under the usual TERM-then-KILL
26
+ sequence, the worker vanished as `no_record`. A recorder thread now writes an interim `trapped_signal`
27
+ record (`phase: during_gated_call`) the moment a signal lands while the call is active; the final
28
+ `normal` / `uncaught` / watchdog record supersedes it and carries `signals_during_call`. The call is
29
+ still left to finish (aborting it would turn a sound advance into a crash-path recovery); no result
30
+ file is written on any signal path. The flag is re-checked after tool-registry bootstrap, so a signal
31
+ in that window exits 130 with a `before_gated_call` record; the recorder is stopped before every
32
+ final write so it cannot overwrite a final record; and the exit record (and its atomic-write temp
33
+ file) no longer counts as session activity — before this fix a TERM to a hung worker pushed the
34
+ stall bound out by a full stall window (measured +4.0 s → +7.1 s with stall=3).
35
+
36
+ **Housekeeping.** A fresh delegation handle removes the previous worker's `worker_exit.json`
37
+ (readers already filtered by token; the file lingered). New env knob
38
+ `KAIROS_WORKER_WATCHDOG_TICK_SECONDS` (default 30, floor 1) sets how often the watchdog re-evaluates
39
+ its two bounds — the default is unchanged; the knob exists so real-process tests can drive both
40
+ bounds in seconds.
41
+
42
+ **Not changed, by evidence.** D3 (read-side queries blocked during a delegated run) is MCP stdio
43
+ serialisation in the server's request loop, not the SkillSet; mitigation today is a short
44
+ `max_wait_seconds` on `agent_wait`. D7 (a GVL-holding call starving the watchdog) did not reproduce:
45
+ every blocking primitive on the worker's path releases the GVL (probe table in the implementation log).
46
+
47
+ **Tests.** New `test_agent_worker_process.rb` starts the shipped worker script as a real child
48
+ process and drives 7 of its 8 exit classes (normal, uncaught, superseded, signal before the call,
49
+ bootstrap failure, stall bound, hard cap) plus the D6 interim record, a TERM to a hung worker, a TERM
50
+ inside registry bootstrap, and signals carried on a watchdog record. Agent suite 96 runs / 209
51
+ assertions → 131 / 345; every fix has a test shown red on the pre-fix code (one exception recorded:
52
+ the recorder-serialisation window is sub-millisecond and has no deterministic test). Reviewed in two
53
+ implementation rounds (7 seats each); records in the instance's L2 store.
54
+
7
55
  ## [3.83.0] - 2026-09-01
8
56
 
9
57
  ### Added — readable_gate measures sentence length
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.83.0"
2
+ VERSION = "3.84.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -19,7 +19,12 @@
19
19
  # KAIROS_DATA_DIR (the server's effective data dir; makes the
20
20
  # worker resolve the SAME .kairos)
21
21
  #
22
- # Exit codes: 0 success; 1 exception; 125 setsid failed; 130 signal.
22
+ # Exit codes: 0 success (also: superseded); 1 exception / bootstrap failure;
23
+ # 124 watchdog (stall bound or hard cap); 125 setsid failed;
24
+ # 130 signal received BEFORE the gated call. A signal received
25
+ # DURING the call is recorded but the call is left to finish (D6).
26
+ #
27
+ # Every exit path above leaves a worker_exit.json record (field defect D4).
23
28
  #
24
29
  # NB: bootstrap failures (LoadError/ScriptError from require) are caught too,
25
30
  # so the driver always sees a result rather than a silently hung handle.
@@ -79,9 +84,15 @@ end
79
84
  delegation = KairosMcp::SkillSets::Agent::StepDelegation.new(session_dir)
80
85
  my_token = boot_identity['step_token']
81
86
 
82
- shutdown = { requested: false }
87
+ # Signals are trapped, not honoured mid-flight: a TERM during a multi-minute
88
+ # gated call must not turn a sound advance into a crash-path recovery. The
89
+ # trap only records; who reads the record is decided below (field defect D6).
90
+ shutdown = { requested: false, signals: [] }
83
91
  %w[TERM INT HUP].each do |sig|
84
- Signal.trap(sig) { shutdown[:requested] = true }
92
+ Signal.trap(sig) do
93
+ shutdown[:requested] = true
94
+ shutdown[:signals] << sig
95
+ end
85
96
  end
86
97
 
87
98
  begin
@@ -118,25 +129,82 @@ end
118
129
  # worker_exit record BEFORE exit!(124). That record is not a result, so the
119
130
  # collector still takes the crash path and a committed advance is still
120
131
  # recovered from the gate log, never masked.
132
+ #
133
+ # Impl review R1 (2026-09-02, I5): a watchdog record is a FINAL record, so it
134
+ # carries the signals that landed during the call — otherwise the operator's
135
+ # TERM vanished from the crash report the moment the watchdog superseded the
136
+ # D6 interim record.
137
+ #
138
+ # Impl review R2 (2026-09-03, J1): clearing the phase only NARROWED the window
139
+ # in which the recorder, already past its phase check, could rename an interim
140
+ # record over the final one. Every final write now first stops the recorder
141
+ # (kill, then join — join is safe after kill) so the two writers are
142
+ # serialised, not raced. A recorder killed mid-write may leave a
143
+ # worker_exit.json.tmp.* file behind; readers ignore it (they open the
144
+ # renamed file only) and so does the activity clock (last_activity_time).
145
+ call_phase = { active: false }
146
+ signal_recorder = nil # assigned below; the watchdog closes over it
147
+ stop_recorder = lambda do
148
+ signal_recorder&.kill
149
+ signal_recorder&.join
150
+ end
121
151
  worker_started = Time.now
122
152
  stall_s = KairosMcp::SkillSets::Agent::StepDelegation.worker_stall_seconds
123
153
  cap_s = KairosMcp::SkillSets::Agent::StepDelegation.worker_hard_cap_seconds
154
+ tick_s = KairosMcp::SkillSets::Agent::StepDelegation.worker_watchdog_tick_seconds
124
155
  watchdog = Thread.new do
125
156
  loop do
126
- sleep 30
157
+ sleep tick_s
127
158
  elapsed = (Time.now - worker_started).round
128
159
  silent = (Time.now - delegation.last_activity_time).round
129
- if elapsed > cap_s
130
- delegation.write_worker_exit(boot_identity, 'self_timeout_hard_cap',
131
- 'elapsed_seconds' => elapsed,
132
- 'silent_seconds' => silent)
133
- exit!(124)
134
- elsif silent > stall_s
135
- delegation.write_worker_exit(boot_identity, 'self_timeout_stalled',
136
- 'elapsed_seconds' => elapsed,
137
- 'silent_seconds' => silent)
138
- exit!(124)
139
- end
160
+ exit_class = if elapsed > cap_s
161
+ 'self_timeout_hard_cap'
162
+ elsif silent > stall_s
163
+ 'self_timeout_stalled'
164
+ end
165
+ next unless exit_class
166
+
167
+ call_phase[:active] = false
168
+ stop_recorder.call
169
+ detail = { 'elapsed_seconds' => elapsed, 'silent_seconds' => silent }
170
+ detail['signals_during_call'] = shutdown[:signals].dup unless shutdown[:signals].empty?
171
+ delegation.write_worker_exit(boot_identity, exit_class, detail)
172
+ exit!(124)
173
+ end
174
+ end
175
+
176
+ # Field defect D6 (2026-08-27): the shutdown flag used to be read exactly once,
177
+ # just before the gated call, so a TERM/INT/HUP that landed DURING the call —
178
+ # which can run for minutes — was trapped, set a flag nobody read again, and
179
+ # left no record; the worker then finished and reported 'normal'. The usual
180
+ # shutdown sequence is TERM, then KILL after a grace period, so that worker
181
+ # vanished with 'no_record'. The call is still left to finish (see the trap
182
+ # comment above), but the signal is now recorded the moment it lands: an
183
+ # interim 'trapped_signal' exit record with phase 'during_gated_call'. If the
184
+ # worker survives to its own exit, the final record supersedes it and carries
185
+ # the signal list; if it is killed first, the interim record is what the
186
+ # collector's crash report finds. Recording from a thread rather than inside
187
+ # the trap keeps file I/O out of trap context.
188
+ #
189
+ # Impl review R1 (I2, I3): `seen` advances ONLY while the phase is active. A
190
+ # signal that lands before the call starts (e.g. inside ToolRegistry.new) is
191
+ # not consumed here — it is either caught by the main thread's re-check below
192
+ # or recorded on the recorder's first active tick. The phase is cleared by
193
+ # the main thread around the call (ensure) and before each final write; R2 J1
194
+ # then stops this thread (stop_recorder) before every final record, which is
195
+ # what actually prevents it overwriting an `uncaught` / watchdog / normal one.
196
+ signal_recorder = Thread.new do
197
+ seen = 0
198
+ loop do
199
+ sleep 0.25
200
+ next unless call_phase[:active]
201
+ sigs = shutdown[:signals]
202
+ next unless sigs.size > seen
203
+ seen = sigs.size
204
+ delegation.write_worker_exit(boot_identity, 'trapped_signal',
205
+ 'phase' => 'during_gated_call',
206
+ 'signals' => sigs.dup,
207
+ 'note' => 'gated call left to finish; the final exit record supersedes this one')
140
208
  end
141
209
  end
142
210
 
@@ -176,13 +244,26 @@ begin
176
244
  args = (pending['arguments'] || {}).merge('session_id' => session_id)
177
245
  args.delete('execution') # never recurse into another delegation
178
246
 
179
- if shutdown[:requested]
180
- delegation.write_worker_exit(boot_identity, 'trapped_signal')
247
+ exit_before_call = lambda do
248
+ delegation.write_worker_exit(boot_identity, 'trapped_signal',
249
+ 'phase' => 'before_gated_call',
250
+ 'signals' => shutdown[:signals].dup)
181
251
  exit 130
182
252
  end
253
+ exit_before_call.call if shutdown[:requested]
183
254
 
184
255
  registry = KairosMcp::ToolRegistry.new
185
- raw = registry.call_tool('agent_step', args)
256
+ # Re-check (I3): registry bootstrap takes long enough for a signal to land
257
+ # in it, and the recorder deliberately does not consume signals while the
258
+ # phase is inactive — so this check is what turns that signal into a record.
259
+ exit_before_call.call if shutdown[:requested]
260
+
261
+ call_phase[:active] = true
262
+ begin
263
+ raw = registry.call_tool('agent_step', args)
264
+ ensure
265
+ call_phase[:active] = false # the exception path clears it too (I2)
266
+ end
186
267
 
187
268
  # Normalize the MCP content shape to the response hash the inline call
188
269
  # would have returned.
@@ -201,7 +282,10 @@ begin
201
282
  # races a concurrently-opened fresh delegation by clearing state it may no
202
283
  # longer own or by mislabeling its result as a newer delegation's.
203
284
  delegation.write_result(response, identity: identity)
204
- delegation.write_worker_exit(identity, 'normal')
285
+ call_phase[:active] = false
286
+ stop_recorder.call
287
+ signal_detail = shutdown[:signals].empty? ? {} : { 'signals_during_call' => shutdown[:signals].dup }
288
+ delegation.write_worker_exit(identity, 'normal', signal_detail)
205
289
  exit 0
206
290
  rescue SystemExit, SignalException
207
291
  # A deliberate exit (including our own `exit 0`) or a signal is not a
@@ -221,12 +305,17 @@ rescue Exception => e # rubocop:disable Lint/RescueException
221
305
  # best effort
222
306
  end
223
307
  begin
224
- delegation.write_worker_exit(boot_identity, 'uncaught', 'error' => "#{e.class}: #{e.message}")
308
+ call_phase[:active] = false
309
+ stop_recorder.call
310
+ detail = { 'error' => "#{e.class}: #{e.message}" }
311
+ detail['signals_during_call'] = shutdown[:signals].dup unless shutdown[:signals].empty?
312
+ delegation.write_worker_exit(boot_identity, 'uncaught', detail)
225
313
  rescue StandardError
226
314
  # best effort
227
315
  end
228
316
  exit 1
229
317
  ensure
230
318
  watchdog&.kill
319
+ signal_recorder&.kill
231
320
  heartbeat_thread&.kill
232
321
  end
@@ -38,6 +38,9 @@ module KairosMcp
38
38
  # delegation.heartbeat — touched by the live worker every 2s
39
39
  # delegation_result.json— the worker's final response, tagged with the
40
40
  # issue_anchor it belongs to
41
+ # worker_exit.json — the worker's exit record (why it left), one
42
+ # file overwritten per exit; readers match on
43
+ # step_token, a fresh open_handle removes it
41
44
  class StepDelegation
42
45
  LOCK_FILE = 'delegation.lock'
43
46
  PENDING_FILE = 'delegation.json'
@@ -61,9 +64,18 @@ module KairosMcp
61
64
  # record their firing in WORKER_EXIT_FILE before exiting.
62
65
  WORKER_STALL_SECONDS_DEFAULT = 2700
63
66
  WORKER_HARD_CAP_SECONDS_DEFAULT = 10800
67
+ # How often the watchdog re-evaluates the two bounds. Env-overridable
68
+ # (floor 1 s) so a real-process test can drive both firings in seconds
69
+ # instead of minutes; the default is unchanged from the D4 fix.
70
+ WORKER_WATCHDOG_TICK_SECONDS_DEFAULT = 30
64
71
 
65
72
  WORKER_SCRIPT = File.expand_path('../../bin/agent_step_worker.rb', __dir__)
66
73
 
74
+ # atomic_write's temp file is "<path>.tmp.<pid>.<thread>"; the infix
75
+ # is shared with last_activity_time's exclusion (and the test that
76
+ # pins it) so the three cannot drift apart.
77
+ ATOMIC_TMP_INFIX = '.tmp.'
78
+
67
79
  def initialize(session_dir)
68
80
  @dir = session_dir
69
81
  FileUtils.mkdir_p(@dir)
@@ -154,11 +166,16 @@ module KairosMcp
154
166
  # (crashed) — fall through to open a fresh handle below.
155
167
  end
156
168
 
157
- # Fresh delegation: clear any stale result and ALL prior per-token
169
+ # Fresh delegation: clear any stale result, ALL prior per-token
158
170
  # heartbeats (a superseded worker's heartbeat must not count toward
159
- # this handle's liveness), then write the new handle. The
171
+ # this handle's liveness) and the previous worker's exit record
172
+ # (it belongs to a token that is stale by construction once a new
173
+ # token is minted; crash_detail already filters by token, so this
174
+ # is housekeeping — the record would otherwise linger until the
175
+ # session dir is removed), then write the new handle. The
160
176
  # issue_anchor is injected into the worker args.
161
177
  FileUtils.rm_f(result_path)
178
+ FileUtils.rm_f(worker_exit_path)
162
179
  clear_all_heartbeats
163
180
  token = SecureRandom.uuid
164
181
  worker_args = recorded_args.merge('anchor' => issue_anchor)
@@ -249,13 +266,18 @@ module KairosMcp
249
266
 
250
267
  # Most recent write anywhere in the session dir EXCLUDING liveness and
251
268
  # locking artifacts: heartbeats tick every 2 s even when the main
252
- # thread hangs, so counting them would blind the stall bound.
269
+ # thread hangs, so counting them would blind the stall bound. The
270
+ # worker's own exit record (and its atomic-write temp file) is excluded
271
+ # too: the D6 interim record is written by a TERM to a possibly HUNG
272
+ # worker, and counting it pushed the stall bound out by a whole stall
273
+ # window for exactly the worker the operator had asked to stop.
253
274
  def last_activity_time
254
275
  newest = nil
255
276
  Dir.glob(File.join(@dir, '*')).each do |f|
256
277
  base = File.basename(f)
257
278
  next if base.start_with?(HEARTBEAT_FILE)
258
279
  next if base == LOCK_FILE || base.end_with?('.lock')
280
+ next if base == WORKER_EXIT_FILE || base.start_with?("#{WORKER_EXIT_FILE}#{ATOMIC_TMP_INFIX}")
259
281
  t = begin
260
282
  File.mtime(f)
261
283
  rescue StandardError
@@ -353,6 +375,10 @@ module KairosMcp
353
375
  (ENV['KAIROS_WORKER_TIMEOUT_SECONDS'] || WORKER_HARD_CAP_SECONDS_DEFAULT).to_i
354
376
  end
355
377
 
378
+ def self.worker_watchdog_tick_seconds
379
+ [(ENV['KAIROS_WORKER_WATCHDOG_TICK_SECONDS'] || WORKER_WATCHDOG_TICK_SECONDS_DEFAULT).to_i, 1].max
380
+ end
381
+
356
382
  private
357
383
 
358
384
  # A pending handle whose worker is not (yet) declared crashed.
@@ -399,11 +425,15 @@ module KairosMcp
399
425
  end
400
426
 
401
427
  def atomic_write(path, content)
402
- tmp = "#{path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
428
+ tmp = atomic_tmp_path(path)
403
429
  File.write(tmp, content)
404
430
  File.rename(tmp, path)
405
431
  end
406
432
 
433
+ def atomic_tmp_path(path)
434
+ "#{path}#{ATOMIC_TMP_INFIX}#{Process.pid}.#{Thread.current.object_id}"
435
+ end
436
+
407
437
  def clear_all_heartbeats
408
438
  Dir.glob(File.join(@dir, "#{HEARTBEAT_FILE}.*")).each { |f| FileUtils.rm_f(f) }
409
439
  FileUtils.rm_f(File.join(@dir, HEARTBEAT_FILE)) # legacy unscoped, if any