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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'puma_child'
4
+
5
+ module Woods
6
+ module Watch
7
+ # Optional Puma master-process integration. Requiring Woods itself never
8
+ # loads Puma or starts a watcher; only `plugin :woods` installs these hooks.
9
+ class PumaAdapter
10
+ SUPPORTED_MAJORS = [6, 7, 8].freeze
11
+ FALLBACK = 'Run bin/woods-watch through your development process manager instead.'
12
+
13
+ # @param launcher [Puma::Launcher] configured master launcher
14
+ # @param puma_version [String] loaded Puma version
15
+ # @param platform [String] Ruby platform used for supported-mode checks
16
+ def initialize(launcher, puma_version: ::Puma::Const::PUMA_VERSION, platform: RUBY_PLATFORM)
17
+ @launcher = launcher
18
+ @puma_version = puma_version
19
+ @platform = platform
20
+ @master_pid = Process.pid
21
+ # Puma has already chdir'd before plugin start. Expanding its raw
22
+ # relative `directory` option here would apply that directory twice.
23
+ @root = Dir.pwd
24
+ @started = false
25
+ @installed = false
26
+ end
27
+
28
+ # Register master-only lifecycle callbacks without starting extraction.
29
+ # @return [void]
30
+ def install
31
+ return if @installed
32
+
33
+ @installed = true
34
+ unless supported?
35
+ log("Puma #{@puma_version} on #{@platform} is unsupported by the Woods plugin. #{FALLBACK}")
36
+ return
37
+ end
38
+
39
+ hook(:after_booted, :on_booted) { start }
40
+ hook(:before_restart, :on_restart) { stop }
41
+ hook(:after_stopped, :on_stopped) { stop }
42
+ end
43
+
44
+ # Start at most once after Puma has finalized its environment and booted.
45
+ # @return [void]
46
+ def start
47
+ return unless Process.pid == @master_pid
48
+ return if @started || @launcher.options[:environment].to_s != 'development'
49
+
50
+ @started = true
51
+ unless File.file?(File.join(@root, 'bin/woods-watch'))
52
+ log('Missing bin/woods-watch; run bin/rails generate woods:watch --mode=puma. ' \
53
+ 'Automatic maintenance is inactive.')
54
+ return
55
+ end
56
+
57
+ @child = PumaChild.new(root: @root, environment: 'development', logger: @launcher.log_writer)
58
+ pid = @child.start
59
+ log("Started launcher #{pid}; index readiness is reported separately by woods-watch.")
60
+ rescue SystemCallError => e
61
+ log("Could not start launcher (#{e.class}); automatic maintenance is inactive. #{FALLBACK}")
62
+ end
63
+
64
+ # Stop only the launcher owned by this master process.
65
+ # @return [void]
66
+ def stop
67
+ @child&.stop if Process.pid == @master_pid
68
+ end
69
+
70
+ private
71
+
72
+ def supported?
73
+ SUPPORTED_MAJORS.include?(@puma_version.to_s.split('.').first.to_i) &&
74
+ Process.respond_to?(:fork) && !@platform.match?(/mswin|mingw|cygwin|java/)
75
+ end
76
+
77
+ def hook(current, legacy, &block)
78
+ events = @launcher.events
79
+ events.public_send(events.respond_to?(current) ? current : legacy, &block)
80
+ end
81
+
82
+ def log(message)
83
+ @launcher.log_writer.log("[woods-watch] #{message}")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require_relative 'managed_process'
5
+
6
+ module Woods
7
+ module Watch
8
+ # Owns one foreground launcher. Retry policy belongs to that launcher, never
9
+ # to Puma; a failed launcher must not terminate or restart the web server.
10
+ class PumaChild
11
+ # The launcher gives its extraction child ten seconds to stop. Allow its
12
+ # guardian to finish that cleanup before escalating against the launcher.
13
+ STOP_GRACE = 15
14
+
15
+ # @param root [String] application root containing bin/woods-watch
16
+ # @param environment [String] finalized Puma environment
17
+ # @param logger [#log] Puma's log writer
18
+ # @param stop_grace [Numeric] graceful owned-process shutdown seconds
19
+ def initialize(root:, environment:, logger:, stop_grace: STOP_GRACE)
20
+ @root = root
21
+ @environment = environment
22
+ @logger = logger
23
+ @stop_grace = stop_grace
24
+ @stopping = false
25
+ end
26
+
27
+ # @return [Integer] owned guardian PID, not proof of index readiness
28
+ def start
29
+ @process = ManagedProcess.new(command: [RbConfig.ruby, File.join(@root, 'bin/woods-watch')],
30
+ root: @root, env: child_environment, shutdown_timeout: @stop_grace, events: false)
31
+ @process.start
32
+ @observer = Thread.new { observe }
33
+ @observer.name = 'woods-puma-launcher' if @observer.respond_to?(:name=)
34
+ @process.pid
35
+ end
36
+
37
+ # @return [void]
38
+ def stop
39
+ @stopping = true
40
+ # Puma can invoke stopped/restart callbacks from a signal trap.
41
+ # Only request and join here; the observer owns locks and IO cleanup.
42
+ (@observer || Thread.new { @process&.stop }).join
43
+ end
44
+
45
+ private
46
+
47
+ def child_environment
48
+ { 'APP_ENV' => @environment, 'RACK_ENV' => @environment, 'RAILS_ENV' => @environment }
49
+ end
50
+
51
+ def observe
52
+ until @stopping
53
+ unless @process.alive?
54
+ @process.stop
55
+ @logger.log('[woods-watch] Puma launcher stopped unexpectedly; automatic maintenance is inactive. ' \
56
+ 'Fix the launcher configuration and restart Puma, or run bin/woods-watch separately.')
57
+ return
58
+ end
59
+ sleep 0.1
60
+ end
61
+ ensure
62
+ @process.stop
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ # Bounded readers and liveness correlation for persisted supervision records.
6
+ module SupervisionRecords
7
+ # Read bounded records without granting signal or writer-lock authority.
8
+ # @param index [String] published index root
9
+ # @return [Hash] supervision records with separately evaluated liveness
10
+ def self.read(index)
11
+ directory = File.join(index, SupervisionStatus::DIRECTORY)
12
+ return { records: [] } unless readable_directory?(directory)
13
+
14
+ names = Dir.each_child(directory).take(SupervisionStatus::MAX_SCAN + 1)
15
+ records = names.first(SupervisionStatus::MAX_SCAN).filter_map do |name|
16
+ read_record(File.join(directory, name)) if /\A[a-f0-9]{32}\.json\z/.match?(name)
17
+ end
18
+ records.sort_by! { |record| [record['alive'] ? 0 : 1, -Time.iso8601(record['updated_at']).to_f] }
19
+ correlate(records.first(SupervisionStatus::MAX_RECORDS), index)
20
+ truncated = names.size > SupervisionStatus::MAX_SCAN || records.size > SupervisionStatus::MAX_RECORDS
21
+ { records: records.first(SupervisionStatus::MAX_RECORDS), truncated: truncated }
22
+ rescue SystemCallError
23
+ { records: [] }
24
+ end
25
+
26
+ def self.readable_directory?(directory)
27
+ File.directory?(directory) && !File.symlink?(directory)
28
+ end
29
+ private_class_method :readable_directory?
30
+
31
+ def self.read_record(path)
32
+ bytes = bounded_read(path)
33
+ return unless bytes
34
+
35
+ record = JSON.parse(bytes)
36
+ return unless valid_record?(record, path)
37
+
38
+ age = Time.now - Time.iso8601(record['updated_at'])
39
+ record['alive'] = record['state'] != 'stopped' &&
40
+ age.between?(-30, SupervisionStatus::STALE_AFTER) && local_alive?(record)
41
+ record
42
+ rescue SystemCallError, JSON::ParserError, TypeError, ArgumentError
43
+ nil
44
+ end
45
+ private_class_method :read_record
46
+
47
+ def self.bounded_read(path)
48
+ return if File.symlink?(path)
49
+
50
+ flags = File::RDONLY | File::NONBLOCK
51
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
52
+ bytes = File.open(path, flags) { |file| file.read(SupervisionStatus::MAX_BYTES + 1) if file.stat.file? }
53
+ bytes if bytes && bytes.bytesize <= SupervisionStatus::MAX_BYTES
54
+ end
55
+ private_class_method :bounded_read
56
+
57
+ def self.valid_record?(record, path)
58
+ record.is_a?(Hash) && record['version'] == 1 && record['launcher'] == File.basename(path, '.json') &&
59
+ SupervisionStatus::STATES.include?(record['state'])
60
+ end
61
+ private_class_method :valid_record?
62
+
63
+ def self.local_alive?(record)
64
+ unless record['host'] == Status.host_identity && record['pid'].is_a?(Integer) && record['pid'].positive?
65
+ return false
66
+ end
67
+
68
+ Process.kill(0, record['pid'])
69
+ true
70
+ rescue Errno::ESRCH
71
+ false
72
+ rescue Errno::EPERM
73
+ true
74
+ end
75
+ private_class_method :local_alive?
76
+
77
+ def self.correlate(records, index)
78
+ status = Status.new(output_dir: index)
79
+ daemon = status.read
80
+ daemon_alive = daemon.is_a?(Hash) && status.alive?
81
+ records.each do |record|
82
+ record['active_child'] = daemon_alive && matches_child?(record, daemon)
83
+ end
84
+ rescue SystemCallError, TypeError, NoMethodError
85
+ records.each { |record| record['active_child'] = false }
86
+ end
87
+ private_class_method :correlate
88
+
89
+ def self.matches_child?(record, daemon)
90
+ record['alive'] && record['child_pid'] == daemon['pid'] && record['host'] == daemon['host']
91
+ end
92
+ private_class_method :matches_child?
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'status'
5
+ require_relative 'supervision_records'
6
+
7
+ module Woods
8
+ module Watch
9
+ # Independent owner records: a parked duplicate cannot overwrite the
10
+ # daemon heartbeat or make incremental hooks defer to an inactive writer.
11
+ class SupervisionStatus
12
+ DIRECTORY = 'watch_supervisors'
13
+ MAX_RECORDS = 32
14
+ MAX_SCAN = 256
15
+ MAX_BYTES = 8192
16
+ STALE_AFTER = 30
17
+ STATES = %w[starting booting reconciling ready degraded retrying parked stopped].freeze
18
+ FIELDS = %i[state reason attempt child_pid retry_at].freeze
19
+
20
+ # @param index [String] application-resolved output directory
21
+ # @param token [String] unguessable launcher identity
22
+ def initialize(index:, token:)
23
+ raise ArgumentError, 'invalid launcher token' unless /\A[a-f0-9]{32}\z/.match?(token)
24
+
25
+ @token = token
26
+ @directory = File.join(index, DIRECTORY)
27
+ @path = File.join(@directory, "#{token}.json")
28
+ end
29
+
30
+ # @param fields [Hash] state, owned child, attempt and bounded reason
31
+ # @return [void]
32
+ def write(**fields)
33
+ validate_fields!(fields)
34
+ verify_ownership!
35
+ data = { version: 1, launcher: @token, pid: Process.pid, host: Status.host_identity,
36
+ updated_at: Time.now.utc.iso8601 }.merge(fields)
37
+ bytes = JSON.generate(data)
38
+ raise ArgumentError, 'oversized supervision record' if bytes.bytesize > MAX_BYTES
39
+
40
+ AtomicFile.write(@path, bytes, mode: 0o644)
41
+ prune_stale
42
+ end
43
+
44
+ # @param index [String] published index root
45
+ # @return [Hash] bounded supervision diagnostics
46
+ def self.read(index)
47
+ SupervisionRecords.read(index)
48
+ end
49
+
50
+ def self.read_record(path)
51
+ SupervisionRecords.send(:read_record, path)
52
+ end
53
+ private_class_method :read_record
54
+
55
+ def self.local_alive?(record)
56
+ SupervisionRecords.send(:local_alive?, record)
57
+ end
58
+ private_class_method :local_alive?
59
+
60
+ private
61
+
62
+ def verify_ownership!
63
+ raise ArgumentError, 'invalid supervision directory' if File.symlink?(@directory)
64
+ return unless File.exist?(@path) || File.symlink?(@path)
65
+
66
+ previous = self.class.send(:read_record, @path)
67
+ return if previous && previous['pid'] == Process.pid && previous['host'] == Status.host_identity
68
+
69
+ raise ArgumentError, 'supervision record belongs to another owner'
70
+ end
71
+
72
+ def validate_fields!(fields)
73
+ valid = (fields.keys - FIELDS).empty? && STATES.include?(fields[:state]) &&
74
+ fields[:reason].is_a?(String) && /\A[a-z_]{1,80}\z/.match?(fields[:reason]) &&
75
+ fields[:attempt].is_a?(String) && /\A[a-f0-9]{32}\z/.match?(fields[:attempt])
76
+ raise ArgumentError, 'invalid supervision fields' unless valid
77
+ end
78
+
79
+ def prune_stale
80
+ records = self.class.read(File.dirname(@directory))[:records]
81
+ records.each do |record|
82
+ next unless expired?(record) && removable?(record)
83
+
84
+ path = File.join(@directory, "#{record['launcher']}.json")
85
+ current = self.class.send(:read_record, path)
86
+ stored = record.except('active_child')
87
+ File.unlink(path) if current == stored
88
+ end
89
+ rescue SystemCallError
90
+ nil
91
+ end
92
+
93
+ def expired?(record)
94
+ record['launcher'] != @token && !record['alive'] &&
95
+ Time.now - Time.iso8601(record['updated_at']) > STALE_AFTER
96
+ end
97
+
98
+ def removable?(record)
99
+ record['state'] == 'stopped' ||
100
+ (record['host'] == Status.host_identity && !self.class.send(:local_alive?, record))
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'managed_process'
4
+ require_relative 'lifecycle'
5
+ require_relative 'supervision_status'
6
+ require_relative 'supervisor_reporting'
7
+
8
+ module Woods
9
+ module Watch
10
+ # Foreground retry owner shared by Procfile and Puma installations.
11
+ # It never retries configuration conflicts or silently takes over ownership.
12
+ class Supervisor # rubocop:disable Metrics/ClassLength -- retry, park and shutdown share one foreground attempt state
13
+ include SupervisorReporting
14
+
15
+ RETRY_DELAYS = [1, 2, 4, 8, 16, 30].freeze
16
+
17
+ # @param command [Array<String>] fresh application invocation on each attempt
18
+ # @param root [String] application root
19
+ # @param env [Hash] application environment
20
+ # @param logger [#puts] diagnostic destination (stderr by default)
21
+ # @param boot_timeout [Numeric] pre-identity boot deadline
22
+ # @param shutdown_timeout [Numeric] graceful application shutdown
23
+ # @param retry_delays [Array<Numeric>] bounded retry schedule
24
+ # rubocop:disable-next Metrics/ParameterLists -- independently injectable process policy and diagnostics
25
+ def initialize(command:, root:, env: ENV.to_h, logger: $stderr, boot_timeout: 300,
26
+ shutdown_timeout: 10, retry_delays: RETRY_DELAYS)
27
+ raise ArgumentError, 'WOODS_WATCH_IDLE_TIMEOUT must be unset for managed watching' unless
28
+ env['WOODS_WATCH_IDLE_TIMEOUT'].to_s.empty?
29
+
30
+ @command = command
31
+ @root = File.realpath(root)
32
+ @env = env
33
+ @logger = logger
34
+ @boot_timeout = boot_timeout
35
+ @shutdown_timeout = shutdown_timeout
36
+ @retry_delays = retry_delays
37
+ @token = SecureRandom.hex(16)
38
+ @attempts = @failures = 0
39
+ @stopping = false
40
+ end
41
+
42
+ # @return [String, nil] honest lifecycle state
43
+ attr_reader :state, :attempts
44
+
45
+ # @return [Integer] zero after owner-requested shutdown
46
+ def run
47
+ until @stopping
48
+ run_attempt
49
+ break if @stopping
50
+
51
+ reason = retry_reason
52
+ reason ? retry_after(reason) : park(@protocol.terminal || 'incompatible_or_stopped_task')
53
+ end
54
+ 0
55
+ ensure
56
+ @process&.stop
57
+ publish('stopped', 'owner_stopped')
58
+ end
59
+
60
+ # Signal-safe request; process cleanup occurs on the run thread.
61
+ # @return [void]
62
+ def stop
63
+ @stopping = true
64
+ end
65
+
66
+ private
67
+
68
+ def run_attempt
69
+ prepare_attempt
70
+ @process.start
71
+ monitor_attempt
72
+ rescue ArgumentError => e
73
+ @invalid = true
74
+ @logger.puts("[woods-watch] incompatible lifecycle: #{e.message}")
75
+ ensure
76
+ @process&.stop
77
+ end
78
+
79
+ def prepare_attempt
80
+ retire_status
81
+ @attempts += 1
82
+ @attempt = SecureRandom.hex(16)
83
+ @invalid = @timed_out = false
84
+ @ready_at = nil
85
+ @started_at = monotonic
86
+ @protocol = Lifecycle.new(launcher: @token, attempt: @attempt, root: @root)
87
+ @process = ManagedProcess.new(command: @command, root: @root, env: @env, events: true,
88
+ launcher: @token, attempt: @attempt, shutdown_timeout: @shutdown_timeout)
89
+ publish('starting', 'boot_pending')
90
+ end
91
+
92
+ def monitor_attempt
93
+ loop do
94
+ consume_events
95
+ break if @stopping || @protocol.finished? || !@process.alive?
96
+
97
+ if !@protocol.booted? && monotonic - @started_at >= @boot_timeout
98
+ @timed_out = true
99
+ break
100
+ end
101
+ raise ArgumentError, 'lifecycle stream ended while task was running' if @process.eof?
102
+
103
+ heartbeat
104
+ sleep 0.05
105
+ end
106
+ consume_events
107
+ end
108
+
109
+ def consume_events
110
+ @process.read_events.each do |line|
111
+ record = @protocol.accept(line)
112
+ @status = SupervisionStatus.new(index: @protocol.index, token: @token) if record['event'] == 'identity'
113
+ track_readiness(record) if record['event'] == 'startup'
114
+ publish(@protocol.state, @protocol.reason) if @protocol.state && !%w[exit terminal].include?(record['event'])
115
+ end
116
+ end
117
+
118
+ def track_readiness(record)
119
+ @ready_at = record['state'] == 'ready' ? (@ready_at || monotonic) : nil
120
+ end
121
+
122
+ def retry_reason
123
+ return if parked_outcome?
124
+ return 'boot_timeout' if @timed_out
125
+ return 'restart_required' if @protocol.terminal == 'restart_required' && @protocol.exit_code == 75
126
+ return if [0, 127].include?(@protocol.exit_code)
127
+
128
+ 'child_failed'
129
+ end
130
+
131
+ def parked_outcome?
132
+ @invalid || %w[already_running unsupported_environment].include?(@protocol.terminal)
133
+ end
134
+
135
+ def retry_after(reason)
136
+ @failures = 0 if @ready_at && monotonic - @ready_at >= 60
137
+ delay = @retry_delays.fetch([@failures, @retry_delays.size - 1].min)
138
+ @failures += 1
139
+ publish('retrying', reason, retry_at: (Time.now + delay).utc.iso8601)
140
+ wait_until(monotonic + delay)
141
+ end
142
+
143
+ def park(reason)
144
+ publish('parked', reason)
145
+ @logger.puts('[woods-watch] restart the owner after correcting configuration; no automatic takeover')
146
+ wait_until(Float::INFINITY)
147
+ end
148
+
149
+ def wait_until(deadline)
150
+ until @stopping || monotonic >= deadline
151
+ heartbeat
152
+ sleep 0.05
153
+ end
154
+ end
155
+
156
+ def monotonic
157
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Watch
5
+ # Publication and retirement of one launcher's diagnostic records.
6
+ module SupervisorReporting
7
+ private
8
+
9
+ def publish(state, reason, **details)
10
+ return unless state
11
+
12
+ reason ||= 'pending'
13
+ changed = @state != state || @reason != reason
14
+ @state = state
15
+ @reason = reason
16
+ @details = details
17
+ @logger.puts("[woods-watch] #{state}: #{reason || 'pending'} (attempt #{@attempts})") if changed
18
+ heartbeat(force: true)
19
+ end
20
+
21
+ def heartbeat(force: false)
22
+ return unless @status && heartbeat_due?(force)
23
+
24
+ @status.write(state: @state, reason: @reason, attempt: @attempt,
25
+ child_pid: @process&.alive? ? @protocol.child_pid : nil, **(@details || {}))
26
+ @heartbeat_at = monotonic
27
+ rescue SystemCallError, ArgumentError => e
28
+ @logger.puts("[woods-watch] cannot write supervision status (#{e.class})")
29
+ @heartbeat_at = monotonic
30
+ end
31
+
32
+ def heartbeat_due?(force)
33
+ force || !@heartbeat_at || monotonic - @heartbeat_at >= 5
34
+ end
35
+
36
+ def retire_status
37
+ @status&.write(state: 'stopped', reason: 'attempt_finished', attempt: @attempt, child_pid: nil)
38
+ rescue SystemCallError, ArgumentError => e
39
+ @logger.puts("[woods-watch] cannot retire supervision status (#{e.class})")
40
+ ensure
41
+ @status = nil
42
+ @heartbeat_at = nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "woods-plugin",
3
3
  "description": "Woods user guides for Claude Code: set up, upgrade, configure MCP servers for, investigate codebases with, enable a repository's agents on, and diagnose the Woods Rails code-intelligence gem.",
4
- "version": "2.3.47",
4
+ "version": "2.3.54",
5
5
  "author": {
6
6
  "name": "lost-in-the"
7
7
  },
@@ -23,7 +23,7 @@ and removal. Preserve manual setup for older installed versions.
23
23
  For concurrent configuration operations, wait for the active operation to
24
24
  finish and generate a fresh plan if the saved snapshots changed. Do not remove
25
25
  its lock or overwrite the other application's entry. Coordination across
26
- applications sharing user configuration is unreleased after `2.0.0.beta3`;
26
+ applications sharing user configuration is included in Woods `2.0.0`;
27
27
  check the installed revision before relying on it.
28
28
 
29
29
  ## Preflight