soba-cli 0.1.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 (101) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/osoba/add-backlog.md +173 -0
  3. data/.claude/commands/osoba/implement.md +151 -0
  4. data/.claude/commands/osoba/plan.md +217 -0
  5. data/.claude/commands/osoba/review.md +133 -0
  6. data/.claude/commands/osoba/revise.md +176 -0
  7. data/.claude/commands/soba/implement.md +88 -0
  8. data/.claude/commands/soba/plan.md +93 -0
  9. data/.claude/commands/soba/review.md +91 -0
  10. data/.claude/commands/soba/revise.md +76 -0
  11. data/.devcontainer/.env +2 -0
  12. data/.devcontainer/Dockerfile +3 -0
  13. data/.devcontainer/LICENSE +21 -0
  14. data/.devcontainer/README.md +85 -0
  15. data/.devcontainer/bin/devcontainer-common.sh +50 -0
  16. data/.devcontainer/bin/down +35 -0
  17. data/.devcontainer/bin/rebuild +10 -0
  18. data/.devcontainer/bin/up +11 -0
  19. data/.devcontainer/compose.yaml +28 -0
  20. data/.devcontainer/devcontainer.json +53 -0
  21. data/.devcontainer/post-attach.sh +29 -0
  22. data/.devcontainer/post-create.sh +62 -0
  23. data/.devcontainer/setup/01-os-package.sh +19 -0
  24. data/.devcontainer/setup/02-npm-package.sh +22 -0
  25. data/.devcontainer/setup/03-mcp-server.sh +33 -0
  26. data/.devcontainer/setup/04-tool.sh +17 -0
  27. data/.devcontainer/setup/05-soba-setup.sh +66 -0
  28. data/.devcontainer/setup/scripts/functions/install_apt.sh +77 -0
  29. data/.devcontainer/setup/scripts/functions/install_npm.sh +71 -0
  30. data/.devcontainer/setup/scripts/functions/mcp_config.sh +14 -0
  31. data/.devcontainer/setup/scripts/functions/print_message.sh +59 -0
  32. data/.devcontainer/setup/scripts/setup/mcp-markdownify.sh +39 -0
  33. data/.devcontainer/sync-envs.sh +58 -0
  34. data/.envrc.sample +7 -0
  35. data/.rspec +4 -0
  36. data/.rubocop.yml +70 -0
  37. data/.rubocop_airbnb.yml +2 -0
  38. data/.rubocop_todo.yml +74 -0
  39. data/.tool-versions +1 -0
  40. data/CLAUDE.md +20 -0
  41. data/LICENSE +21 -0
  42. data/README.md +384 -0
  43. data/README_ja.md +384 -0
  44. data/Rakefile +18 -0
  45. data/bin/soba +120 -0
  46. data/config/config.yml.example +36 -0
  47. data/docs/business/INDEX.md +6 -0
  48. data/docs/business/overview.md +42 -0
  49. data/docs/business/workflow.md +143 -0
  50. data/docs/development/INDEX.md +10 -0
  51. data/docs/development/architecture.md +69 -0
  52. data/docs/development/coding-standards.md +152 -0
  53. data/docs/development/distribution.md +26 -0
  54. data/docs/development/implementation-guide.md +103 -0
  55. data/docs/development/testing-strategy.md +128 -0
  56. data/docs/development/tmux-management.md +253 -0
  57. data/docs/document_system.md +58 -0
  58. data/lib/soba/commands/config/show.rb +63 -0
  59. data/lib/soba/commands/init.rb +778 -0
  60. data/lib/soba/commands/open.rb +144 -0
  61. data/lib/soba/commands/start.rb +442 -0
  62. data/lib/soba/commands/status.rb +175 -0
  63. data/lib/soba/commands/stop.rb +147 -0
  64. data/lib/soba/config_loader.rb +32 -0
  65. data/lib/soba/configuration.rb +268 -0
  66. data/lib/soba/container.rb +48 -0
  67. data/lib/soba/domain/issue.rb +38 -0
  68. data/lib/soba/domain/phase_strategy.rb +74 -0
  69. data/lib/soba/infrastructure/errors.rb +23 -0
  70. data/lib/soba/infrastructure/github_client.rb +399 -0
  71. data/lib/soba/infrastructure/lock_manager.rb +129 -0
  72. data/lib/soba/infrastructure/tmux_client.rb +331 -0
  73. data/lib/soba/services/ansi_processor.rb +92 -0
  74. data/lib/soba/services/auto_merge_service.rb +133 -0
  75. data/lib/soba/services/closed_issue_window_cleaner.rb +96 -0
  76. data/lib/soba/services/daemon_service.rb +83 -0
  77. data/lib/soba/services/git_workspace_manager.rb +102 -0
  78. data/lib/soba/services/issue_monitor.rb +29 -0
  79. data/lib/soba/services/issue_processor.rb +215 -0
  80. data/lib/soba/services/issue_watcher.rb +193 -0
  81. data/lib/soba/services/pid_manager.rb +87 -0
  82. data/lib/soba/services/process_info.rb +58 -0
  83. data/lib/soba/services/queueing_service.rb +98 -0
  84. data/lib/soba/services/session_logger.rb +111 -0
  85. data/lib/soba/services/session_resolver.rb +72 -0
  86. data/lib/soba/services/slack_notifier.rb +121 -0
  87. data/lib/soba/services/status_manager.rb +74 -0
  88. data/lib/soba/services/test_process_manager.rb +84 -0
  89. data/lib/soba/services/tmux_session_manager.rb +251 -0
  90. data/lib/soba/services/workflow_blocking_checker.rb +73 -0
  91. data/lib/soba/services/workflow_executor.rb +256 -0
  92. data/lib/soba/services/workflow_integrity_checker.rb +151 -0
  93. data/lib/soba/templates/claude_commands/implement.md +88 -0
  94. data/lib/soba/templates/claude_commands/plan.md +93 -0
  95. data/lib/soba/templates/claude_commands/review.md +91 -0
  96. data/lib/soba/templates/claude_commands/revise.md +76 -0
  97. data/lib/soba/version.rb +5 -0
  98. data/lib/soba.rb +44 -0
  99. data/lib/tasks/gem.rake +75 -0
  100. data/soba-cli.gemspec +59 -0
  101. metadata +430 -0
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ module Soba
5
+ module Services
6
+ class ProcessInfo
7
+ attr_reader :pid
8
+
9
+ def initialize(pid)
10
+ @pid = pid.to_i
11
+ end
12
+
13
+ def memory_usage_mb
14
+ return nil unless exists?
15
+
16
+ memory_kb = if File.exist?("/proc/#{pid}/status")
17
+ # Linux: Read from /proc filesystem
18
+ memory_from_proc
19
+ else
20
+ # macOS/other: Use ps command
21
+ memory_from_ps
22
+ end
23
+
24
+ memory_kb ? (memory_kb / 1024.0).round(2) : nil
25
+ rescue StandardError
26
+ nil
27
+ end
28
+
29
+ def exists?
30
+ return false if pid <= 0
31
+
32
+ Process.kill(0, pid)
33
+ true
34
+ rescue Errno::ESRCH, Errno::EPERM
35
+ false
36
+ end
37
+
38
+ private
39
+
40
+ def memory_from_proc
41
+ content = File.read("/proc/#{pid}/status")
42
+ # Look for VmRSS (Resident Set Size) in kilobytes
43
+ if content =~ /VmRSS:\s+(\d+)\s+kB/
44
+ Regexp.last_match(1).to_i
45
+ end
46
+ end
47
+
48
+ def memory_from_ps
49
+ # ps -o rss= returns memory in kilobytes
50
+ output = `ps -o rss= -p #{pid} 2>/dev/null`.strip
51
+
52
+ if $CHILD_STATUS.success? && !output.empty?
53
+ output.to_i
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soba
4
+ module Services
5
+ class QueueingService
6
+ TODO_LABEL = "soba:todo"
7
+ QUEUED_LABEL = "soba:queued"
8
+
9
+ attr_reader :github_client, :blocking_checker, :logger
10
+
11
+ def initialize(github_client:, blocking_checker:, logger: nil)
12
+ @github_client = github_client
13
+ @blocking_checker = blocking_checker
14
+ @logger = logger || SemanticLogger["QueueingService"]
15
+ end
16
+
17
+ def queue_next_issue(repository)
18
+ logger.info("Starting queueing process: #{repository}")
19
+
20
+ if has_active_issue?(repository)
21
+ issues = github_client.issues(repository, state: "open")
22
+ reason = blocking_checker.blocking_reason(repository, issues: issues)
23
+ logger.info("Skipping queueing process: #{reason}")
24
+ return nil
25
+ end
26
+
27
+ candidate = find_next_candidate_from_repository(repository)
28
+ if candidate.nil?
29
+ logger.info("No issues found for queueing")
30
+ return nil
31
+ end
32
+
33
+ result = transition_to_queued(candidate, repository)
34
+ return nil if result.nil? # 競合状態検出時
35
+
36
+ candidate
37
+ rescue => e
38
+ logger.error("Error during queueing process: #{e.message} (repository: #{repository})")
39
+ raise
40
+ end
41
+
42
+ private
43
+
44
+ def has_active_issue?(repository)
45
+ issues = github_client.issues(repository, state: "open")
46
+ blocking_checker.blocking?(repository, issues: issues)
47
+ end
48
+
49
+ def find_next_candidate_from_repository(repository)
50
+ issues = github_client.issues(repository, state: "open")
51
+ find_next_candidate(issues)
52
+ end
53
+
54
+ def find_next_candidate(issues)
55
+ # アクティブまたは中間状態のsobaラベルを持つIssueが存在する場合はnilを返す
56
+ active_or_intermediate_issues = issues.select do |issue|
57
+ issue.labels.any? do |label|
58
+ label_name = label[:name]
59
+ WorkflowBlockingChecker::ACTIVE_LABELS.include?(label_name) ||
60
+ WorkflowBlockingChecker::INTERMEDIATE_LABELS.include?(label_name)
61
+ end
62
+ end
63
+
64
+ return nil unless active_or_intermediate_issues.empty?
65
+
66
+ todo_issues = issues.select do |issue|
67
+ issue.labels.any? { |label| label[:name] == TODO_LABEL }
68
+ end
69
+
70
+ return nil if todo_issues.empty?
71
+
72
+ # Issue番号の昇順でソートして最初の1件を返す
73
+ todo_issues.min_by(&:number)
74
+ end
75
+
76
+ def transition_to_queued(issue, repository)
77
+ # ラベル更新直前に再度排他制御チェック(競合状態の検出)
78
+ current_issues = github_client.issues(repository, state: "open")
79
+ if blocking_checker.blocking?(repository, issues: current_issues)
80
+ reason = blocking_checker.blocking_reason(repository, issues: current_issues)
81
+ logger.warn("Race condition detected: #{reason}")
82
+ logger.warn("Skipping queueing for Issue ##{issue.number}")
83
+ return nil
84
+ end
85
+
86
+ logger.debug("Updating labels for Issue ##{issue.number}: #{TODO_LABEL} -> #{QUEUED_LABEL}")
87
+
88
+ github_client.update_issue_labels(repository, issue.number, from: TODO_LABEL, to: QUEUED_LABEL)
89
+
90
+ logger.info("Transitioned Issue ##{issue.number} to soba:queued: #{issue.title}")
91
+ true # 成功を示すために true を返す
92
+ rescue => e
93
+ logger.error("Failed to update labels for Issue ##{issue.number}: #{e.message}")
94
+ raise
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module Soba
7
+ module Services
8
+ # セッション出力のログ管理サービス
9
+ class SessionLogger
10
+ DEFAULT_LOG_DIR = File.join(Dir.home, ".soba", "logs")
11
+ DEFAULT_MAX_SIZE = 10 * 1024 * 1024 # 10MB
12
+
13
+ attr_reader :log_dir
14
+
15
+ def initialize(log_dir: nil, max_size: DEFAULT_MAX_SIZE)
16
+ @log_dir = log_dir || DEFAULT_LOG_DIR
17
+ @max_size = max_size
18
+ @file_handles = {}
19
+
20
+ ensure_log_directory
21
+ end
22
+
23
+ # ログファイルに書き込み
24
+ def write(session_name, content)
25
+ ensure_file_handle(session_name)
26
+
27
+ timestamp = Time.now.strftime("[%Y-%m-%d %H:%M:%S]")
28
+ @file_handles[session_name].write("#{timestamp} #{content}")
29
+ @file_handles[session_name].flush
30
+
31
+ # ローテーションチェック
32
+ check_rotation(session_name)
33
+ end
34
+
35
+ # セッションのログファイルパスを取得
36
+ def find_log(session_name)
37
+ log_file = File.join(@log_dir, "#{session_name}.log")
38
+ File.exist?(log_file) ? log_file : nil
39
+ end
40
+
41
+ # ログが存在するセッション一覧
42
+ def list_sessions
43
+ Dir.glob(File.join(@log_dir, "soba-*.log")).
44
+ reject { |f| f.include?(".log.") }. # ローテートファイルを除外
45
+ map { |f| File.basename(f, ".log") }.
46
+ sort
47
+ end
48
+
49
+ # ファイルハンドルをクローズ
50
+ def close
51
+ @file_handles.each_value(&:close)
52
+ @file_handles.clear
53
+ end
54
+
55
+ # 古いログファイルのクリーンアップ
56
+ def cleanup_old_logs(days: 30)
57
+ cutoff_time = Time.now - (days * 24 * 60 * 60)
58
+
59
+ Dir.glob(File.join(@log_dir, "soba-*.log*")).each do |file|
60
+ if File.mtime(file) < cutoff_time
61
+ File.delete(file)
62
+ end
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ # ログディレクトリの確認と作成
69
+ def ensure_log_directory
70
+ FileUtils.mkdir_p(@log_dir) unless Dir.exist?(@log_dir)
71
+ end
72
+
73
+ # ファイルハンドルの取得または作成
74
+ def ensure_file_handle(session_name)
75
+ return if @file_handles[session_name]
76
+
77
+ log_file = File.join(@log_dir, "#{session_name}.log")
78
+ @file_handles[session_name] = File.open(log_file, "a")
79
+ end
80
+
81
+ # ログローテーションのチェック
82
+ def check_rotation(session_name)
83
+ log_file = File.join(@log_dir, "#{session_name}.log")
84
+ return unless File.size(log_file) > @max_size
85
+
86
+ rotate_log(session_name)
87
+ end
88
+
89
+ # ログファイルのローテート
90
+ def rotate_log(session_name)
91
+ log_file = File.join(@log_dir, "#{session_name}.log")
92
+
93
+ # ファイルハンドルを閉じる
94
+ @file_handles[session_name]&.close
95
+ @file_handles.delete(session_name)
96
+
97
+ # ローテート番号を決定
98
+ rotate_number = 1
99
+ while File.exist?("#{log_file}.#{rotate_number}")
100
+ rotate_number += 1
101
+ end
102
+
103
+ # ファイルを移動
104
+ File.rename(log_file, "#{log_file}.#{rotate_number}")
105
+
106
+ # 新しいファイルハンドルを開く
107
+ ensure_file_handle(session_name)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soba
4
+ module Services
5
+ class SessionResolver
6
+ def initialize(pid_manager: nil, tmux_manager: nil)
7
+ @pid_manager = pid_manager || PidManager.new
8
+ @tmux_manager = tmux_manager || TmuxSessionManager.new
9
+ end
10
+
11
+ def resolve_active_session(repository)
12
+ pid = @pid_manager.read
13
+ return nil unless pid
14
+
15
+ session_name = generate_session_name(repository, pid)
16
+
17
+ if @tmux_manager.session_exists?(session_name)
18
+ session_name
19
+ else
20
+ @pid_manager.delete
21
+ nil
22
+ end
23
+ rescue Errno::ENOENT
24
+ nil
25
+ end
26
+
27
+ def find_all_repository_sessions(repository)
28
+ pid = @pid_manager.read
29
+ return [] unless pid
30
+
31
+ session_name = generate_session_name(repository, pid)
32
+ active = @tmux_manager.session_exists?(session_name)
33
+
34
+ unless active
35
+ @pid_manager.delete
36
+ end
37
+
38
+ [
39
+ {
40
+ name: session_name,
41
+ pid: pid,
42
+ active: active,
43
+ },
44
+ ]
45
+ end
46
+
47
+ def generate_session_name(repository, pid)
48
+ raise ArgumentError, "PID cannot be nil" if pid.nil?
49
+
50
+ sanitized_repo = repository.to_s.gsub(/[^a-zA-Z0-9-]/, "-")
51
+ "soba-#{sanitized_repo}-#{pid}"
52
+ end
53
+
54
+ def cleanup_stale_sessions(repository)
55
+ pid = @pid_manager.read
56
+ return [] unless pid
57
+
58
+ session_name = generate_session_name(repository, pid)
59
+ if @tmux_manager.session_exists?(session_name)
60
+ []
61
+ else
62
+ @pid_manager.delete
63
+ [pid]
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :pid_manager, :tmux_manager
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Soba
7
+ module Services
8
+ class SlackNotifier
9
+ def initialize(webhook_url:)
10
+ @webhook_url = webhook_url
11
+ end
12
+
13
+ def notify_phase_start(issue_data)
14
+ return false unless enabled?
15
+
16
+ logger.debug "Starting Slack notification for issue ##{issue_data[:number]}, phase: #{issue_data[:phase]}"
17
+
18
+ begin
19
+ message = build_message(issue_data)
20
+ logger.debug "Sending notification to Slack webhook"
21
+
22
+ response = send_notification(message)
23
+
24
+ if response.success?
25
+ logger.debug "Slack notification sent successfully (HTTP #{response.status})"
26
+ true
27
+ else
28
+ logger.warn("Failed to send Slack notification: HTTP #{response.status}")
29
+ false
30
+ end
31
+ rescue StandardError => e
32
+ logger.warn("Error sending Slack notification: #{e.message}")
33
+ false
34
+ end
35
+ end
36
+
37
+ def enabled?
38
+ @webhook_url.present?
39
+ end
40
+
41
+ def self.from_env
42
+ new(webhook_url: ENV["SLACK_WEBHOOK_URL"])
43
+ end
44
+
45
+ def self.from_config
46
+ config = Soba::Configuration.config
47
+ return unless config.slack.notifications_enabled
48
+
49
+ webhook_url = config.slack.webhook_url
50
+ # 環境変数形式の場合は展開
51
+ if webhook_url&.match?(/\$\{([^}]+)\}/)
52
+ var_name = webhook_url.match(/\$\{([^}]+)\}/)[1]
53
+ webhook_url = ENV[var_name]
54
+ end
55
+
56
+ new(webhook_url: webhook_url)
57
+ end
58
+
59
+ private
60
+
61
+ def send_notification(message)
62
+ connection = Faraday.new do |conn|
63
+ conn.request :json
64
+ conn.response :json
65
+ conn.adapter Faraday.default_adapter
66
+ conn.options.timeout = 5
67
+ conn.options.open_timeout = 5
68
+ end
69
+
70
+ connection.post(@webhook_url, message.to_json)
71
+ end
72
+
73
+ def build_message(issue_data)
74
+ issue_url = if issue_data[:repository]
75
+ "https://github.com/#{issue_data[:repository]}/issues/#{issue_data[:number]}"
76
+ else
77
+ "##{issue_data[:number]}"
78
+ end
79
+
80
+ issue_value = if issue_data[:repository]
81
+ "<#{issue_url}|##{issue_data[:number]}>"
82
+ else
83
+ "##{issue_data[:number]}"
84
+ end
85
+
86
+ {
87
+ text: "🚀 Soba started #{issue_data[:phase]} phase: Issue ##{issue_data[:number]}",
88
+ attachments: [
89
+ {
90
+ color: "good",
91
+ title: issue_data[:title],
92
+ fields: [
93
+ {
94
+ title: "Issue",
95
+ value: issue_value,
96
+ short: true,
97
+ },
98
+ {
99
+ title: "Phase",
100
+ value: issue_data[:phase],
101
+ short: true,
102
+ },
103
+ ],
104
+ footer: "Soba CLI",
105
+ footer_icon: "https://github.com/favicon.ico",
106
+ ts: Time.now.to_i,
107
+ },
108
+ ],
109
+ }
110
+ end
111
+
112
+ def logger
113
+ @logger ||= if defined?(Soba.logger)
114
+ Soba.logger
115
+ else
116
+ SemanticLogger["SlackNotifier"]
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ module Soba
8
+ module Services
9
+ class StatusManager
10
+ attr_reader :status_file
11
+
12
+ def initialize(status_file)
13
+ @status_file = status_file
14
+ end
15
+
16
+ def write(data)
17
+ dir = File.dirname(status_file)
18
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
19
+
20
+ # Atomic write using temp file
21
+ temp_file = "#{status_file}.tmp"
22
+ File.write(temp_file, JSON.pretty_generate(data))
23
+ File.rename(temp_file, status_file)
24
+ rescue StandardError => e
25
+ # Clean up temp file if something goes wrong
26
+ FileUtils.rm_f(temp_file) if defined?(temp_file)
27
+ raise e
28
+ end
29
+
30
+ def read
31
+ return nil unless File.exist?(status_file)
32
+
33
+ content = File.read(status_file)
34
+ return nil if content.empty?
35
+
36
+ JSON.parse(content, symbolize_names: true)
37
+ rescue JSON::ParserError, StandardError
38
+ nil
39
+ end
40
+
41
+ def update_current_issue(issue_number, phase)
42
+ data = read || {}
43
+ data[:current_issue] = {
44
+ number: issue_number,
45
+ phase: phase,
46
+ started_at: Time.now.iso8601,
47
+ }
48
+ write(data)
49
+ end
50
+
51
+ def update_last_processed
52
+ data = read || {}
53
+ if data[:current_issue]
54
+ data[:last_processed] = {
55
+ number: data[:current_issue][:number],
56
+ completed_at: Time.now.iso8601,
57
+ }
58
+ data.delete(:current_issue)
59
+ end
60
+ write(data)
61
+ end
62
+
63
+ def update_memory(memory_mb)
64
+ data = read || {}
65
+ data[:memory_mb] = memory_mb
66
+ write(data)
67
+ end
68
+
69
+ def clear
70
+ FileUtils.rm_f(status_file)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'fileutils'
5
+ require_relative 'pid_manager'
6
+
7
+ module Soba
8
+ module Services
9
+ class TestProcessManager
10
+ TEST_PID_DIR = '/tmp/soba-test-pids'
11
+
12
+ def test_mode?
13
+ ENV['SOBA_TEST_MODE'] == 'true'
14
+ end
15
+
16
+ def generate_test_session_name(repository)
17
+ sanitized_repo = repository.gsub(/[\/._]/, '-')
18
+ "soba-test-#{sanitized_repo}-#{generate_test_id}"
19
+ end
20
+
21
+ def generate_test_id
22
+ "#{Process.pid}-#{SecureRandom.hex(4)}"
23
+ end
24
+
25
+ def test_pid_file_path(test_id)
26
+ "#{TEST_PID_DIR}/#{test_id}.pid"
27
+ end
28
+
29
+ def create_test_pid_manager(test_id)
30
+ pid_file = test_pid_file_path(test_id)
31
+ PidManager.new(pid_file)
32
+ end
33
+
34
+ def cleanup_test_processes(test_id, timeout: 10)
35
+ pid_manager = create_test_pid_manager(test_id)
36
+ cleaned_processes = []
37
+
38
+ pid = pid_manager.read
39
+ return { success: true, cleaned_processes: cleaned_processes } unless pid
40
+
41
+ if pid_manager.running?
42
+ begin
43
+ # Graceful termination
44
+ Process.kill('TERM', pid)
45
+
46
+ # Wait for graceful shutdown
47
+ wait_time = 0
48
+ while wait_time < timeout && pid_manager.running?
49
+ sleep(0.1)
50
+ wait_time += 0.1
51
+ end
52
+
53
+ # Force kill if still running
54
+ if pid_manager.running?
55
+ Process.kill('KILL', pid)
56
+ sleep(0.1) # Brief wait for force kill
57
+ end
58
+
59
+ cleaned_processes << pid
60
+ rescue Errno::ESRCH, Errno::EPERM
61
+ # Process already dead or no permission
62
+ end
63
+ end
64
+
65
+ # Clean up PID file
66
+ pid_manager.delete
67
+
68
+ { success: true, cleaned_processes: cleaned_processes }
69
+ rescue StandardError => e
70
+ { success: false, error: e.message, cleaned_processes: cleaned_processes }
71
+ end
72
+
73
+ def ensure_test_environment
74
+ if test_mode?
75
+ FileUtils.mkdir_p(TEST_PID_DIR)
76
+ end
77
+
78
+ { success: true, test_mode: test_mode? }
79
+ rescue StandardError => e
80
+ { success: false, error: e.message, test_mode: test_mode? }
81
+ end
82
+ end
83
+ end
84
+ end