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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE.txt +21 -0
- data/README.md +298 -0
- data/exe/agent-session-context +6 -0
- data/lib/agent/session_context/builder.rb +150 -0
- data/lib/agent/session_context/cli/options.rb +214 -0
- data/lib/agent/session_context/cli.rb +254 -0
- data/lib/agent/session_context/config.rb +206 -0
- data/lib/agent/session_context/errors.rb +15 -0
- data/lib/agent/session_context/evidence_collector.rb +227 -0
- data/lib/agent/session_context/evidence_packet.rb +271 -0
- data/lib/agent/session_context/immutable_value.rb +71 -0
- data/lib/agent/session_context/injected_context.rb +92 -0
- data/lib/agent/session_context/injected_context_collector.rb +53 -0
- data/lib/agent/session_context/item.rb +54 -0
- data/lib/agent/session_context/loop.rb +122 -0
- data/lib/agent/session_context/loop_view.rb +248 -0
- data/lib/agent/session_context/prompt.rb +40 -0
- data/lib/agent/session_context/prompt_extractor.rb +31 -0
- data/lib/agent/session_context/renderers/human_display.rb +113 -0
- data/lib/agent/session_context/renderers/json.rb +21 -0
- data/lib/agent/session_context/renderers/json_lines.rb +25 -0
- data/lib/agent/session_context/renderers/markdown.rb +130 -0
- data/lib/agent/session_context/renderers/serializer.rb +124 -0
- data/lib/agent/session_context/renderers/text.rb +128 -0
- data/lib/agent/session_context/semantic_categories.rb +89 -0
- data/lib/agent/session_context/semantic_pipeline.rb +151 -0
- data/lib/agent/session_context/semantic_schema.rb +75 -0
- data/lib/agent/session_context/session_resolver.rb +147 -0
- data/lib/agent/session_context/snapshot.rb +128 -0
- data/lib/agent/session_context/source_ref.rb +46 -0
- data/lib/agent/session_context/subprocess_runner.rb +362 -0
- data/lib/agent/session_context/summarizers/claude.rb +126 -0
- data/lib/agent/session_context/summarizers/codex.rb +132 -0
- data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
- data/lib/agent/session_context/summarizers.rb +35 -0
- data/lib/agent/session_context/summary_parser.rb +219 -0
- data/lib/agent/session_context/tool_call.rb +21 -0
- data/lib/agent/session_context/transcript.rb +236 -0
- data/lib/agent/session_context/version.rb +7 -0
- data/lib/agent/session_context.rb +60 -0
- 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
|