claude_swarm 0.3.2 ā 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 +4 -4
- data/.claude/commands/release.md +27 -0
- data/CHANGELOG.md +174 -0
- data/CLAUDE.md +62 -3
- data/README.md +131 -5
- data/examples/simple-session-hook-swarm.yml +37 -0
- data/lib/claude_swarm/base_executor.rb +133 -0
- data/lib/claude_swarm/claude_code_executor.rb +245 -210
- data/lib/claude_swarm/claude_mcp_server.rb +3 -2
- data/lib/claude_swarm/cli.rb +27 -20
- data/lib/claude_swarm/commands/ps.rb +29 -10
- data/lib/claude_swarm/commands/show.rb +4 -5
- data/lib/claude_swarm/configuration.rb +19 -12
- data/lib/claude_swarm/hooks/session_start_hook.rb +42 -0
- data/lib/claude_swarm/json_handler.rb +91 -0
- data/lib/claude_swarm/mcp_generator.rb +7 -5
- data/lib/claude_swarm/openai/chat_completion.rb +16 -16
- data/lib/claude_swarm/openai/executor.rb +155 -209
- data/lib/claude_swarm/openai/responses.rb +29 -29
- data/lib/claude_swarm/orchestrator.rb +452 -257
- data/lib/claude_swarm/session_cost_calculator.rb +130 -14
- data/lib/claude_swarm/session_path.rb +2 -8
- data/lib/claude_swarm/settings_generator.rb +77 -0
- data/lib/claude_swarm/system_utils.rb +6 -2
- data/lib/claude_swarm/templates/generation_prompt.md.erb +6 -6
- data/lib/claude_swarm/tools/task_tool.rb +13 -1
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +2 -2
- data/lib/claude_swarm.rb +23 -2
- data/team.yml +75 -3
- data/team_v2.yml +367 -0
- metadata +54 -5
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
module ClaudeSwarm
|
|
4
4
|
class Orchestrator
|
|
5
5
|
include SystemUtils
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
attr_reader :config, :session_path, :session_log_path
|
|
8
|
+
|
|
9
|
+
["INT", "TERM", "QUIT"].each do |signal|
|
|
10
|
+
Signal.trap(signal) do
|
|
11
|
+
puts "\nš Received #{signal} signal."
|
|
12
|
+
end
|
|
13
|
+
end
|
|
7
14
|
|
|
8
15
|
def initialize(configuration, mcp_generator, vibe: false, prompt: nil, interactive_prompt: nil, stream_logs: false, debug: false,
|
|
9
16
|
restore_session_path: nil, worktree: nil, session_id: nil)
|
|
@@ -15,7 +22,6 @@ module ClaudeSwarm
|
|
|
15
22
|
@stream_logs = stream_logs
|
|
16
23
|
@debug = debug
|
|
17
24
|
@restore_session_path = restore_session_path
|
|
18
|
-
@session_path = nil
|
|
19
25
|
@provided_session_id = session_id
|
|
20
26
|
# Store worktree option for later use
|
|
21
27
|
@worktree_option = worktree
|
|
@@ -25,119 +31,143 @@ module ClaudeSwarm
|
|
|
25
31
|
@modified_instances = nil
|
|
26
32
|
# Track start time for runtime calculation
|
|
27
33
|
@start_time = nil
|
|
34
|
+
# Track transcript tailing thread
|
|
35
|
+
@transcript_thread = nil
|
|
28
36
|
|
|
29
37
|
# Set environment variable for prompt mode to suppress output
|
|
30
38
|
ENV["CLAUDE_SWARM_PROMPT"] = "1" if @non_interactive_prompt
|
|
39
|
+
|
|
40
|
+
# Initialize session path
|
|
41
|
+
if @restore_session_path
|
|
42
|
+
# Use existing session path for restoration
|
|
43
|
+
@session_path = @restore_session_path
|
|
44
|
+
@session_log_path = File.join(@session_path, "session.log")
|
|
45
|
+
else
|
|
46
|
+
# Generate new session path
|
|
47
|
+
session_params = { working_dir: ClaudeSwarm.root_dir }
|
|
48
|
+
session_params[:session_id] = @provided_session_id if @provided_session_id
|
|
49
|
+
@session_path = SessionPath.generate(**session_params)
|
|
50
|
+
SessionPath.ensure_directory(@session_path)
|
|
51
|
+
@session_log_path = File.join(@session_path, "session.log")
|
|
52
|
+
|
|
53
|
+
# Extract session ID from path (the timestamp part)
|
|
54
|
+
@session_id = File.basename(@session_path)
|
|
55
|
+
|
|
56
|
+
end
|
|
57
|
+
ENV["CLAUDE_SWARM_SESSION_PATH"] = @session_path
|
|
58
|
+
ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
|
|
59
|
+
|
|
60
|
+
# Initialize components that depend on session path
|
|
61
|
+
@process_tracker = ProcessTracker.new(@session_path)
|
|
62
|
+
@settings_generator = SettingsGenerator.new(@config)
|
|
63
|
+
|
|
64
|
+
# Initialize WorktreeManager if needed
|
|
65
|
+
if @needs_worktree_manager && !@restore_session_path
|
|
66
|
+
cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
|
|
67
|
+
@worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
|
|
68
|
+
end
|
|
31
69
|
end
|
|
32
70
|
|
|
33
71
|
def start
|
|
34
72
|
# Track start time
|
|
35
73
|
@start_time = Time.now
|
|
36
74
|
|
|
75
|
+
begin
|
|
76
|
+
start_internal
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
# Ensure cleanup happens even on unexpected errors
|
|
79
|
+
cleanup_processes
|
|
80
|
+
cleanup_run_symlink
|
|
81
|
+
cleanup_worktrees
|
|
82
|
+
raise e
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def start_internal
|
|
37
89
|
if @restore_session_path
|
|
38
|
-
|
|
90
|
+
non_interactive_output do
|
|
39
91
|
puts "š Restoring Claude Swarm: #{@config.swarm_name}"
|
|
40
92
|
puts "š Vibe mode ON" if @vibe
|
|
41
|
-
puts
|
|
42
93
|
end
|
|
43
94
|
|
|
44
|
-
# Use existing session path
|
|
45
|
-
session_path = @restore_session_path
|
|
46
|
-
@session_path = session_path
|
|
47
|
-
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
|
48
|
-
ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
|
|
49
|
-
|
|
50
95
|
# Create run symlink for restored session
|
|
51
96
|
create_run_symlink
|
|
52
97
|
|
|
53
|
-
|
|
54
|
-
puts "š Using existing session: #{session_path}/"
|
|
55
|
-
puts
|
|
98
|
+
non_interactive_output do
|
|
99
|
+
puts "š Using existing session: #{@session_path}/"
|
|
56
100
|
end
|
|
57
101
|
|
|
58
|
-
# Initialize process tracker
|
|
59
|
-
@process_tracker = ProcessTracker.new(session_path)
|
|
60
|
-
|
|
61
|
-
# Set up signal handlers to clean up child processes
|
|
62
|
-
setup_signal_handlers
|
|
63
|
-
|
|
64
102
|
# Check if the original session used worktrees
|
|
65
|
-
restore_worktrees_if_needed(session_path)
|
|
103
|
+
restore_worktrees_if_needed(@session_path)
|
|
66
104
|
|
|
67
105
|
# Regenerate MCP configurations with session IDs for restoration
|
|
68
106
|
@generator.generate_all
|
|
69
|
-
|
|
107
|
+
non_interactive_output do
|
|
70
108
|
puts "ā Regenerated MCP configurations with session IDs"
|
|
71
|
-
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Generate settings files
|
|
112
|
+
@settings_generator.generate_all
|
|
113
|
+
non_interactive_output do
|
|
114
|
+
puts "ā Generated settings files with hooks"
|
|
72
115
|
end
|
|
73
116
|
else
|
|
74
|
-
|
|
117
|
+
non_interactive_output do
|
|
75
118
|
puts "š Starting Claude Swarm: #{@config.swarm_name}"
|
|
76
119
|
puts "š Vibe mode ON" if @vibe
|
|
77
|
-
puts
|
|
78
120
|
end
|
|
79
121
|
|
|
80
|
-
# Generate and set session path for all instances
|
|
81
|
-
session_path = if @provided_session_id
|
|
82
|
-
SessionPath.generate(working_dir: ClaudeSwarm.root_dir, session_id: @provided_session_id)
|
|
83
|
-
else
|
|
84
|
-
SessionPath.generate(working_dir: ClaudeSwarm.root_dir)
|
|
85
|
-
end
|
|
86
|
-
SessionPath.ensure_directory(session_path)
|
|
87
|
-
@session_path = session_path
|
|
88
|
-
|
|
89
|
-
# Extract session ID from path (the timestamp part)
|
|
90
|
-
@session_id = File.basename(session_path)
|
|
91
|
-
|
|
92
|
-
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
|
93
|
-
ENV["CLAUDE_SWARM_ROOT_DIR"] = ClaudeSwarm.root_dir
|
|
94
|
-
|
|
95
122
|
# Create run symlink for new session
|
|
96
123
|
create_run_symlink
|
|
97
124
|
|
|
98
|
-
|
|
99
|
-
puts "š Session files will be saved to: #{session_path}/"
|
|
100
|
-
puts
|
|
125
|
+
non_interactive_output do
|
|
126
|
+
puts "š Session files will be saved to: #{@session_path}/"
|
|
101
127
|
end
|
|
102
128
|
|
|
103
|
-
#
|
|
104
|
-
@
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
setup_signal_handlers
|
|
108
|
-
|
|
109
|
-
# Create WorktreeManager if needed with session ID
|
|
110
|
-
if @needs_worktree_manager
|
|
111
|
-
cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
|
|
112
|
-
@worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
|
|
113
|
-
puts "š³ Setting up Git worktrees..." unless @non_interactive_prompt
|
|
129
|
+
# Setup worktrees if needed
|
|
130
|
+
if @worktree_manager
|
|
131
|
+
begin
|
|
132
|
+
non_interactive_output { print("š³ Setting up Git worktrees...") }
|
|
114
133
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
134
|
+
# Get all instances for worktree setup
|
|
135
|
+
# Note: instances.values already includes the main instance
|
|
136
|
+
all_instances = @config.instances.values
|
|
118
137
|
|
|
119
|
-
|
|
138
|
+
@worktree_manager.setup_worktrees(all_instances)
|
|
120
139
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
140
|
+
non_interactive_output do
|
|
141
|
+
puts "ā Worktrees created with branch: #{@worktree_manager.worktree_name}"
|
|
142
|
+
end
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
non_interactive_output { print("ā Failed to setup worktrees: #{e.message}") }
|
|
145
|
+
cleanup_processes
|
|
146
|
+
cleanup_run_symlink
|
|
147
|
+
cleanup_worktrees
|
|
148
|
+
raise
|
|
124
149
|
end
|
|
125
150
|
end
|
|
126
151
|
|
|
127
152
|
# Generate all MCP configuration files
|
|
128
153
|
@generator.generate_all
|
|
129
|
-
|
|
154
|
+
non_interactive_output do
|
|
130
155
|
puts "ā Generated MCP configurations in session directory"
|
|
131
|
-
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Generate settings files
|
|
159
|
+
@settings_generator.generate_all
|
|
160
|
+
non_interactive_output do
|
|
161
|
+
puts "ā Generated settings files with hooks"
|
|
132
162
|
end
|
|
133
163
|
|
|
134
164
|
# Save swarm config path for restoration
|
|
135
|
-
save_swarm_config_path(session_path)
|
|
165
|
+
save_swarm_config_path(@session_path)
|
|
136
166
|
end
|
|
137
167
|
|
|
138
168
|
# Launch the main instance (fetch after worktree setup to get modified paths)
|
|
139
169
|
main_instance = @config.main_instance_config
|
|
140
|
-
|
|
170
|
+
non_interactive_output do
|
|
141
171
|
puts "š Launching main instance: #{@config.main_instance}"
|
|
142
172
|
puts " Model: #{main_instance[:model]}"
|
|
143
173
|
if main_instance[:directories].size == 1
|
|
@@ -150,53 +180,89 @@ module ClaudeSwarm
|
|
|
150
180
|
puts " Disallowed tools: #{main_instance[:disallowed_tools].join(", ")}" if main_instance[:disallowed_tools]&.any?
|
|
151
181
|
puts " Connections: #{main_instance[:connections].join(", ")}" if main_instance[:connections].any?
|
|
152
182
|
puts " š Vibe mode ON for this instance" if main_instance[:vibe]
|
|
153
|
-
puts
|
|
154
183
|
end
|
|
155
184
|
|
|
156
185
|
command = build_main_command(main_instance)
|
|
157
|
-
if @debug
|
|
158
|
-
|
|
159
|
-
|
|
186
|
+
if @debug
|
|
187
|
+
non_interactive_output do
|
|
188
|
+
puts "š Running: #{format_command_for_display(command)}"
|
|
189
|
+
end
|
|
160
190
|
end
|
|
161
191
|
|
|
162
192
|
# Start log streaming thread if in non-interactive mode with --stream-logs
|
|
163
193
|
log_thread = nil
|
|
164
194
|
log_thread = start_log_streaming if @non_interactive_prompt && @stream_logs
|
|
165
195
|
|
|
196
|
+
# Start transcript tailing thread for main instance
|
|
197
|
+
@transcript_thread = start_transcript_tailing
|
|
198
|
+
|
|
166
199
|
# Write the current process PID (orchestrator) to a file for easy access
|
|
167
200
|
main_pid_file = File.join(@session_path, "main_pid")
|
|
168
201
|
File.write(main_pid_file, Process.pid.to_s)
|
|
169
202
|
|
|
170
|
-
# Execute
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
end
|
|
203
|
+
# Execute before commands if specified
|
|
204
|
+
# If the main instance directory exists, run in it for backward compatibility
|
|
205
|
+
# If it doesn't exist, run in the parent directory so before commands can create it
|
|
206
|
+
before_commands = @config.before_commands
|
|
207
|
+
if before_commands.any? && !@restore_session_path
|
|
208
|
+
non_interactive_output do
|
|
209
|
+
puts "āļø Executing before commands..."
|
|
210
|
+
end
|
|
179
211
|
|
|
212
|
+
# Determine where to run before commands
|
|
213
|
+
if File.exist?(main_instance[:directory])
|
|
214
|
+
# Directory exists, run commands in it (backward compatibility)
|
|
215
|
+
before_commands_dir = main_instance[:directory]
|
|
216
|
+
else
|
|
217
|
+
# Directory doesn't exist, run in parent directory
|
|
218
|
+
# This allows before commands to create the directory
|
|
219
|
+
parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
|
|
220
|
+
# Ensure parent directory exists (important for worktrees)
|
|
221
|
+
FileUtils.mkdir_p(parent_dir)
|
|
222
|
+
before_commands_dir = parent_dir
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
Dir.chdir(before_commands_dir) do
|
|
180
226
|
success = execute_before_commands?(before_commands)
|
|
181
227
|
unless success
|
|
182
|
-
|
|
228
|
+
non_interactive_output { print("ā Before commands failed. Aborting swarm launch.") }
|
|
183
229
|
cleanup_processes
|
|
184
230
|
cleanup_run_symlink
|
|
185
231
|
cleanup_worktrees
|
|
186
232
|
exit(1)
|
|
187
233
|
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
non_interactive_output do
|
|
237
|
+
puts "ā Before commands completed successfully"
|
|
238
|
+
end
|
|
188
239
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
240
|
+
# Validate directories after before commands have run
|
|
241
|
+
begin
|
|
242
|
+
@config.validate_directories
|
|
243
|
+
non_interactive_output do
|
|
244
|
+
puts "ā All directories validated successfully"
|
|
192
245
|
end
|
|
246
|
+
rescue ClaudeSwarm::Error => e
|
|
247
|
+
non_interactive_output { print("ā Directory validation failed: #{e.message}") }
|
|
248
|
+
cleanup_processes
|
|
249
|
+
cleanup_run_symlink
|
|
250
|
+
cleanup_worktrees
|
|
251
|
+
exit(1)
|
|
193
252
|
end
|
|
253
|
+
end
|
|
194
254
|
|
|
255
|
+
# Execute the main instance - this will cascade to other instances via MCP
|
|
256
|
+
Dir.chdir(main_instance[:directory]) do
|
|
195
257
|
# Execute main Claude instance with unbundled environment to avoid bundler conflicts
|
|
196
258
|
# This ensures the main instance runs in a clean environment without inheriting
|
|
197
259
|
# Claude Swarm's BUNDLE_* environment variables
|
|
198
260
|
Bundler.with_unbundled_env do
|
|
199
|
-
|
|
261
|
+
if @non_interactive_prompt
|
|
262
|
+
stream_to_session_log(*command)
|
|
263
|
+
else
|
|
264
|
+
system!(*command)
|
|
265
|
+
end
|
|
200
266
|
end
|
|
201
267
|
end
|
|
202
268
|
|
|
@@ -206,23 +272,36 @@ module ClaudeSwarm
|
|
|
206
272
|
log_thread.join
|
|
207
273
|
end
|
|
208
274
|
|
|
275
|
+
# Clean up transcript tailing thread
|
|
276
|
+
cleanup_transcript_thread
|
|
277
|
+
|
|
209
278
|
# Display runtime and cost summary
|
|
210
279
|
display_summary
|
|
211
280
|
|
|
212
281
|
# Execute after commands if specified
|
|
282
|
+
# Use the same logic as before commands for consistency
|
|
213
283
|
after_commands = @config.after_commands
|
|
214
284
|
if after_commands.any? && !@restore_session_path
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
285
|
+
# Determine where to run after commands (same logic as before commands)
|
|
286
|
+
if File.exist?(main_instance[:directory])
|
|
287
|
+
# Directory exists, run commands in it
|
|
288
|
+
after_commands_dir = main_instance[:directory]
|
|
289
|
+
else
|
|
290
|
+
# Directory doesn't exist (shouldn't happen after main instance runs, but be safe)
|
|
291
|
+
parent_dir = File.dirname(File.expand_path(main_instance[:directory]))
|
|
292
|
+
after_commands_dir = parent_dir
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
Dir.chdir(after_commands_dir) do
|
|
296
|
+
non_interactive_output do
|
|
297
|
+
print("āļø Executing after commands...")
|
|
220
298
|
end
|
|
221
299
|
|
|
222
300
|
success = execute_after_commands?(after_commands)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
301
|
+
unless success
|
|
302
|
+
non_interactive_output do
|
|
303
|
+
puts "ā ļø Some after commands failed"
|
|
304
|
+
end
|
|
226
305
|
end
|
|
227
306
|
end
|
|
228
307
|
end
|
|
@@ -233,117 +312,19 @@ module ClaudeSwarm
|
|
|
233
312
|
cleanup_worktrees
|
|
234
313
|
end
|
|
235
314
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def execute_before_commands?(commands)
|
|
239
|
-
log_file = File.join(@session_path, "session.log") if @session_path
|
|
240
|
-
|
|
241
|
-
commands.each_with_index do |command, index|
|
|
242
|
-
# Log the command execution to session log
|
|
243
|
-
if @session_path
|
|
244
|
-
File.open(log_file, "a") do |f|
|
|
245
|
-
f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing before command #{index + 1}/#{commands.size}: #{command}"
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
# Execute the command and capture output
|
|
250
|
-
begin
|
|
251
|
-
puts "Debug: Executing command #{index + 1}/#{commands.size}: #{command}" if @debug && !@non_interactive_prompt
|
|
252
|
-
|
|
253
|
-
# Use system with output capture
|
|
254
|
-
output = %x(#{command} 2>&1)
|
|
255
|
-
success = $CHILD_STATUS.success?
|
|
256
|
-
|
|
257
|
-
# Log the output
|
|
258
|
-
if @session_path
|
|
259
|
-
File.open(log_file, "a") do |f|
|
|
260
|
-
f.puts "Command output:"
|
|
261
|
-
f.puts output
|
|
262
|
-
f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
|
|
263
|
-
f.puts "-" * 80
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
# Show output if in debug mode or if command failed
|
|
268
|
-
if (@debug || !success) && !@non_interactive_prompt
|
|
269
|
-
puts "Command #{index + 1} output:"
|
|
270
|
-
puts output
|
|
271
|
-
puts "Exit status: #{$CHILD_STATUS.exitstatus}"
|
|
272
|
-
end
|
|
315
|
+
def non_interactive_output
|
|
316
|
+
return if @non_interactive_prompt
|
|
273
317
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
end
|
|
278
|
-
rescue StandardError => e
|
|
279
|
-
puts "Error executing before command #{index + 1}: #{e.message}" unless @non_interactive_prompt
|
|
280
|
-
if @session_path
|
|
281
|
-
File.open(log_file, "a") do |f|
|
|
282
|
-
f.puts "Error: #{e.message}"
|
|
283
|
-
f.puts "-" * 80
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
return false
|
|
287
|
-
end
|
|
288
|
-
end
|
|
318
|
+
yield
|
|
319
|
+
puts
|
|
320
|
+
end
|
|
289
321
|
|
|
290
|
-
|
|
322
|
+
def execute_before_commands?(commands)
|
|
323
|
+
execute_commands(commands, phase: "before", fail_fast: true)
|
|
291
324
|
end
|
|
292
325
|
|
|
293
326
|
def execute_after_commands?(commands)
|
|
294
|
-
|
|
295
|
-
all_succeeded = true
|
|
296
|
-
|
|
297
|
-
commands.each_with_index do |command, index|
|
|
298
|
-
# Log the command execution to session log
|
|
299
|
-
if @session_path
|
|
300
|
-
File.open(log_file, "a") do |f|
|
|
301
|
-
f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing after command #{index + 1}/#{commands.size}: #{command}"
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
# Execute the command and capture output
|
|
306
|
-
begin
|
|
307
|
-
puts "Debug: Executing after command #{index + 1}/#{commands.size}: #{command}" if @debug && !@non_interactive_prompt
|
|
308
|
-
|
|
309
|
-
# Use system with output capture
|
|
310
|
-
output = %x(#{command} 2>&1)
|
|
311
|
-
success = $CHILD_STATUS.success?
|
|
312
|
-
|
|
313
|
-
# Log the output
|
|
314
|
-
if @session_path
|
|
315
|
-
File.open(log_file, "a") do |f|
|
|
316
|
-
f.puts "Command output:"
|
|
317
|
-
f.puts output
|
|
318
|
-
f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
|
|
319
|
-
f.puts "-" * 80
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
# Show output if in debug mode or if command failed
|
|
324
|
-
if (@debug || !success) && !@non_interactive_prompt
|
|
325
|
-
puts "After command #{index + 1} output:"
|
|
326
|
-
puts output
|
|
327
|
-
puts "Exit status: #{$CHILD_STATUS.exitstatus}"
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
unless success
|
|
331
|
-
puts "ā After command #{index + 1} failed: #{command}" unless @non_interactive_prompt
|
|
332
|
-
all_succeeded = false
|
|
333
|
-
end
|
|
334
|
-
rescue StandardError => e
|
|
335
|
-
puts "Error executing after command #{index + 1}: #{e.message}" unless @non_interactive_prompt
|
|
336
|
-
if @session_path
|
|
337
|
-
File.open(log_file, "a") do |f|
|
|
338
|
-
f.puts "Error: #{e.message}"
|
|
339
|
-
f.puts "-" * 80
|
|
340
|
-
end
|
|
341
|
-
end
|
|
342
|
-
all_succeeded = false
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
all_succeeded
|
|
327
|
+
execute_commands(commands, phase: "after", fail_fast: false)
|
|
347
328
|
end
|
|
348
329
|
|
|
349
330
|
def save_swarm_config_path(session_path)
|
|
@@ -356,58 +337,33 @@ module ClaudeSwarm
|
|
|
356
337
|
File.write(root_dir_file, ClaudeSwarm.root_dir)
|
|
357
338
|
|
|
358
339
|
# Save session metadata
|
|
359
|
-
|
|
340
|
+
metadata_file = File.join(session_path, "session_metadata.json")
|
|
341
|
+
JsonHandler.write_file!(metadata_file, build_session_metadata)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def build_session_metadata
|
|
345
|
+
{
|
|
360
346
|
"root_directory" => ClaudeSwarm.root_dir,
|
|
361
347
|
"timestamp" => Time.now.utc.iso8601,
|
|
362
348
|
"start_time" => @start_time.utc.iso8601,
|
|
363
349
|
"swarm_name" => @config.swarm_name,
|
|
364
350
|
"claude_swarm_version" => VERSION,
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
|
|
369
|
-
|
|
370
|
-
metadata_file = File.join(session_path, "session_metadata.json")
|
|
371
|
-
File.write(metadata_file, JSON.pretty_generate(metadata))
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
def setup_signal_handlers
|
|
375
|
-
["INT", "TERM", "QUIT"].each do |signal|
|
|
376
|
-
Signal.trap(signal) do
|
|
377
|
-
puts "\nš Received #{signal} signal, cleaning up..."
|
|
378
|
-
display_summary
|
|
379
|
-
|
|
380
|
-
# Execute after commands if configured
|
|
381
|
-
main_instance = @config.main_instance_config
|
|
382
|
-
after_commands = @config.after_commands
|
|
383
|
-
if after_commands.any? && !@restore_session_path && !@non_interactive_prompt
|
|
384
|
-
Dir.chdir(main_instance[:directory]) do
|
|
385
|
-
puts
|
|
386
|
-
puts "āļø Executing after commands..."
|
|
387
|
-
puts
|
|
388
|
-
execute_after_commands?(after_commands)
|
|
389
|
-
end
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
cleanup_processes
|
|
393
|
-
cleanup_run_symlink
|
|
394
|
-
cleanup_worktrees
|
|
395
|
-
exit
|
|
396
|
-
end
|
|
351
|
+
}.tap do |metadata|
|
|
352
|
+
# Add worktree info if applicable
|
|
353
|
+
metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
|
|
397
354
|
end
|
|
398
355
|
end
|
|
399
356
|
|
|
400
357
|
def cleanup_processes
|
|
401
358
|
@process_tracker.cleanup_all
|
|
359
|
+
cleanup_transcript_thread
|
|
402
360
|
puts "ā Cleanup complete"
|
|
403
361
|
rescue StandardError => e
|
|
404
362
|
puts "ā ļø Error during cleanup: #{e.message}"
|
|
405
363
|
end
|
|
406
364
|
|
|
407
365
|
def cleanup_worktrees
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
@worktree_manager.cleanup_worktrees
|
|
366
|
+
@worktree_manager&.cleanup_worktrees
|
|
411
367
|
rescue StandardError => e
|
|
412
368
|
puts "ā ļø Error during worktree cleanup: #{e.message}"
|
|
413
369
|
end
|
|
@@ -438,13 +394,13 @@ module ClaudeSwarm
|
|
|
438
394
|
metadata_file = File.join(@session_path, "session_metadata.json")
|
|
439
395
|
return unless File.exist?(metadata_file)
|
|
440
396
|
|
|
441
|
-
metadata =
|
|
397
|
+
metadata = JsonHandler.parse_file!(metadata_file)
|
|
442
398
|
metadata["end_time"] = end_time.utc.iso8601
|
|
443
399
|
metadata["duration_seconds"] = (end_time - @start_time).to_i
|
|
444
400
|
|
|
445
|
-
|
|
401
|
+
JsonHandler.write_file!(metadata_file, metadata)
|
|
446
402
|
rescue StandardError => e
|
|
447
|
-
|
|
403
|
+
non_interactive_output { print("ā ļø Error updating session metadata: #{e.message}") }
|
|
448
404
|
end
|
|
449
405
|
|
|
450
406
|
def calculate_total_cost
|
|
@@ -480,11 +436,12 @@ module ClaudeSwarm
|
|
|
480
436
|
def create_run_symlink
|
|
481
437
|
return unless @session_path
|
|
482
438
|
|
|
483
|
-
|
|
439
|
+
run_dir = ClaudeSwarm.joined_run_dir
|
|
440
|
+
FileUtils.mkdir_p(run_dir)
|
|
484
441
|
|
|
485
442
|
# Session ID is the last part of the session path
|
|
486
443
|
session_id = File.basename(@session_path)
|
|
487
|
-
symlink_path =
|
|
444
|
+
symlink_path = ClaudeSwarm.joined_run_dir(session_id)
|
|
488
445
|
|
|
489
446
|
# Remove stale symlink if exists
|
|
490
447
|
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
|
@@ -493,14 +450,14 @@ module ClaudeSwarm
|
|
|
493
450
|
File.symlink(@session_path, symlink_path)
|
|
494
451
|
rescue StandardError => e
|
|
495
452
|
# Don't fail the process if symlink creation fails
|
|
496
|
-
|
|
453
|
+
non_interactive_output { print("ā ļø Warning: Could not create run symlink: #{e.message}") }
|
|
497
454
|
end
|
|
498
455
|
|
|
499
456
|
def cleanup_run_symlink
|
|
500
457
|
return unless @session_path
|
|
501
458
|
|
|
502
459
|
session_id = File.basename(@session_path)
|
|
503
|
-
symlink_path =
|
|
460
|
+
symlink_path = ClaudeSwarm.joined_run_dir(session_id)
|
|
504
461
|
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
|
505
462
|
rescue StandardError
|
|
506
463
|
# Ignore errors during cleanup
|
|
@@ -508,15 +465,11 @@ module ClaudeSwarm
|
|
|
508
465
|
|
|
509
466
|
def start_log_streaming
|
|
510
467
|
Thread.new do
|
|
511
|
-
session_log_path = File.join(ENV.fetch("CLAUDE_SWARM_SESSION_PATH", nil), "session.log")
|
|
512
|
-
|
|
513
468
|
# Wait for log file to be created
|
|
514
|
-
sleep(0.1) until File.exist?(session_log_path)
|
|
469
|
+
sleep(0.1) until File.exist?(@session_log_path)
|
|
515
470
|
|
|
516
471
|
# Open file and seek to end
|
|
517
|
-
File.open(session_log_path, "r") do |file|
|
|
518
|
-
file.seek(0, IO::SEEK_END)
|
|
519
|
-
|
|
472
|
+
File.open(@session_log_path, "r") do |file|
|
|
520
473
|
loop do
|
|
521
474
|
changes = file.read
|
|
522
475
|
if changes
|
|
@@ -543,11 +496,13 @@ module ClaudeSwarm
|
|
|
543
496
|
end
|
|
544
497
|
|
|
545
498
|
def build_main_command(instance)
|
|
546
|
-
parts = [
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
499
|
+
parts = ["claude"]
|
|
500
|
+
|
|
501
|
+
# Only add --model if ANTHROPIC_MODEL env var is not set
|
|
502
|
+
unless ENV["ANTHROPIC_MODEL"]
|
|
503
|
+
parts << "--model"
|
|
504
|
+
parts << instance[:model]
|
|
505
|
+
end
|
|
551
506
|
|
|
552
507
|
# Add resume flag if restoring session
|
|
553
508
|
if @restore_session_path
|
|
@@ -557,7 +512,7 @@ module ClaudeSwarm
|
|
|
557
512
|
|
|
558
513
|
# Find the state file for the main instance
|
|
559
514
|
state_files.each do |state_file|
|
|
560
|
-
state_data =
|
|
515
|
+
state_data = JsonHandler.parse_file!(state_file)
|
|
561
516
|
next unless state_data["instance_name"] == main_instance_name
|
|
562
517
|
|
|
563
518
|
claude_session_id = state_data["claude_session_id"]
|
|
@@ -615,11 +570,20 @@ module ClaudeSwarm
|
|
|
615
570
|
parts << "--mcp-config"
|
|
616
571
|
parts << mcp_config_path
|
|
617
572
|
|
|
573
|
+
# Add settings file if it exists for the main instance
|
|
574
|
+
settings_file = @settings_generator.settings_path(@config.main_instance)
|
|
575
|
+
if File.exist?(settings_file)
|
|
576
|
+
parts << "--settings"
|
|
577
|
+
parts << settings_file
|
|
578
|
+
end
|
|
579
|
+
|
|
618
580
|
# Handle different modes
|
|
619
581
|
if @non_interactive_prompt
|
|
620
582
|
# Non-interactive mode with -p
|
|
621
583
|
parts << "-p"
|
|
622
584
|
parts << @non_interactive_prompt
|
|
585
|
+
parts << "--verbose"
|
|
586
|
+
parts << "--output-format=stream-json"
|
|
623
587
|
elsif @interactive_prompt
|
|
624
588
|
# Interactive mode with initial prompt (no -p flag)
|
|
625
589
|
parts << @interactive_prompt
|
|
@@ -633,13 +597,12 @@ module ClaudeSwarm
|
|
|
633
597
|
metadata_file = File.join(session_path, "session_metadata.json")
|
|
634
598
|
return unless File.exist?(metadata_file)
|
|
635
599
|
|
|
636
|
-
metadata =
|
|
600
|
+
metadata = JsonHandler.parse_file!(metadata_file)
|
|
637
601
|
worktree_data = metadata["worktree"]
|
|
638
602
|
return unless worktree_data && worktree_data["enabled"]
|
|
639
603
|
|
|
640
|
-
|
|
604
|
+
non_interactive_output do
|
|
641
605
|
puts "š³ Restoring Git worktrees..."
|
|
642
|
-
puts
|
|
643
606
|
end
|
|
644
607
|
|
|
645
608
|
# Restore worktrees using the saved configuration
|
|
@@ -651,10 +614,242 @@ module ClaudeSwarm
|
|
|
651
614
|
all_instances = @config.instances.values
|
|
652
615
|
@worktree_manager.setup_worktrees(all_instances)
|
|
653
616
|
|
|
654
|
-
|
|
617
|
+
non_interactive_output do
|
|
618
|
+
puts "ā Worktrees restored with branch: #{@worktree_manager.worktree_name}"
|
|
619
|
+
end
|
|
620
|
+
end
|
|
655
621
|
|
|
656
|
-
|
|
657
|
-
|
|
622
|
+
def stream_to_session_log(*command)
|
|
623
|
+
# Setup logger for session logging
|
|
624
|
+
logger = Logger.new(@session_log_path, level: :info, progname: @config.main_instance)
|
|
625
|
+
|
|
626
|
+
# Use Open3.popen2e to capture stdout and stderr merged for formatting
|
|
627
|
+
Open3.popen2e(*command) do |stdin, stdout_and_stderr, wait_thr|
|
|
628
|
+
stdin.close
|
|
629
|
+
|
|
630
|
+
# Read and process the merged output
|
|
631
|
+
stdout_and_stderr.each_line do |line|
|
|
632
|
+
logger.info do
|
|
633
|
+
chomped_line = line.chomp
|
|
634
|
+
json_data = JsonHandler.parse(chomped_line)
|
|
635
|
+
json_data == chomped_line ? chomped_line : JsonHandler.pretty_generate!(json_data)
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
wait_thr.value
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def start_transcript_tailing
|
|
644
|
+
Thread.new do
|
|
645
|
+
path_file = File.join(@session_path, "main_instance_transcript.path")
|
|
646
|
+
|
|
647
|
+
# Wait for path file to exist (created by SessionStart hook)
|
|
648
|
+
sleep(0.5) until File.exist?(path_file)
|
|
649
|
+
|
|
650
|
+
# Read the transcript path
|
|
651
|
+
transcript_path = File.read(path_file).strip
|
|
652
|
+
|
|
653
|
+
# Wait for transcript file to exist
|
|
654
|
+
sleep(0.5) until File.exist?(transcript_path)
|
|
655
|
+
|
|
656
|
+
# Tail the transcript file continuously (like tail -f)
|
|
657
|
+
File.open(transcript_path, "r") do |file|
|
|
658
|
+
# Start from the beginning to capture all entries
|
|
659
|
+
file.seek(0, IO::SEEK_SET) # Start at beginning of file
|
|
660
|
+
|
|
661
|
+
loop do
|
|
662
|
+
line = file.gets
|
|
663
|
+
if line
|
|
664
|
+
begin
|
|
665
|
+
# Parse JSONL entry, silently skip unparseable lines
|
|
666
|
+
transcript_entry = JsonHandler.parse(line)
|
|
667
|
+
|
|
668
|
+
# Skip if parsing failed or if it's a summary entry
|
|
669
|
+
next if transcript_entry == line || transcript_entry["type"] == "summary"
|
|
670
|
+
|
|
671
|
+
# Convert to session.log.json format
|
|
672
|
+
session_entry = convert_transcript_to_session_format(transcript_entry)
|
|
673
|
+
|
|
674
|
+
# Only write if we got a valid conversion (skips summary and other non-relevant entries)
|
|
675
|
+
if session_entry
|
|
676
|
+
# Write with file locking (same pattern as BaseExecutor)
|
|
677
|
+
session_json_path = File.join(@session_path, "session.log.json")
|
|
678
|
+
File.open(session_json_path, File::WRONLY | File::APPEND | File::CREAT) do |log_file|
|
|
679
|
+
log_file.flock(File::LOCK_EX)
|
|
680
|
+
log_file.puts(session_entry.to_json)
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
rescue StandardError
|
|
684
|
+
# Silently handle other errors to keep thread running
|
|
685
|
+
end
|
|
686
|
+
else
|
|
687
|
+
# No new data, sleep briefly
|
|
688
|
+
sleep(0.1)
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
rescue StandardError
|
|
693
|
+
# Silently handle thread errors
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def convert_transcript_to_session_format(transcript_entry)
|
|
698
|
+
# Skip if no type
|
|
699
|
+
return unless transcript_entry["type"]
|
|
700
|
+
|
|
701
|
+
instance_name = @config.main_instance
|
|
702
|
+
instance_id = "main"
|
|
703
|
+
timestamp = transcript_entry["timestamp"] || Time.now.iso8601
|
|
704
|
+
|
|
705
|
+
case transcript_entry["type"]
|
|
706
|
+
when "user"
|
|
707
|
+
# User message - format as request from user to main instance
|
|
708
|
+
message = transcript_entry["message"]
|
|
709
|
+
|
|
710
|
+
# Extract prompt text - message might be a string or an object
|
|
711
|
+
prompt_text = if message.is_a?(String)
|
|
712
|
+
message
|
|
713
|
+
elsif message.is_a?(Hash)
|
|
714
|
+
content = message["content"]
|
|
715
|
+
if content.is_a?(String)
|
|
716
|
+
content
|
|
717
|
+
elsif content.is_a?(Array)
|
|
718
|
+
# For tool results or complex content, extract text
|
|
719
|
+
extract_text_from_array(content)
|
|
720
|
+
else
|
|
721
|
+
""
|
|
722
|
+
end
|
|
723
|
+
else
|
|
724
|
+
""
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
{
|
|
728
|
+
instance: instance_name,
|
|
729
|
+
instance_id: instance_id,
|
|
730
|
+
timestamp: timestamp,
|
|
731
|
+
event: {
|
|
732
|
+
type: "request",
|
|
733
|
+
from_instance: "user",
|
|
734
|
+
from_instance_id: "user",
|
|
735
|
+
to_instance: instance_name,
|
|
736
|
+
to_instance_id: instance_id,
|
|
737
|
+
prompt: prompt_text,
|
|
738
|
+
timestamp: timestamp,
|
|
739
|
+
},
|
|
740
|
+
}
|
|
741
|
+
when "assistant"
|
|
742
|
+
# Assistant message - format as assistant response
|
|
743
|
+
message = transcript_entry["message"]
|
|
744
|
+
|
|
745
|
+
# Build a clean message structure without transcript-specific fields
|
|
746
|
+
clean_message = {
|
|
747
|
+
"type" => "message",
|
|
748
|
+
"role" => "assistant",
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
# Handle different message formats
|
|
752
|
+
if message.is_a?(String)
|
|
753
|
+
# Simple string message
|
|
754
|
+
clean_message["content"] = [{ "type" => "text", "text" => message }]
|
|
755
|
+
elsif message.is_a?(Hash)
|
|
756
|
+
# Only include the fields that other instances include
|
|
757
|
+
clean_message["content"] = message["content"] if message["content"]
|
|
758
|
+
clean_message["model"] = message["model"] if message["model"]
|
|
759
|
+
clean_message["usage"] = message["usage"] if message["usage"]
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
{
|
|
763
|
+
instance: instance_name,
|
|
764
|
+
instance_id: instance_id,
|
|
765
|
+
timestamp: timestamp,
|
|
766
|
+
event: {
|
|
767
|
+
type: "assistant",
|
|
768
|
+
message: clean_message,
|
|
769
|
+
session_id: transcript_entry["sessionId"],
|
|
770
|
+
},
|
|
771
|
+
}
|
|
772
|
+
end
|
|
773
|
+
# For other types (like summary), return nil to skip them
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
def extract_text_from_array(content)
|
|
777
|
+
content.map do |item|
|
|
778
|
+
if item.is_a?(Hash)
|
|
779
|
+
item["text"] || item["content"] || ""
|
|
780
|
+
else
|
|
781
|
+
item.to_s
|
|
782
|
+
end
|
|
783
|
+
end.join("\n")
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def cleanup_transcript_thread
|
|
787
|
+
return unless @transcript_thread
|
|
788
|
+
|
|
789
|
+
@transcript_thread.terminate if @transcript_thread.alive?
|
|
790
|
+
@transcript_thread.join(1) # Wait up to 1 second for thread to finish
|
|
791
|
+
rescue StandardError => e
|
|
792
|
+
logger.error { "Error cleaning up transcript thread: #{e.message}" }
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def logger
|
|
796
|
+
@logger ||= Logger.new(File.join(@session_path, "session.log"), level: :info, progname: "orchestrator")
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
def execute_commands(commands, phase:, fail_fast:)
|
|
800
|
+
all_succeeded = true
|
|
801
|
+
|
|
802
|
+
# Setup logger for session logging if we have a session path
|
|
803
|
+
logger = Logger.new(@session_log_path, level: :info)
|
|
804
|
+
|
|
805
|
+
commands.each_with_index do |command, index|
|
|
806
|
+
# Log the command execution to session log
|
|
807
|
+
logger.info { "Executing #{phase} command #{index + 1}/#{commands.size}: #{command}" }
|
|
808
|
+
|
|
809
|
+
# Execute the command and capture output
|
|
810
|
+
begin
|
|
811
|
+
if @debug
|
|
812
|
+
non_interactive_output do
|
|
813
|
+
debug_prefix = phase == "after" ? "after " : ""
|
|
814
|
+
print("Debug: Executing #{debug_prefix} command #{index + 1}/#{commands.size}: #{format_command_for_display(command)}")
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
output = %x(#{command} 2>&1)
|
|
819
|
+
success = $CHILD_STATUS.success?
|
|
820
|
+
output_separator = "-" * 80
|
|
821
|
+
|
|
822
|
+
logger.info { "Command output:" }
|
|
823
|
+
logger.info { output }
|
|
824
|
+
logger.info { "Exit status: #{$CHILD_STATUS.exitstatus}" }
|
|
825
|
+
logger.info { output_separator }
|
|
826
|
+
|
|
827
|
+
# Show output if in debug mode or if command failed
|
|
828
|
+
if @debug || !success
|
|
829
|
+
non_interactive_output do
|
|
830
|
+
output_prefix = phase == "after" ? "After command" : "Command"
|
|
831
|
+
puts "#{output_prefix} #{index + 1} output:"
|
|
832
|
+
puts output
|
|
833
|
+
print("Exit status: #{$CHILD_STATUS.exitstatus}")
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
unless success
|
|
838
|
+
error_prefix = phase.capitalize
|
|
839
|
+
non_interactive_output { print("ā #{error_prefix} command #{index + 1} failed: #{command}") }
|
|
840
|
+
all_succeeded = false
|
|
841
|
+
return false if fail_fast
|
|
842
|
+
end
|
|
843
|
+
rescue StandardError => e
|
|
844
|
+
non_interactive_output { print("Error executing #{phase} command #{index + 1}: #{e.message}") }
|
|
845
|
+
logger.info { "Error: #{e.message}" }
|
|
846
|
+
logger.info { output_separator }
|
|
847
|
+
all_succeeded = false
|
|
848
|
+
return false if fail_fast
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
all_succeeded
|
|
658
853
|
end
|
|
659
854
|
end
|
|
660
855
|
end
|