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 +4 -4
- data/CHANGELOG.md +48 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/bin/agent_step_worker.rb +109 -20
- data/templates/skillsets/agent/lib/agent/step_delegation.rb +34 -4
- data/templates/skillsets/agent/test/test_agent_worker_exit_and_review_parse.rb +388 -7
- data/templates/skillsets/agent/test/test_agent_worker_process.rb +386 -0
- data/templates/skillsets/agent/tools/agent_step.rb +128 -16
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ebfc6fe1ff6c901c620512c56c698f6dca7bd058143e611547864a0a5460e29
|
|
4
|
+
data.tar.gz: caa3229fcb55a9f057a88be84a7a7694e514aff6e9b31ec5a8796d5e1ca93138
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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 =
|
|
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
|