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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +480 -477
- data/CONTRIBUTING.md +2 -2
- data/README.md +11 -26
- data/docs/AGENT_GUIDE.md +31 -12
- data/docs/AGENT_SETUP.md +17 -10
- data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
- data/docs/BACKEND_MATRIX.md +13 -7
- data/docs/CLIENT_HOOKS.md +1 -1
- data/docs/CONFIGURATION_REFERENCE.md +36 -26
- data/docs/CONSOLE_MCP_SETUP.md +10 -8
- data/docs/DOCKER_SETUP.md +15 -0
- data/docs/EVALUATION.md +10 -4
- data/docs/EXTRACTOR_REFERENCE.md +14 -2
- data/docs/FAQ.md +14 -3
- data/docs/GETTING_STARTED.md +18 -17
- data/docs/INCREMENTAL_EXTRACTION.md +8 -3
- data/docs/INDEX_LAYOUT.md +2 -2
- data/docs/MCP_SERVERS.md +28 -11
- data/docs/MCP_TOOL_COOKBOOK.md +1 -1
- data/docs/MCP_WORKTREE_SETUP.md +13 -1
- data/docs/PUBLISHED_INDEX.md +1 -1
- data/docs/README.md +2 -1
- data/docs/RETRIEVAL_GUIDE.md +57 -8
- data/docs/SOURCE_FRESHNESS.md +1 -1
- data/docs/TOKEN_BENCHMARK.md +16 -10
- data/docs/TROUBLESHOOTING.md +133 -37
- data/docs/UPGRADING_TO_2.md +9 -7
- data/docs/WATCH_DAEMON.md +172 -17
- data/docs/WHY_WOODS.md +9 -5
- data/exe/woods-console +13 -11
- data/exe/woods-watch +5 -0
- data/lib/generators/woods/watch_generator.rb +53 -0
- data/lib/puma/plugin/woods.rb +10 -0
- data/lib/tasks/woods.rake +14 -0
- data/lib/woods/cache/cache_middleware.rb +6 -0
- data/lib/woods/console/stdio_transport.rb +27 -0
- data/lib/woods/extractor.rb +25 -7
- data/lib/woods/git_command.rb +6 -7
- data/lib/woods/git_provenance.rb +4 -6
- data/lib/woods/mcp/bootstrapper.rb +3 -1
- data/lib/woods/mcp/initialization_guidance.rb +1 -1
- data/lib/woods/mcp/server.rb +41 -9
- data/lib/woods/retrieval/corpus_status.rb +46 -0
- data/lib/woods/retriever.rb +19 -7
- data/lib/woods/storage/local_corpus_stats.rb +32 -0
- data/lib/woods/storage/metadata_store.rb +20 -0
- data/lib/woods/storage/vector_store.rb +10 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/child_environment.rb +30 -0
- data/lib/woods/watch/cli.rb +91 -0
- data/lib/woods/watch/daemon.rb +55 -7
- data/lib/woods/watch/event_stream.rb +70 -0
- data/lib/woods/watch/guardian.rb +142 -0
- data/lib/woods/watch/installation/layout.rb +70 -0
- data/lib/woods/watch/installation/options.rb +128 -0
- data/lib/woods/watch/installation/planner.rb +128 -0
- data/lib/woods/watch/installation/probe.rb +101 -0
- data/lib/woods/watch/installation/receipt.rb +77 -0
- data/lib/woods/watch/installation/recovery.rb +64 -0
- data/lib/woods/watch/installation/templates.rb +58 -0
- data/lib/woods/watch/installation.rb +56 -0
- data/lib/woods/watch/lifecycle.rb +182 -0
- data/lib/woods/watch/managed_child.rb +113 -0
- data/lib/woods/watch/managed_cleanup.rb +48 -0
- data/lib/woods/watch/managed_process.rb +144 -0
- data/lib/woods/watch/puma_adapter.rb +87 -0
- data/lib/woods/watch/puma_child.rb +66 -0
- data/lib/woods/watch/supervision_records.rb +95 -0
- data/lib/woods/watch/supervision_status.rb +104 -0
- data/lib/woods/watch/supervisor.rb +161 -0
- data/lib/woods/watch/supervisor_reporting.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
- data/plugin/skills/woods-diagnose/SKILL.md +77 -8
- data/plugin/skills/woods-investigate/SKILL.md +6 -6
- data/plugin/skills/woods-mcp-config/SKILL.md +28 -1
- data/plugin/skills/woods-setup/SKILL.md +58 -4
- 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.
|
|
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
|
|
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
|