agent_session_context 1.0.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +298 -0
  5. data/exe/agent-session-context +6 -0
  6. data/lib/agent/session_context/builder.rb +150 -0
  7. data/lib/agent/session_context/cli/options.rb +214 -0
  8. data/lib/agent/session_context/cli.rb +254 -0
  9. data/lib/agent/session_context/config.rb +206 -0
  10. data/lib/agent/session_context/errors.rb +15 -0
  11. data/lib/agent/session_context/evidence_collector.rb +227 -0
  12. data/lib/agent/session_context/evidence_packet.rb +271 -0
  13. data/lib/agent/session_context/immutable_value.rb +71 -0
  14. data/lib/agent/session_context/injected_context.rb +92 -0
  15. data/lib/agent/session_context/injected_context_collector.rb +53 -0
  16. data/lib/agent/session_context/item.rb +54 -0
  17. data/lib/agent/session_context/loop.rb +122 -0
  18. data/lib/agent/session_context/loop_view.rb +248 -0
  19. data/lib/agent/session_context/prompt.rb +40 -0
  20. data/lib/agent/session_context/prompt_extractor.rb +31 -0
  21. data/lib/agent/session_context/renderers/human_display.rb +113 -0
  22. data/lib/agent/session_context/renderers/json.rb +21 -0
  23. data/lib/agent/session_context/renderers/json_lines.rb +25 -0
  24. data/lib/agent/session_context/renderers/markdown.rb +130 -0
  25. data/lib/agent/session_context/renderers/serializer.rb +124 -0
  26. data/lib/agent/session_context/renderers/text.rb +128 -0
  27. data/lib/agent/session_context/semantic_categories.rb +89 -0
  28. data/lib/agent/session_context/semantic_pipeline.rb +151 -0
  29. data/lib/agent/session_context/semantic_schema.rb +75 -0
  30. data/lib/agent/session_context/session_resolver.rb +147 -0
  31. data/lib/agent/session_context/snapshot.rb +128 -0
  32. data/lib/agent/session_context/source_ref.rb +46 -0
  33. data/lib/agent/session_context/subprocess_runner.rb +362 -0
  34. data/lib/agent/session_context/summarizers/claude.rb +126 -0
  35. data/lib/agent/session_context/summarizers/codex.rb +132 -0
  36. data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
  37. data/lib/agent/session_context/summarizers.rb +35 -0
  38. data/lib/agent/session_context/summary_parser.rb +219 -0
  39. data/lib/agent/session_context/tool_call.rb +21 -0
  40. data/lib/agent/session_context/transcript.rb +236 -0
  41. data/lib/agent/session_context/version.rb +7 -0
  42. data/lib/agent/session_context.rb +60 -0
  43. metadata +115 -0
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class SessionResolver
6
+ SUPPORTED = %i[claude codex].freeze
7
+
8
+ def initialize(catalog: Agent::Sessions, env: ENV)
9
+ @catalog = catalog
10
+ @env = env
11
+ end
12
+
13
+ def resolve(identifier, agent: nil)
14
+ parsed_agent, session_id = parse_identifier(identifier)
15
+ requested_agent = (agent && normalize_agent(agent, source: "agent")) || parsed_agent
16
+
17
+ return resolve_for_agent(requested_agent, session_id) if requested_agent
18
+
19
+ matches = SUPPORTED.flat_map do |candidate|
20
+ matching_sessions(candidate, session_id)
21
+ end
22
+
23
+ resolve_cardinality(
24
+ matches,
25
+ not_found_message: "Session #{session_id.inspect} was not found for claude or codex.",
26
+ ambiguous_message: "Session #{session_id.inspect} matches multiple sessions: " \
27
+ "#{matches.map(&:uid).join(", ")}. Use an exact agent-prefixed identifier."
28
+ )
29
+ end
30
+
31
+ def current(agent: nil, &)
32
+ generic_identifier = fetch_env("AGENT_SESSION_ID")
33
+ if present?(generic_identifier)
34
+ agent_name = fetch_env("AGENT_NAME")
35
+ unless present?(agent_name)
36
+ raise CurrentSessionUnavailable,
37
+ "AGENT_SESSION_ID is set but AGENT_NAME is missing."
38
+ end
39
+
40
+ return resolve(generic_identifier, agent: normalize_agent(agent_name, source: "AGENT_NAME"))
41
+ end
42
+
43
+ claude_identifier = fetch_env("CLAUDE_CODE_SESSION_ID")
44
+ codex_identifier = current_codex_identifier
45
+
46
+ if present?(claude_identifier) && present?(codex_identifier)
47
+ codex_source = present?(fetch_env("CODEX_SESSION_ID")) ? "CODEX_SESSION_ID" : "CODEX_THREAD_ID"
48
+ raise CurrentSessionUnavailable,
49
+ "Conflicting current session variables: CLAUDE_CODE_SESSION_ID and " \
50
+ "#{codex_source}. Clear one and retry."
51
+ end
52
+
53
+ return resolve(claude_identifier, agent: :claude) if present?(claude_identifier)
54
+ return resolve(codex_identifier, agent: :codex) if present?(codex_identifier)
55
+
56
+ current_from_disk(agent:, &)
57
+ end
58
+
59
+ private
60
+
61
+ def parse_identifier(identifier)
62
+ identifier = identifier.to_s
63
+ prefix, remainder = identifier.split(":", 2)
64
+
65
+ return [normalize_agent(prefix, source: "identifier prefix"), remainder] if remainder
66
+
67
+ [nil, identifier]
68
+ end
69
+
70
+ def resolve_for_agent(agent, session_id)
71
+ matches = matching_sessions(agent, session_id)
72
+ resolve_cardinality(
73
+ matches,
74
+ not_found_message: "Session #{session_id.inspect} was not found for #{agent}.",
75
+ ambiguous_message: "Session #{session_id.inspect} matches multiple sessions for #{agent}: " \
76
+ "#{matches.map(&:uid).join(", ")}."
77
+ )
78
+ end
79
+
80
+ def matching_sessions(agent, session_id)
81
+ @catalog.sessions(agent, env: @env)
82
+ .select { |session| session.id == session_id }
83
+ .force
84
+ end
85
+
86
+ def resolve_cardinality(matches, not_found_message:, ambiguous_message:)
87
+ return matches.first if matches.one?
88
+ raise SessionNotFound, not_found_message if matches.empty?
89
+
90
+ raise AmbiguousSession, ambiguous_message
91
+ end
92
+
93
+ def current_codex_identifier
94
+ session_identifier = fetch_env("CODEX_SESSION_ID")
95
+ return session_identifier if present?(session_identifier)
96
+
97
+ fetch_env("CODEX_THREAD_ID")
98
+ end
99
+
100
+ def current_from_disk(agent:, &block)
101
+ agents = agent ? [normalize_agent(agent, source: "agent")] : SUPPORTED
102
+ sessions = agents.flat_map { |candidate| @catalog.sessions(candidate, env: @env).force }
103
+
104
+ if sessions.empty?
105
+ raise CurrentSessionUnavailable,
106
+ "Current session is unavailable: no sessions found for #{agent_scope(agents)}. " \
107
+ "Set AGENT_SESSION_ID with AGENT_NAME, CLAUDE_CODE_SESSION_ID, CODEX_SESSION_ID, or CODEX_THREAD_ID."
108
+ end
109
+
110
+ latest_updated_at = sessions.map(&:updated_at).max
111
+ latest_sessions = sessions.select { |session| session.updated_at == latest_updated_at }
112
+
113
+ if latest_sessions.size > 1
114
+ raise AmbiguousSession,
115
+ "Multiple sessions share the latest update at #{latest_updated_at.iso8601(9)}: " \
116
+ "#{latest_sessions.map(&:uid).sort.join(", ")}. Pass an explicit SESSION."
117
+ end
118
+
119
+ session = latest_sessions.first
120
+ block&.call(session)
121
+ session
122
+ end
123
+
124
+ def agent_scope(agents)
125
+ return agents.first.to_s if agents.size == 1
126
+
127
+ agents.join(" or ")
128
+ end
129
+
130
+ def normalize_agent(agent, source:)
131
+ normalized = agent.to_s.downcase.to_sym
132
+ return normalized if SUPPORTED.include?(normalized)
133
+
134
+ raise UnsupportedAgent,
135
+ "Unsupported agent #{agent.inspect} from #{source}. Supported agents: #{SUPPORTED.join(", ")}."
136
+ end
137
+
138
+ def fetch_env(key)
139
+ @env[key]
140
+ end
141
+
142
+ def present?(value)
143
+ !value.nil? && !value.empty?
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ Snapshot = Data.define(
6
+ :session_uid,
7
+ :agent,
8
+ :project_path,
9
+ :captured_at,
10
+ :message_count,
11
+ :prompts,
12
+ :injected_context,
13
+ :files,
14
+ :documents,
15
+ :tool_activity,
16
+ :goals,
17
+ :decisions,
18
+ :terms,
19
+ :constraints,
20
+ :open_questions,
21
+ :next_actions,
22
+ :warnings,
23
+ :summary_metadata
24
+ ) do
25
+ def initialize(
26
+ session_uid:,
27
+ agent:,
28
+ project_path:,
29
+ captured_at:,
30
+ message_count:,
31
+ prompts: [],
32
+ injected_context: [],
33
+ files: [],
34
+ documents: [],
35
+ tool_activity: [],
36
+ goals: [],
37
+ decisions: [],
38
+ terms: [],
39
+ constraints: [],
40
+ open_questions: [],
41
+ next_actions: [],
42
+ warnings: [],
43
+ summary_metadata: {}
44
+ )
45
+ super(
46
+ session_uid: normalize_string(session_uid, :session_uid),
47
+ agent: normalize_agent(agent),
48
+ project_path: normalize_optional_string(project_path, :project_path),
49
+ captured_at: captured_at,
50
+ message_count: normalize_message_count(message_count),
51
+ prompts: duplicate_collection(prompts),
52
+ injected_context: duplicate_collection(injected_context),
53
+ files: duplicate_collection(files),
54
+ documents: duplicate_collection(documents),
55
+ tool_activity: duplicate_collection(tool_activity),
56
+ goals: duplicate_collection(goals),
57
+ decisions: duplicate_collection(decisions),
58
+ terms: duplicate_collection(terms),
59
+ constraints: duplicate_collection(constraints),
60
+ open_questions: duplicate_collection(open_questions),
61
+ next_actions: duplicate_collection(next_actions),
62
+ warnings: normalize_warnings(warnings),
63
+ summary_metadata: normalize_summary_metadata(summary_metadata)
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def duplicate_collection(value)
70
+ ImmutableValue.copy(Array(value))
71
+ end
72
+
73
+ def normalize_agent(value)
74
+ return value if value.is_a?(Symbol)
75
+ return value.to_sym if value.respond_to?(:to_sym)
76
+
77
+ raise TypeError, "agent must be symbolizable"
78
+ end
79
+
80
+ def normalize_message_count(value)
81
+ count =
82
+ if value.is_a?(Integer)
83
+ value
84
+ elsif value.respond_to?(:to_int)
85
+ value.to_int
86
+ elsif value.is_a?(String)
87
+ Integer(value, exception: false)
88
+ end
89
+
90
+ raise TypeError, "message_count must be an Integer or integer-like value" if count.nil?
91
+ raise ArgumentError, "message_count must be greater than or equal to 0" if count.negative?
92
+
93
+ count
94
+ end
95
+
96
+ def normalize_string(value, name)
97
+ raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
98
+
99
+ String.new(value.to_str).freeze
100
+ end
101
+
102
+ def normalize_optional_string(value, name)
103
+ return if value.nil?
104
+
105
+ normalize_string(value, name)
106
+ end
107
+
108
+ def normalize_summary_metadata(value)
109
+ raise TypeError, "summary_metadata must be a Hash" unless value.is_a?(Hash)
110
+
111
+ value.each_with_object({}) do |(key, metadata_value), normalized|
112
+ normalized[normalize_summary_metadata_key(key)] = ImmutableValue.copy(metadata_value)
113
+ end.freeze
114
+ end
115
+
116
+ def normalize_summary_metadata_key(key)
117
+ return key if key.is_a?(Symbol)
118
+ return key.to_sym if key.respond_to?(:to_sym)
119
+
120
+ raise TypeError, "summary_metadata keys must be symbolizable"
121
+ end
122
+
123
+ def normalize_warnings(value)
124
+ Array(value).map { |warning| normalize_string(warning, :warning) }.freeze
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ SourceRef = Data.define(:session_uid, :message_index, :part_index) do
6
+ def initialize(session_uid:, message_index:, part_index:)
7
+ super(
8
+ session_uid: normalize_string(session_uid, :session_uid),
9
+ message_index: normalize_positive_index(message_index, :message_index),
10
+ part_index: normalize_positive_index(part_index, :part_index)
11
+ )
12
+ end
13
+
14
+ def to_s
15
+ format("%<session_uid>s/message:%<message_index>06d/part:%<part_index>06d",
16
+ session_uid: session_uid,
17
+ message_index: message_index,
18
+ part_index: part_index)
19
+ end
20
+
21
+ private
22
+
23
+ def normalize_positive_index(value, name)
24
+ index =
25
+ if value.is_a?(Integer)
26
+ value
27
+ elsif value.respond_to?(:to_int)
28
+ value.to_int
29
+ elsif value.is_a?(String)
30
+ Integer(value, exception: false)
31
+ end
32
+
33
+ raise TypeError, "#{name} must be an Integer or integer-like value" if index.nil?
34
+ raise ArgumentError, "#{name} must be greater than or equal to 1" if index < 1
35
+
36
+ index
37
+ end
38
+
39
+ def normalize_string(value, name)
40
+ raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
41
+
42
+ String.new(value.to_str).freeze
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "open3"
5
+
6
+ module Agent
7
+ module SessionContext
8
+ class SubprocessRunner
9
+ DEFAULT_CLOCK = lambda {
10
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ }
12
+ private_constant :DEFAULT_CLOCK
13
+
14
+ class TimeoutError < StandardError
15
+ attr_reader :timeout_seconds
16
+
17
+ def initialize(timeout_seconds)
18
+ @timeout_seconds = timeout_seconds
19
+ super("subprocess exceeded #{timeout_seconds} seconds")
20
+ end
21
+ end
22
+
23
+ class OutputLimitError < StandardError
24
+ attr_reader :stream, :max_output_bytes
25
+
26
+ def initialize(stream:, max_output_bytes:)
27
+ @stream = stream
28
+ @max_output_bytes = max_output_bytes
29
+ super("#{stream} exceeded #{max_output_bytes} bytes")
30
+ end
31
+ end
32
+
33
+ def initialize(timeout_seconds:, max_output_bytes:, clock: DEFAULT_CLOCK, termination_grace_seconds: 0.5)
34
+ @timeout_seconds = validate_timeout!(timeout_seconds)
35
+ @max_output_bytes = validate_max_output_bytes!(max_output_bytes)
36
+ @clock = validate_clock!(clock)
37
+ @termination_grace_seconds = validate_termination_grace!(termination_grace_seconds)
38
+ end
39
+
40
+ def call(env:, argv:, stdin_data:)
41
+ deadline = monotonic_now + @timeout_seconds
42
+ stdin = stdout = stderr = wait_thread = stdin_thread = nil
43
+ stdin_error = nil
44
+ completed = false
45
+ stdout_buffer = String.new.b
46
+ stderr_buffer = String.new.b
47
+
48
+ stdin, stdout, stderr, wait_thread = Open3.popen3(
49
+ env,
50
+ *argv,
51
+ unsetenv_others: true,
52
+ **process_group_options
53
+ )
54
+ prepare_streams!(stdin, stdout, stderr)
55
+ stdin_thread, stdin_error = start_stdin_writer(stdin, stdin_data)
56
+
57
+ status = monitor_process(
58
+ deadline:,
59
+ wait_thread:,
60
+ stdout:,
61
+ stderr:,
62
+ stdout_buffer:,
63
+ stderr_buffer:,
64
+ stdin_thread:,
65
+ stdin_error:
66
+ )
67
+
68
+ join_stdin_thread!(stdin_thread, stdin_error)
69
+ completed = true
70
+ [stdout_buffer, stderr_buffer, status]
71
+ ensure
72
+ cleanup_call(
73
+ wait_thread:,
74
+ stdin:,
75
+ stdout:,
76
+ stderr:,
77
+ stdin_thread:,
78
+ stdin_error:,
79
+ completed:,
80
+ primary_error: $ERROR_INFO
81
+ )
82
+ end
83
+
84
+ private
85
+
86
+ def validate_timeout!(value)
87
+ validate_numeric!(value, name: "timeout_seconds", allow_zero: false)
88
+ end
89
+
90
+ def validate_max_output_bytes!(value)
91
+ unless value.is_a?(Integer) && value.positive?
92
+ raise ArgumentError,
93
+ "max_output_bytes must be a positive Integer"
94
+ end
95
+
96
+ value
97
+ end
98
+
99
+ def validate_clock!(value)
100
+ raise ArgumentError, "clock must respond to call" unless value.respond_to?(:call)
101
+
102
+ value
103
+ end
104
+
105
+ def validate_termination_grace!(value)
106
+ validate_numeric!(value, name: "termination_grace_seconds", allow_zero: true)
107
+ end
108
+
109
+ def validate_numeric!(value, name:, allow_zero:)
110
+ valid_type = value.is_a?(Integer) || value.is_a?(Float)
111
+ unless valid_type
112
+ raise ArgumentError,
113
+ "#{name} must be a #{allow_zero ? "nonnegative" : "positive"} finite number"
114
+ end
115
+
116
+ finite = !value.is_a?(Float) || value.finite?
117
+ positive_enough = allow_zero ? value >= 0 : value.positive?
118
+ unless finite && positive_enough
119
+ raise ArgumentError,
120
+ "#{name} must be a #{allow_zero ? "nonnegative" : "positive"} finite number"
121
+ end
122
+
123
+ value
124
+ end
125
+
126
+ def process_group_options
127
+ return { new_pgroup: true } if Gem.win_platform?
128
+
129
+ { pgroup: true }
130
+ end
131
+
132
+ def prepare_streams!(stdin, stdout, stderr)
133
+ stdin.binmode
134
+ stdout.binmode
135
+ stderr.binmode
136
+ end
137
+
138
+ def start_stdin_writer(stdin, stdin_data)
139
+ error_box = { error: nil }
140
+ writer_thread = Thread.new do
141
+ write_stdin(stdin, stdin_data)
142
+ rescue Errno::EPIPE
143
+ nil
144
+ rescue IOError => e
145
+ error_box[:error] = e unless stdin.closed?
146
+ rescue StandardError => e
147
+ error_box[:error] = e
148
+ ensure
149
+ close_stream(stdin)
150
+ end
151
+
152
+ [writer_thread, error_box]
153
+ end
154
+
155
+ def write_stdin(stdin, stdin_data)
156
+ return if stdin_data.nil?
157
+
158
+ payload = stdin_data.dup.force_encoding(Encoding::BINARY)
159
+ stdin.write(payload)
160
+ stdin.flush
161
+ end
162
+
163
+ def monitor_process(deadline:, wait_thread:, stdout:, stderr:, stdout_buffer:, stderr_buffer:, stdin_thread:,
164
+ stdin_error:)
165
+ streams = { stdout: stdout, stderr: stderr }
166
+ status = nil
167
+
168
+ loop do
169
+ raise_writer_error!(stdin_thread, stdin_error)
170
+ stdin_writer_finished = stdin_writer_finished?(stdin_thread)
171
+
172
+ raise TimeoutError, @timeout_seconds if timed_out?(deadline)
173
+
174
+ status = wait_thread.value if wait_thread.join(0)
175
+ break if status && streams.empty? && stdin_writer_finished
176
+
177
+ ready = wait_for_ready_streams(wait_thread, stdin_thread, streams.values, deadline)
178
+
179
+ ready.each do |stream|
180
+ name = streams.key(stream)
181
+ next unless name
182
+
183
+ drain_stream!(
184
+ name:,
185
+ stream:,
186
+ buffer: name == :stdout ? stdout_buffer : stderr_buffer,
187
+ streams:
188
+ )
189
+ end
190
+ end
191
+
192
+ status || wait_thread.value
193
+ end
194
+
195
+ def drain_stream!(name:, stream:, buffer:, streams:)
196
+ chunk = stream.read_nonblock(4096, exception: false)
197
+
198
+ case chunk
199
+ when :wait_readable
200
+ nil
201
+ when nil
202
+ close_stream(stream)
203
+ streams.delete(name)
204
+ else
205
+ append_chunk!(buffer, chunk, name)
206
+ end
207
+ end
208
+
209
+ def append_chunk!(buffer, chunk, stream_name)
210
+ remaining = @max_output_bytes - buffer.bytesize
211
+ if chunk.bytesize > remaining
212
+ buffer << chunk.byteslice(0, remaining) if remaining.positive?
213
+ raise OutputLimitError.new(stream: stream_name, max_output_bytes: @max_output_bytes)
214
+ end
215
+
216
+ buffer << chunk
217
+ end
218
+
219
+ def wait_for_ready_streams(wait_thread, stdin_thread, readable_streams, deadline)
220
+ interval = poll_interval(deadline)
221
+ return [] if interval <= 0
222
+
223
+ if readable_streams.empty?
224
+ wait_deadline = monotonic_now + interval
225
+ wait_thread.join(remaining_poll_time(wait_deadline))
226
+ stdin_thread&.join(remaining_poll_time(wait_deadline))
227
+ return []
228
+ end
229
+
230
+ IO.select(readable_streams, nil, nil, interval)&.first || []
231
+ end
232
+
233
+ def raise_writer_error!(stdin_thread, error_box)
234
+ return unless stdin_thread
235
+ return unless stdin_thread.join(0)
236
+ return unless error_box[:error]
237
+
238
+ raise error_box[:error]
239
+ end
240
+
241
+ def stdin_writer_finished?(stdin_thread)
242
+ return true unless stdin_thread
243
+
244
+ !stdin_thread.join(0).nil?
245
+ end
246
+
247
+ def cleanup_call(wait_thread:, stdin:, stdout:, stderr:, stdin_thread:, stdin_error:, completed:, primary_error:)
248
+ terminate_process(wait_thread) if wait_thread && !completed
249
+ rescue StandardError
250
+ nil
251
+ ensure
252
+ close_stream(stdin)
253
+ close_stream(stdout)
254
+ close_stream(stderr)
255
+ begin
256
+ join_stdin_thread!(stdin_thread, stdin_error, suppress_error: !primary_error.nil?) if stdin_thread
257
+ rescue StandardError
258
+ nil
259
+ end
260
+ end
261
+
262
+ def terminate_process(wait_thread)
263
+ pid = wait_thread.pid
264
+
265
+ if Gem.win_platform?
266
+ terminate_direct_child(wait_thread)
267
+ else
268
+ terminate_process_group(pid)
269
+ end
270
+
271
+ wait_thread.value
272
+ rescue Errno::ECHILD
273
+ nil
274
+ end
275
+
276
+ def terminate_direct_child(wait_thread)
277
+ pid = wait_thread.pid
278
+
279
+ begin
280
+ Process.kill("TERM", pid)
281
+ rescue Errno::EINVAL
282
+ kill_direct_child(pid)
283
+ return
284
+ rescue Errno::ESRCH
285
+ return
286
+ end
287
+
288
+ return unless wait_thread.join(@termination_grace_seconds).nil?
289
+
290
+ kill_direct_child(pid)
291
+ end
292
+
293
+ def terminate_process_group(pid)
294
+ Process.kill("TERM", -pid)
295
+ rescue Errno::ESRCH
296
+ nil
297
+ else
298
+ sleep_cleanup_grace(@termination_grace_seconds)
299
+
300
+ begin
301
+ Process.kill("KILL", -pid)
302
+ rescue Errno::ESRCH
303
+ nil
304
+ end
305
+ end
306
+
307
+ def kill_direct_child(pid)
308
+ Process.kill("KILL", pid)
309
+ rescue Errno::ESRCH
310
+ nil
311
+ end
312
+
313
+ def timed_out?(deadline)
314
+ monotonic_now >= deadline
315
+ end
316
+
317
+ def remaining_seconds(deadline)
318
+ [deadline - monotonic_now, 0].max
319
+ end
320
+
321
+ def poll_interval(deadline)
322
+ [remaining_seconds(deadline), 0.05].min
323
+ end
324
+
325
+ def remaining_poll_time(wait_deadline)
326
+ [wait_deadline - monotonic_now, 0].max
327
+ end
328
+
329
+ def monotonic_now
330
+ @clock.call
331
+ end
332
+
333
+ def cleanup_monotonic_now
334
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
335
+ end
336
+
337
+ def sleep_cleanup_grace(duration)
338
+ return if duration.zero?
339
+
340
+ deadline = cleanup_monotonic_now + duration
341
+
342
+ sleep([deadline - cleanup_monotonic_now, 0.01].min) while cleanup_monotonic_now < deadline
343
+ end
344
+
345
+ def close_stream(stream)
346
+ return unless stream
347
+ return if stream.closed?
348
+
349
+ stream.close
350
+ rescue IOError, Errno::EBADF
351
+ nil
352
+ end
353
+
354
+ def join_stdin_thread!(stdin_thread, error_box, suppress_error: false)
355
+ return unless stdin_thread
356
+
357
+ stdin_thread.join
358
+ raise error_box[:error] if !suppress_error && error_box && error_box[:error]
359
+ end
360
+ end
361
+ end
362
+ end