woods 2.0.0.beta4 → 2.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +480 -477
  3. data/CONTRIBUTING.md +2 -2
  4. data/README.md +11 -26
  5. data/docs/AGENT_GUIDE.md +31 -12
  6. data/docs/AGENT_SETUP.md +17 -10
  7. data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
  8. data/docs/BACKEND_MATRIX.md +13 -7
  9. data/docs/CLIENT_HOOKS.md +1 -1
  10. data/docs/CONFIGURATION_REFERENCE.md +36 -26
  11. data/docs/CONSOLE_MCP_SETUP.md +10 -8
  12. data/docs/DOCKER_SETUP.md +15 -0
  13. data/docs/EVALUATION.md +10 -4
  14. data/docs/EXTRACTOR_REFERENCE.md +14 -2
  15. data/docs/FAQ.md +14 -3
  16. data/docs/GETTING_STARTED.md +18 -17
  17. data/docs/INCREMENTAL_EXTRACTION.md +8 -3
  18. data/docs/INDEX_LAYOUT.md +2 -2
  19. data/docs/MCP_SERVERS.md +28 -11
  20. data/docs/MCP_TOOL_COOKBOOK.md +1 -1
  21. data/docs/MCP_WORKTREE_SETUP.md +13 -1
  22. data/docs/PUBLISHED_INDEX.md +1 -1
  23. data/docs/README.md +2 -1
  24. data/docs/RETRIEVAL_GUIDE.md +57 -8
  25. data/docs/SOURCE_FRESHNESS.md +1 -1
  26. data/docs/TOKEN_BENCHMARK.md +16 -10
  27. data/docs/TROUBLESHOOTING.md +133 -37
  28. data/docs/UPGRADING_TO_2.md +9 -7
  29. data/docs/WATCH_DAEMON.md +172 -17
  30. data/docs/WHY_WOODS.md +9 -5
  31. data/exe/woods-console +13 -11
  32. data/exe/woods-watch +5 -0
  33. data/lib/generators/woods/watch_generator.rb +53 -0
  34. data/lib/puma/plugin/woods.rb +10 -0
  35. data/lib/tasks/woods.rake +14 -0
  36. data/lib/woods/cache/cache_middleware.rb +6 -0
  37. data/lib/woods/console/stdio_transport.rb +27 -0
  38. data/lib/woods/extractor.rb +25 -7
  39. data/lib/woods/git_command.rb +6 -7
  40. data/lib/woods/git_provenance.rb +4 -6
  41. data/lib/woods/mcp/bootstrapper.rb +3 -1
  42. data/lib/woods/mcp/initialization_guidance.rb +1 -1
  43. data/lib/woods/mcp/server.rb +41 -9
  44. data/lib/woods/retrieval/corpus_status.rb +46 -0
  45. data/lib/woods/retriever.rb +19 -7
  46. data/lib/woods/storage/local_corpus_stats.rb +32 -0
  47. data/lib/woods/storage/metadata_store.rb +20 -0
  48. data/lib/woods/storage/vector_store.rb +10 -0
  49. data/lib/woods/version.rb +1 -1
  50. data/lib/woods/watch/child_environment.rb +30 -0
  51. data/lib/woods/watch/cli.rb +91 -0
  52. data/lib/woods/watch/daemon.rb +55 -7
  53. data/lib/woods/watch/event_stream.rb +70 -0
  54. data/lib/woods/watch/guardian.rb +142 -0
  55. data/lib/woods/watch/installation/layout.rb +70 -0
  56. data/lib/woods/watch/installation/options.rb +128 -0
  57. data/lib/woods/watch/installation/planner.rb +128 -0
  58. data/lib/woods/watch/installation/probe.rb +101 -0
  59. data/lib/woods/watch/installation/receipt.rb +77 -0
  60. data/lib/woods/watch/installation/recovery.rb +64 -0
  61. data/lib/woods/watch/installation/templates.rb +58 -0
  62. data/lib/woods/watch/installation.rb +56 -0
  63. data/lib/woods/watch/lifecycle.rb +182 -0
  64. data/lib/woods/watch/managed_child.rb +113 -0
  65. data/lib/woods/watch/managed_cleanup.rb +48 -0
  66. data/lib/woods/watch/managed_process.rb +144 -0
  67. data/lib/woods/watch/puma_adapter.rb +87 -0
  68. data/lib/woods/watch/puma_child.rb +66 -0
  69. data/lib/woods/watch/supervision_records.rb +95 -0
  70. data/lib/woods/watch/supervision_status.rb +104 -0
  71. data/lib/woods/watch/supervisor.rb +161 -0
  72. data/lib/woods/watch/supervisor_reporting.rb +46 -0
  73. data/plugin/.claude-plugin/plugin.json +1 -1
  74. data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
  75. data/plugin/skills/woods-diagnose/SKILL.md +77 -8
  76. data/plugin/skills/woods-investigate/SKILL.md +6 -6
  77. data/plugin/skills/woods-mcp-config/SKILL.md +28 -1
  78. data/plugin/skills/woods-setup/SKILL.md +58 -4
  79. metadata +35 -5
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ class Installation
6
+ # Replays only validated local transaction state; never discovers or boots Rails.
7
+ class Recovery
8
+ # @param root [String] selected canonical application directory
9
+ def initialize(root:)
10
+ @root = root
11
+ end
12
+
13
+ # @param pretend [Boolean] check journal and current snapshots without writes
14
+ # @return [String] recovery status
15
+ def call(pretend: false)
16
+ journal = read_journal
17
+ return 'nothing_to_recover' unless journal
18
+
19
+ layout = recovery_layout(journal)
20
+ applier = Woods::AgentConfiguration::Applier.new(layout: layout)
21
+ return applier.recover unless pretend
22
+
23
+ verify_preview(applier, layout, journal)
24
+ 'recovery_preview_verified'
25
+ rescue KeyError, TypeError, ArgumentError => e
26
+ raise Conflict, "Invalid watcher recovery journal: #{e.message}"
27
+ end
28
+
29
+ private
30
+
31
+ def read_journal
32
+ layout = Layout.new(@root, 'Procfile.dev')
33
+ document = Woods::AgentConfiguration::Document.new("#{layout.receipt_path}.pending")
34
+ document.content && document.json
35
+ end
36
+
37
+ def recovery_layout(journal)
38
+ plan = journal.fetch('plan')
39
+ identity = plan.fetch('layout')
40
+ raise Conflict, 'Recovery journal belongs to another application' unless identity.fetch('root') == @root
41
+
42
+ layout = Layout.new(@root, identity.fetch('procfile'))
43
+ paths = plan.fetch('changes').map do |change|
44
+ path = change.fetch('path')
45
+ raise Conflict, 'Recovery target is outside the application' unless path.start_with?("#{@root}/")
46
+
47
+ path.delete_prefix("#{@root}/")
48
+ end
49
+ layout.include_previous(paths)
50
+ layout
51
+ end
52
+
53
+ def verify_preview(applier, layout, journal)
54
+ plan = Woods::AgentConfiguration::Plan.allocate
55
+ plan.instance_variable_set(:@data, journal.fetch('plan'))
56
+ plan.validate!(layout)
57
+ changes = plan.data.fetch('changes')
58
+ applier.send(:validate_originals!, journal.fetch('originals'), changes)
59
+ changes.each { |change| applier.send(:validate_recovery_snapshot!, change) }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ class Installation
6
+ # Small generated fragments; runtime identities never enter committed files.
7
+ module Templates
8
+ START = '# woods:watch:managed:start'
9
+ FINISH = '# woods:watch:managed:end'
10
+
11
+ module_function
12
+
13
+ # @param child_command [Array<String>] explicit task argv
14
+ # @return [String] application-relative executable source
15
+ def wrapper(child_command)
16
+ <<~RUBY
17
+ #!/usr/bin/env ruby
18
+ # frozen_string_literal: true
19
+
20
+ # Generated by Woods; use woods:watch --operation update to change owned setup.
21
+ root = File.expand_path('..', __dir__)
22
+ ENV['BUNDLE_GEMFILE'] ||= File.join(root, 'Gemfile')
23
+ require 'bundler/setup'
24
+ Dir.chdir(root)
25
+ exec Gem.ruby, Gem.bin_path('woods', 'woods-watch'), '--root', root,
26
+ *ARGV, '--', *#{child_command.inspect}
27
+ RUBY
28
+ end
29
+
30
+ # @param mode [String] procfile or puma
31
+ # @return [String] owned startup directive
32
+ def directive(mode)
33
+ return 'woods: bin/woods-watch' unless mode == 'puma'
34
+
35
+ 'plugin :woods if Gem.loaded_specs["woods"]&.full_require_paths&.any? ' \
36
+ '{ |path| File.file?(File.join(path, "puma/plugin/woods.rb")) }'
37
+ end
38
+
39
+ # @param mode [String] selected startup mode
40
+ # @param text [String] surrounding file contents
41
+ # @param previous [String, nil] existing receipt-owned block
42
+ # @param update [Boolean] refresh an existing block in place
43
+ # @return [String] exact appended owned block including separator
44
+ def section(mode, text, previous: nil, update: false)
45
+ if previous
46
+ return previous unless update
47
+
48
+ return previous.partition(START).first + section(mode, previous)
49
+ end
50
+
51
+ newline = text.include?("\r\n") ? "\r\n" : "\n"
52
+ separator = text.empty? || text.end_with?(newline) ? '' : newline
53
+ separator + [START, directive(mode), FINISH, ''].join(newline)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../agent_configuration/applier'
5
+ require_relative '../agent_configuration/plan'
6
+ require_relative 'installation/layout'
7
+ require_relative 'installation/options'
8
+ require_relative 'installation/planner'
9
+ require_relative 'installation/probe'
10
+ require_relative 'installation/recovery'
11
+
12
+ module Woods
13
+ module Watch
14
+ # Plans and applies portable, explicitly owned watcher startup configuration.
15
+ class Installation
16
+ Conflict = Woods::AgentConfiguration::Conflict
17
+
18
+ # @param root [String] application directory
19
+ # @param options [Hash] mode, operation, child/manager argv and preflight options
20
+ def initialize(root:, **options)
21
+ @options = Options.new(root: root, **options)
22
+ @layout = Layout.new(@options.root, @options.procfile)
23
+ end
24
+
25
+ # Preview all edits without creating files or starting a watcher.
26
+ # @return [Woods::AgentConfiguration::Plan]
27
+ def plan
28
+ @options.validate!
29
+ Planner.new(layout: @layout, options: @options).call
30
+ end
31
+
32
+ # Apply a fresh or previously reviewed plan using conflict-checked writes.
33
+ # @param reviewed_plan [Woods::AgentConfiguration::Plan, nil] optional preview
34
+ # @return [String] applied or already_applied
35
+ def apply(reviewed_plan = nil)
36
+ selected = reviewed_plan || plan
37
+ return 'already_applied' if selected.data.fetch('changes').empty?
38
+
39
+ Woods::AgentConfiguration::Applier.new(layout: @layout).apply(selected)
40
+ end
41
+
42
+ # Explain which process must own this installation.
43
+ # @return [String]
44
+ def handoff
45
+ @options.handoff
46
+ end
47
+
48
+ # Recover an interrupted transaction without booting the application.
49
+ # @param pretend [Boolean] validate the journal without restoring files
50
+ # @return [String] recovery outcome
51
+ def recover(pretend: false)
52
+ Recovery.new(root: @options.root).call(pretend: pretend)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'managed_child'
5
+
6
+ module Woods
7
+ module Watch
8
+ # Validates the private attempt protocol independently of application logs.
9
+ class Lifecycle # rubocop:disable Metrics/ClassLength -- one attempt's ordered protocol state must stay together
10
+ # @param launcher [String] expected launcher token
11
+ # @param attempt [String] expected attempt token
12
+ # @param root [String] selected application root
13
+ def initialize(launcher:, attempt:, root:)
14
+ @launcher = launcher
15
+ @attempt = attempt
16
+ @root = File.realpath(root)
17
+ end
18
+
19
+ # @return [String, nil] resolved index directory, only after Rails boot
20
+ attr_reader :index, :child_pid, :terminal, :exit_code, :state, :reason
21
+
22
+ # @return [Boolean] Rails has resolved the application/index identity
23
+ def booted?
24
+ !@index.nil?
25
+ end
26
+
27
+ # @return [Boolean] guardian explicitly reported the application's exit
28
+ def finished?
29
+ @exited == true
30
+ end
31
+
32
+ # @param line [String] bounded JSON event from the private descriptor
33
+ # @return [Hash] validated event
34
+ def accept(line)
35
+ raise ArgumentError, 'oversized Woods lifecycle record' if line.bytesize > ManagedChild::MAX_BYTES
36
+
37
+ record = JSON.parse(line)
38
+ validate_envelope!(record)
39
+ event = record.fetch('event')
40
+ raise ArgumentError, 'event after guardian exit' if @exited
41
+
42
+ if %w[hello spawned exit spawn_error].include?(event)
43
+ guardian_event(record)
44
+ else
45
+ task_event(record)
46
+ end
47
+ record
48
+ rescue JSON::ParserError, KeyError, TypeError, SystemCallError
49
+ raise ArgumentError, 'invalid Woods lifecycle protocol'
50
+ end
51
+
52
+ private
53
+
54
+ def validate_envelope!(record)
55
+ return if record.is_a?(Hash) && record['version'] == 1 && record['launcher'] == @launcher &&
56
+ record['attempt'] == @attempt && record['pid'].is_a?(Integer) && record['pid'].positive?
57
+
58
+ raise ArgumentError, 'invalid Woods lifecycle envelope'
59
+ end
60
+
61
+ def guardian_event(record)
62
+ case record['event']
63
+ when 'hello'
64
+ raise ArgumentError, 'duplicate guardian hello' if @guardian
65
+
66
+ @guardian = record['pid']
67
+ when 'spawned'
68
+ spawned(record)
69
+ when 'exit', 'spawn_error'
70
+ validate_guardian!(record)
71
+ validate_exit!(record)
72
+ @exit_code = record['event'] == 'spawn_error' ? 127 : record['code']
73
+ @exited = true
74
+ end
75
+ end
76
+
77
+ def spawned(record)
78
+ validate_guardian!(record)
79
+ raise ArgumentError, 'duplicate child spawn' if @spawned
80
+
81
+ pid = record['child_pid']
82
+ raise ArgumentError, 'invalid child PID' unless pid.is_a?(Integer) && pid.positive?
83
+ raise ArgumentError, 'child PID changed' if @child_pid && @child_pid != pid
84
+
85
+ @child_pid = pid
86
+ @spawned = true
87
+ end
88
+
89
+ def validate_exit!(record)
90
+ return if record['event'] == 'spawn_error' && !@spawned
91
+ return if record['event'] == 'exit' && @spawned && exit_status_valid?(record)
92
+
93
+ raise ArgumentError, 'invalid guardian exit'
94
+ end
95
+
96
+ def exit_status_valid?(record)
97
+ code, signal = record.values_at('code', 'signal')
98
+ return code.between?(0, 255) if code.is_a?(Integer)
99
+
100
+ code.nil? && signal.is_a?(Integer) && signal.positive?
101
+ end
102
+
103
+ def validate_guardian!(record)
104
+ raise ArgumentError, 'guardian identity mismatch' unless @guardian == record['pid']
105
+ end
106
+
107
+ def task_event(record)
108
+ validate_task_identity!(record)
109
+ event = record['event']
110
+ case event
111
+ when 'task_loaded' then load_task(record)
112
+ when 'identity' then resolve_identity(record)
113
+ when 'backend_ready' then ready_backend
114
+ when 'startup' then startup(record)
115
+ when 'terminal' then finish(record)
116
+ else raise ArgumentError, 'unknown Woods lifecycle event'
117
+ end
118
+ end
119
+
120
+ def validate_task_identity!(record)
121
+ raise ArgumentError, 'missing guardian hello' unless @guardian
122
+ raise ArgumentError, 'task event after terminal' if @terminal
123
+ raise ArgumentError, 'task identity mismatch' if @child_pid && @child_pid != record['pid']
124
+
125
+ @child_pid = record['pid']
126
+ end
127
+
128
+ def load_task(record)
129
+ version = record['woods_version']
130
+ unless !@task && version.is_a?(String) && Gem::Version.correct?(version)
131
+ raise ArgumentError, 'invalid task handshake'
132
+ end
133
+
134
+ @task = true
135
+ @state = 'booting'
136
+ end
137
+
138
+ def resolve_identity(record)
139
+ root, index = record.values_at('root', 'index')
140
+ unless @task && !@index && root.is_a?(String) && File.realpath(root) == @root &&
141
+ index.is_a?(String) && File.expand_path(index) == index
142
+ raise ArgumentError, 'invalid task root/index identity'
143
+ end
144
+
145
+ @index = index
146
+ @state = 'starting'
147
+ end
148
+
149
+ def ready_backend
150
+ raise ArgumentError, 'invalid backend ready event' unless booted? && !@backend
151
+
152
+ @backend = true
153
+ @state = 'reconciling'
154
+ end
155
+
156
+ def startup(record)
157
+ validate_startup!(record)
158
+ reconciled = record['reason'] == 'reconciled' && record['generation'].positive?
159
+ raise ArgumentError, 'inconsistent startup completion' unless (record['state'] == 'ready') == reconciled
160
+
161
+ @state = record['state']
162
+ @reason = record['reason']
163
+ end
164
+
165
+ def validate_startup!(record)
166
+ return if @backend && %w[ready degraded].include?(record['state']) &&
167
+ ManagedChild::STARTUP_REASONS.include?(record['reason']) &&
168
+ record['generation'].is_a?(Integer) && record['generation'] >= 0
169
+
170
+ raise ArgumentError, 'invalid startup completion'
171
+ end
172
+
173
+ def finish(record)
174
+ unless @task && ManagedChild::TERMINAL_REASONS.include?(record['reason'])
175
+ raise ArgumentError, 'invalid terminal event'
176
+ end
177
+
178
+ @terminal = record['reason']
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Watch
7
+ # Private, bounded lifecycle reporting for a task owned by woods-watch.
8
+ # Raw rake tasks do not open descriptors or alter their lifecycle.
9
+ class ManagedChild
10
+ ENV_KEYS = %w[WOODS_WATCH_EVENT_FD WOODS_WATCH_LAUNCHER_TOKEN WOODS_WATCH_ATTEMPT_TOKEN].freeze
11
+ TOKEN = /\A[A-Za-z0-9_-]{1,128}\z/
12
+ MAX_BYTES = 4096
13
+ FIELDS = {
14
+ task_loaded: %i[woods_version], identity: %i[root index], backend_ready: [],
15
+ startup: %i[state generation reason], terminal: %i[reason]
16
+ }.freeze
17
+ STARTUP_REASONS = %w[reconciled startup_failed pending_work restart_required no_index catch_up_disabled].freeze
18
+ TERMINAL_REASONS = %w[restart_required already_running stopped idle unsupported_environment].freeze
19
+
20
+ # @param env [#[]] child process environment
21
+ # @return [ManagedChild, nil] nil for an ordinary unmanaged task
22
+ def self.from_env(env: ENV)
23
+ values = ENV_KEYS.map { |key| env[key] }
24
+ return if values.all?(&:nil?)
25
+
26
+ descriptor, launcher, attempt = values
27
+ validate_environment!(descriptor, [launcher, attempt])
28
+
29
+ unless env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.empty?
30
+ raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be unset for managed watching'
31
+ end
32
+
33
+ new(IO.for_fd(descriptor.to_i, 'w'), launcher: launcher, attempt: attempt)
34
+ end
35
+
36
+ def self.validate_environment!(descriptor, tokens)
37
+ valid_tokens = tokens.all? { |token| token.is_a?(String) && token.match?(TOKEN) }
38
+ valid_fd = descriptor.is_a?(String) && descriptor.match?(/\A\d+\z/) && descriptor.to_i >= 3
39
+ return if valid_tokens && valid_fd
40
+
41
+ raise ArgumentError, 'invalid Woods managed-child environment'
42
+ end
43
+ private_class_method :validate_environment!
44
+
45
+ # @param io [IO] private event descriptor owned by this reporter
46
+ # @param launcher [String] supervisor identity token
47
+ # @param attempt [String] current child attempt identity token
48
+ def initialize(io, launcher:, attempt:)
49
+ @io = io
50
+ @io.binmode
51
+ @io.sync = true
52
+ @io.close_on_exec = true
53
+ @launcher = launcher
54
+ @attempt = attempt
55
+ @mutex = Mutex.new
56
+ end
57
+
58
+ # @param event [Symbol] an allowlisted lifecycle boundary
59
+ # @param fields [Hash] bounded protocol data, never application exceptions
60
+ # @return [void]
61
+ def call(event, **fields)
62
+ validate!(event, fields)
63
+ message = JSON.generate(version: 1, launcher: @launcher, attempt: @attempt,
64
+ event: event.to_s, pid: Process.pid, **fields) << "\n"
65
+ raise ArgumentError, 'Woods lifecycle message exceeds size limit' if message.bytesize > MAX_BYTES
66
+
67
+ @mutex.synchronize { @io.write(message) }
68
+ end
69
+
70
+ # @return [void]
71
+ def close
72
+ @mutex.synchronize { @io.close unless @io.closed? }
73
+ end
74
+
75
+ private
76
+
77
+ def validate!(event, fields)
78
+ expected = FIELDS[event]
79
+ raise ArgumentError, 'invalid Woods lifecycle event fields' unless expected && fields.keys.sort == expected.sort
80
+
81
+ validate_payload!(event, fields)
82
+ end
83
+
84
+ def validate_payload!(event, fields)
85
+ case event
86
+ when :startup then validate_startup!(fields)
87
+ when :terminal
88
+ raise ArgumentError, 'invalid Woods terminal reason' unless TERMINAL_REASONS.include?(fields[:reason])
89
+ when :identity
90
+ validate_identity!(fields)
91
+ when :task_loaded
92
+ raise ArgumentError, 'invalid Woods version' unless fields[:woods_version].is_a?(String)
93
+ end
94
+ end
95
+
96
+ def validate_identity!(fields)
97
+ return if %i[root index].all? do |key|
98
+ fields[key].is_a?(String) && fields[key] == File.expand_path(fields[key])
99
+ end
100
+
101
+ raise ArgumentError, 'Woods lifecycle identity must contain absolute paths'
102
+ end
103
+
104
+ def validate_startup!(fields)
105
+ return if %w[ready degraded].include?(fields[:state]) &&
106
+ fields[:generation].is_a?(Integer) && fields[:generation] >= 0 &&
107
+ STARTUP_REASONS.include?(fields[:reason])
108
+
109
+ raise ArgumentError, 'invalid Woods startup state or reason'
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ # Bounded, idempotent cleanup of groups registered by an owned guardian.
6
+ module ManagedCleanup
7
+ private
8
+
9
+ def stop_owned
10
+ return if @stopped
11
+
12
+ @io_mutex.synchronize { @liveness.close if @liveness && !@liveness.closed? }
13
+ wait_until_stopped(@config[:shutdown_timeout] + 1)
14
+ force_stop if alive? || @status&.signaled?
15
+ @stopped = true
16
+ ensure
17
+ close
18
+ end
19
+
20
+ def force_stop
21
+ child = @stream&.child_pid
22
+ signal_owned_group('KILL', child) if child
23
+ signal_owned_group('KILL', @pid) if alive?
24
+ wait_until_stopped(1)
25
+ end
26
+
27
+ def wait_until_stopped(seconds)
28
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
29
+ while alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
30
+ drain_cleanup_events
31
+ sleep 0.02
32
+ end
33
+ end
34
+
35
+ def drain_cleanup_events
36
+ read_events
37
+ rescue ArgumentError
38
+ @io_mutex.synchronize { @reader.close unless @reader.closed? }
39
+ end
40
+
41
+ def signal_owned_group(signal, pid)
42
+ Process.kill(signal, -pid)
43
+ rescue Errno::ESRCH
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rbconfig'
5
+ require 'securerandom'
6
+ require_relative 'child_environment'
7
+ require_relative 'event_stream'
8
+ require_relative 'managed_cleanup'
9
+
10
+ module Woods
11
+ module Watch
12
+ # Owns one guardian and its private pipes. File-based PIDs never grant
13
+ # signal authority. The guardian starts before the application bundle.
14
+ class ManagedProcess
15
+ include ManagedCleanup
16
+
17
+ MAX_EVENT_BYTES = 4096
18
+
19
+ # @param command [Array<String>] explicit application argument vector
20
+ # @param root [String] application working directory
21
+ # @param env [Hash] application environment
22
+ # @param shutdown_timeout [Numeric] graceful shutdown seconds
23
+ # @param events [Boolean] expose the private task protocol to the child
24
+ # @param launcher [String] launcher identity
25
+ # @param attempt [String] fresh attempt identity
26
+ # rubocop:disable-next Metrics/ParameterLists -- the process and protocol identities are explicit collaborators
27
+ def initialize(command:, root:, env:, shutdown_timeout: 10, events: false,
28
+ launcher: SecureRandom.hex(16), attempt: SecureRandom.hex(16))
29
+ @config = { command: command, root: root, env: ChildEnvironment.build(ENV.to_h.merge(env), root: root),
30
+ shutdown_timeout: shutdown_timeout,
31
+ events: events, launcher: launcher, attempt: attempt }
32
+ @io_mutex = Mutex.new
33
+ @stop_mutex = Mutex.new
34
+ @pending_events = []
35
+ end
36
+
37
+ # @return [Integer, nil] owned guardian PID
38
+ attr_reader :pid
39
+
40
+ # @return [ManagedProcess] this started process
41
+ def start
42
+ raise ArgumentError, 'process already started' if @pid
43
+
44
+ parent, @liveness = IO.pipe
45
+ @reader, writer = IO.pipe
46
+ config_reader, config_writer = IO.pipe
47
+ spawn_guardian(parent, writer, config_reader)
48
+ @stream = EventStream.new(reader: @reader, guardian: @pid,
49
+ launcher: @config[:launcher], attempt: @config[:attempt])
50
+ [parent, writer, config_reader].each(&:close)
51
+ config_writer.write(JSON.generate(child_config))
52
+ config_writer.close
53
+ await_registration
54
+ self
55
+ rescue StandardError
56
+ [parent, writer, config_reader, config_writer].compact.each { |io| io.close unless io.closed? }
57
+ close
58
+ raise
59
+ end
60
+
61
+ # @return [Boolean] guardian still owns an active attempt
62
+ def alive?
63
+ return false unless @pid
64
+ return false if @status
65
+
66
+ @status = Process.waitpid2(@pid, Process::WNOHANG)&.last
67
+ @status.nil?
68
+ rescue Errno::ECHILD
69
+ false
70
+ end
71
+
72
+ # @return [Integer, nil] observed guardian exit code
73
+ def exit_status
74
+ alive?
75
+ @status&.exitstatus
76
+ end
77
+
78
+ # @return [Array<String>] bounded complete private protocol records
79
+ def read_events
80
+ @io_mutex.synchronize do
81
+ pending = @pending_events
82
+ @pending_events = []
83
+ pending + (@stream ? @stream.read : [])
84
+ end
85
+ end
86
+
87
+ # @return [Boolean] private stream ended
88
+ def eof?
89
+ @stream&.eof? == true
90
+ end
91
+
92
+ # Stop only this owned group, with cleanup delegated to its guardian.
93
+ # @return [void]
94
+ def stop
95
+ @stop_mutex.synchronize { stop_owned }
96
+ end
97
+
98
+ # Drop ownership; guardian observes EOF even if application boot hangs.
99
+ # @return [void]
100
+ def close
101
+ @io_mutex.synchronize do
102
+ [@liveness, @reader].compact.each { |io| io.close unless io.closed? }
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def await_registration
109
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
110
+ loop do
111
+ @pending_events.concat(@stream.read)
112
+ if @stream.child_pid
113
+ @liveness.write('S')
114
+ return
115
+ end
116
+ raise ArgumentError, 'guardian could not register its child' unless alive?
117
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
118
+ raise ArgumentError,
119
+ 'guardian registration timed out'
120
+ end
121
+
122
+ sleep 0.01
123
+ end
124
+ end
125
+
126
+ def spawn_guardian(parent, writer, config_reader)
127
+ guardian = File.expand_path('guardian.rb', __dir__)
128
+ @pid = Process.spawn({ 'RUBYOPT' => nil, 'RUBYLIB' => nil }, RbConfig.ruby, '--disable-gems', guardian,
129
+ 3 => writer, 4 => parent, 5 => config_reader, in: File::NULL,
130
+ pgroup: true, close_others: true)
131
+ end
132
+
133
+ def child_config
134
+ config = @config.dup
135
+ env = config[:env].dup
136
+ if config[:events]
137
+ env.merge!('WOODS_WATCH_EVENT_FD' => '3', 'WOODS_WATCH_LAUNCHER_TOKEN' => config[:launcher],
138
+ 'WOODS_WATCH_ATTEMPT_TOKEN' => config[:attempt])
139
+ end
140
+ config.merge(env: env, owner_pid: Process.pid)
141
+ end
142
+ end
143
+ end
144
+ end