woods 2.0.0.beta4 → 2.0.1
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 +495 -471
- data/CONTRIBUTING.md +12 -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 +44 -27
- data/docs/CONSOLE_MCP_SETUP.md +95 -16
- 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_HTTP_TRANSPORT.md +54 -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 +69 -7
- data/docs/WATCH_DAEMON.md +172 -17
- data/docs/WHY_WOODS.md +9 -5
- data/exe/woods-console +13 -11
- data/exe/woods-mcp-http +16 -9
- 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 +18 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +401 -43
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +39 -10
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- 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/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +3 -1
- data/lib/woods/mcp/initialization_guidance.rb +1 -1
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/server.rb +41 -9
- data/lib/woods/railtie_support.rb +8 -0
- 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 +88 -8
- data/plugin/skills/woods-investigate/SKILL.md +6 -6
- data/plugin/skills/woods-mcp-config/SKILL.md +43 -1
- data/plugin/skills/woods-setup/SKILL.md +66 -4
- metadata +37 -5
|
@@ -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
|
|
@@ -36,26 +36,57 @@ bundle exec rails runner 'Rails.application.eager_load!; puts "eager load ok"'
|
|
|
36
36
|
|
|
37
37
|
Use the application's normal Docker command and environment variables when applicable. Fix boot/eager-load failures before Woods.
|
|
38
38
|
|
|
39
|
+
### Watcher setup cannot find already installed Rails or dependencies
|
|
40
|
+
|
|
41
|
+
Compare normal task discovery with installer preflight in the same container and
|
|
42
|
+
application environment. Early Git builds of the watcher installer stripped
|
|
43
|
+
`BUNDLE_PATH` and `BUNDLE_APP_CONFIG`; Woods `2.0.0` includes the #540 fix.
|
|
44
|
+
Record the loaded revision and bundle configuration source before
|
|
45
|
+
reinstalling dependencies or writing a local bundle-path workaround. Follow the
|
|
46
|
+
[watcher installation guide](https://github.com/lost-in-the/woods/blob/main/docs/WATCH_DAEMON.md#managed-development-startup).
|
|
47
|
+
|
|
48
|
+
### Puma cannot load the Woods plugin after switching branches
|
|
49
|
+
|
|
50
|
+
Early generated guards checked only whether Woods was activated. An older gem
|
|
51
|
+
can satisfy that check without providing `puma/plugin/woods.rb`. The #542 fix is
|
|
52
|
+
included in Woods `2.0.0`: verify the loaded revision, then use a supporting
|
|
53
|
+
bundle to preview and apply `bin/rails generate woods:watch --operation update
|
|
54
|
+
--mode puma`. The updated guard checks the active gem's require paths, so older
|
|
55
|
+
gems boot without a watcher. Repeating setup does not upgrade the guard. Follow
|
|
56
|
+
the [owned setup runbook](https://github.com/lost-in-the/woods/blob/main/docs/WATCH_DAEMON.md#ownership-updates-and-removal);
|
|
57
|
+
do not change the owned directive or receipt manually.
|
|
58
|
+
|
|
39
59
|
### Watch repeatedly exits 75
|
|
40
60
|
|
|
61
|
+
First identify the lifecycle owner. The raw task deliberately exits 75; a bare
|
|
62
|
+
Foreman entry then stops all services. Managed `woods-watch`/Puma setup (#538)
|
|
63
|
+
is included in Woods `2.0.0`: verify installed executable/generator help and
|
|
64
|
+
loaded revision before proposing it. Supporting launchers retry boot failures,
|
|
65
|
+
reject idle TTL, and park ownership/protocol conflicts until owner restart.
|
|
66
|
+
Inspect separate supervision state rather than treating its parent PID as a
|
|
67
|
+
healthy daemon. Before the first resolved index path, use launcher logs.
|
|
68
|
+
Do not remove claims or kill PIDs from status to force takeover. See
|
|
69
|
+
[startup diagnosis](https://github.com/lost-in-the/woods/blob/main/docs/TROUBLESHOOTING.md#watcher-startup-or-planned-restart-fails).
|
|
70
|
+
|
|
41
71
|
Check the installed version's watch guide. Older releases, including
|
|
42
72
|
`2.0.0.beta2`, can rediscover the same restart-trigger paths on every boot. Stop
|
|
43
73
|
the supervisor, run one successful full extraction, then restart the standalone
|
|
44
74
|
watch task. Do not assume automatic startup reconciliation exists in that release.
|
|
45
75
|
For versions documenting environment-boot snapshots, confirm that the command is
|
|
46
|
-
|
|
76
|
+
the application's actual `rails woods:watch` or `rake woods:watch` entrypoint,
|
|
77
|
+
with no preceding `environment` task, and check
|
|
47
78
|
whether boot inputs keep changing during initialization or catch-up.
|
|
48
79
|
|
|
49
80
|
### Watch retains facts from an initializer deleted while stopped
|
|
50
81
|
|
|
51
|
-
Record the installed revision.
|
|
82
|
+
Record the installed revision. In Woods `2.0.0`, startup preserves
|
|
52
83
|
registered deleted boot inputs as full-extraction obligations. On earlier builds,
|
|
53
84
|
stop watch, run a successful full extraction in a fresh process, then restart
|
|
54
85
|
standalone `woods:watch`. See the installed version's watch guide.
|
|
55
86
|
|
|
56
87
|
### A cleaned index directory still exists
|
|
57
88
|
|
|
58
|
-
|
|
89
|
+
In Woods `2.0.0`, `woods:clean` retains the output directory and
|
|
59
90
|
hidden extraction guard for concurrent writer coordination. Verify published
|
|
60
91
|
artifacts are gone; do not remove that guard while writers may be running.
|
|
61
92
|
|
|
@@ -134,7 +165,7 @@ with `server.version`; missing/null is unknown, not a failure. A validator
|
|
|
134
165
|
major-version warning calls for full extraction and upgrade review, while a match
|
|
135
166
|
does not certify retained units were migrated. See [writer provenance](https://github.com/lost-in-the/woods/blob/main/docs/PUBLISHED_INDEX.md#manifest-writer-provenance).
|
|
136
167
|
|
|
137
|
-
|
|
168
|
+
Included in Woods `2.0.0`: incremental/refresh handled source errors keep
|
|
138
169
|
the previous generation active and leave watch batches pending. Repair the
|
|
139
170
|
logged source error and retry the complete batch; see
|
|
140
171
|
[handled source errors](https://github.com/lost-in-the/woods/blob/main/docs/INCREMENTAL_EXTRACTION.md#handled-source-errors-and-retry).
|
|
@@ -177,6 +208,24 @@ access with its `WOODS_GIT_DIR` setting. A failed history stream is discarded;
|
|
|
177
208
|
repair git access and run full extraction to refresh retained metadata. See the
|
|
178
209
|
[history contract](https://github.com/lost-in-the/woods/blob/main/docs/CONFIGURATION_REFERENCE.md#git-enrichment-history).
|
|
179
210
|
|
|
211
|
+
If per-unit Git metadata is absent, check `git --version` inside the extraction
|
|
212
|
+
process/container as well as repository access. Builds with #551 warn when Git
|
|
213
|
+
cannot execute and a repository is expected; older versions may be silent.
|
|
214
|
+
Source archives without a Git directory remain supported. `GIT_SHA` and
|
|
215
|
+
structural `ready` do not certify history availability. After repairing Git,
|
|
216
|
+
run full extraction; see the
|
|
217
|
+
[missing-executable diagnostic](https://github.com/lost-in-the/woods/blob/main/docs/TROUBLESHOOTING.md#git-executable-is-missing-from-the-extraction-environment).
|
|
218
|
+
|
|
219
|
+
For linked-worktree provenance/history mismatches, check the installed version's
|
|
220
|
+
`WOODS_GIT_DIR` support and compare the selected branch and exact SHA inside the
|
|
221
|
+
extraction environment. Mount the complete shared `.git` at its original path
|
|
222
|
+
with no override, or select `/mounted-common/worktrees/<id>` within a relocated
|
|
223
|
+
complete mount. Derive `<id>` from Git metadata, not the branch name. Selecting
|
|
224
|
+
the shared root uses the primary checkout's HEAD and also changes incremental
|
|
225
|
+
ranges. A commit alone may leave the source-file watcher idle; run full
|
|
226
|
+
extraction after repair or when current Git history is required. See the
|
|
227
|
+
[worktree mount guide](https://github.com/lost-in-the/woods/blob/main/docs/TROUBLESHOOTING.md#git-directory-mounts-for-linked-worktrees).
|
|
228
|
+
|
|
180
229
|
After a bundle change or removal of a dynamically defined job, incremental
|
|
181
230
|
extraction can retain stale runtime units. Use a fresh process with the updated
|
|
182
231
|
bundle for full extraction, then validate. For missing external gem paths,
|
|
@@ -205,13 +254,13 @@ Compare the client config with the exact command, absolute `cwd`, bundle, and in
|
|
|
205
254
|
bundle exec woods-mcp-start ./tmp/woods
|
|
206
255
|
```
|
|
207
256
|
|
|
208
|
-
If startup says `Could not resolve a published Woods index` (
|
|
209
|
-
`2.0.0
|
|
257
|
+
If startup says `Could not resolve a published Woods index` (included in Woods
|
|
258
|
+
`2.0.0`) or names a missing `manifest.json` on older versions, first check
|
|
210
259
|
the selected index path: an atomic index uses `generation.json` to locate its
|
|
211
260
|
payload manifest. The new headline does not change index validation or recovery.
|
|
212
261
|
Point at an existing index before suggesting a new extraction. Prefer the
|
|
213
|
-
explicit path above; `WOODS_DIR` is also supported.
|
|
214
|
-
`
|
|
262
|
+
explicit path above; `WOODS_DIR` is also supported. Woods `2.0.0` includes
|
|
263
|
+
`WOODS_OUTPUT` after those two choices, so verify the installed
|
|
215
264
|
version's configuration guide before relying on that fallback.
|
|
216
265
|
|
|
217
266
|
Then reconnect through the MCP client and call `woods_status`. Use client-native tool inspection after initialization. Expect 14 packaged Index tools, not all conditional schemas.
|
|
@@ -253,6 +302,18 @@ paging alone only visits the discovered prefix. See the
|
|
|
253
302
|
|
|
254
303
|
## 4. Check semantic retrieval
|
|
255
304
|
|
|
305
|
+
Do not treat structural `ready: true` or bootstrap `hydrated` as proof that
|
|
306
|
+
embeddings exist. Check the installed reader's capabilities: builds with #549
|
|
307
|
+
expose `woods_status.retriever.corpus`, including locally known vector and
|
|
308
|
+
metadata record counts by type. Missing fields or `null` counts mean unknown,
|
|
309
|
+
not zero. Counts include chunks and do not certify complete unit coverage.
|
|
310
|
+
When both stores are known empty, supporting readers return `empty_index` with
|
|
311
|
+
embed or explicit lexical-mode guidance. Follow the
|
|
312
|
+
[corpus diagnostic contract](https://github.com/lost-in-the/woods/blob/main/docs/RETRIEVAL_GUIDE.md#semantic-corpus-diagnostics).
|
|
313
|
+
Older readers need direct embedding-artifact checks; installing this plugin
|
|
314
|
+
does not update the serving gem. Keep reader revision and index writer version
|
|
315
|
+
separate when comparing results.
|
|
316
|
+
|
|
256
317
|
Configured retrieval defaults (#446) are available in Woods `2.0.0.beta3`. For an installed
|
|
257
318
|
version that supports them, an omitted tool budget uses the serving retriever's
|
|
258
319
|
configured default; an explicit budget overrides it. Standalone MCP does not
|
|
@@ -296,6 +357,14 @@ endpoint to silence this warning.
|
|
|
296
357
|
|
|
297
358
|
Console failures are live Rails/config/security failures, not Index failures. Verify authorized environment, Rails boot, `WOODS_CONSOLE_CONFIG` or direct `cwd`, blocked-table policy, credentials, and stderr.
|
|
298
359
|
|
|
360
|
+
For stdio parse errors or response mismatches during tool calls, check for Rails
|
|
361
|
+
logs on stdout. Through Woods `2.0.0.beta4`, stdout is restored after boot;
|
|
362
|
+
configure the Console process's logger to use stderr or a file. Runtime stdout
|
|
363
|
+
isolation is included in Woods `2.0.0`: verify a patched installed revision
|
|
364
|
+
before relying on it. Prefer `bundle exec rake woods:console`; direct Rails
|
|
365
|
+
runner invocation cannot capture output already emitted during Rails boot.
|
|
366
|
+
See the [Console logging diagnosis](https://github.com/lost-in-the/woods/blob/main/docs/CONSOLE_MCP_SETUP.md#rails-logs-break-mcp-protocol).
|
|
367
|
+
|
|
299
368
|
For MySQL SQL refusals, inspect the executing session's `sql_mode` and the installed version's Console guide. Do not change quote modes to bypass a security refusal.
|
|
300
369
|
|
|
301
370
|
For SQLite SQL refusals on `2.0.0.beta4` or a reviewed revision containing its Console corrections, consult the installed Console guide for supported identifier and table-reference syntax. Simplify the query to supported syntax; never relax the blocked-table or function policy. These builds also check resolved default scopes and scan normalized response values. Confirm a patched gem is published before recommending it, and check the installed version’s canonical Console guide; do not infer release availability from this plugin.
|
|
@@ -391,3 +460,14 @@ the named file before moving it aside, or choose a new export directory. Older v
|
|
|
391
460
|
byte-identical generated assets into `_woods/ownership.json`; changed legacy sidecars may need this
|
|
392
461
|
manual recovery. Never fabricate ownership receipts or remove personal files to silence the error.
|
|
393
462
|
See the installed version's `docs/OBSIDIAN_INTEGRATION.md` for the exact safety contract.
|
|
463
|
+
|
|
464
|
+
### Console read compatibility on security-patch candidates
|
|
465
|
+
|
|
466
|
+
Verify the loaded revision for corrections after 2.0.0. Active redaction refuses
|
|
467
|
+
SQL relation/CTE column alias lists; typed EAV matching can intentionally mask
|
|
468
|
+
extra values where tables share a final name or differ only by case. Keep the
|
|
469
|
+
policy enabled and use explicit scalar columns or structured tools. Check exact
|
|
470
|
+
sensitive-key spelling and configure binary secret columns for column redaction.
|
|
471
|
+
Read the [Console compatibility guide](https://github.com/lost-in-the/woods/blob/main/docs/CONSOLE_MCP_SETUP.md#read-policy-compatibility)
|
|
472
|
+
at the installed revision for adapter, timeout and projection limits; a plugin
|
|
473
|
+
update does not patch Woods.
|
|
@@ -16,7 +16,7 @@ Follow the [agent guide](https://github.com/lost-in-the/woods/blob/main/docs/AGE
|
|
|
16
16
|
when instructions are absent. A registered tool does not establish retrieval
|
|
17
17
|
readiness or authorize maintenance or live Console access.
|
|
18
18
|
|
|
19
|
-
Call `woods_status` before relying on the index. Require a ready index with a current generation and non-zero counts for the types you need;
|
|
19
|
+
Call `woods_status` before relying on the index. Require a ready index with a current generation and non-zero counts for the types you need; verify the retrieval mode and its data before using `codebase_retrieve` (see Conceptual questions below). If status is unhealthy or the generation predates the code under review, report that and ask the owner to run `woods:incremental` or `woods:extract` — do not present "not found" as proof the code does not exist.
|
|
20
20
|
|
|
21
21
|
## The default loop
|
|
22
22
|
|
|
@@ -31,9 +31,9 @@ Identifiers are namespaced and typed; never invent one from a filename when `sea
|
|
|
31
31
|
|
|
32
32
|
- **Code review / change impact**: `lookup` the changed unit, then `dependents` at depth 1 before going deeper. Group results by relationship type and layer; report direct dependents separately from inferred downstream impact. A graph edge is not test coverage — select tests from mappings and repository search.
|
|
33
33
|
- **Audit / architecture assessment**: `graph_analysis` for orphans, dead ends, hubs, cycles, bridges, cross-database edges, volatile dependencies, and undeclared package edges; `domain_clusters` for architectural domains; `pagerank` for high-impact units worth reading first.
|
|
34
|
-
- **Investigating behavior / debugging**: `
|
|
34
|
+
- **Investigating behavior / debugging**: find the exact indexed unit with `search` and `lookup`, then use `trace_flow` with `UnitIdentifier` or `UnitIdentifier#method` (for example, `CheckoutService#order`). Bare `order` names a unit, potentially a factory, rather than locating an application method. Receiverless local calls may remain unexpanded; inspect their source or trace the owning unit's method explicitly. Flow output is not proof of runtime execution or exhaustive call coverage. See the [flow workflow](https://github.com/lost-in-the/woods/blob/main/docs/AGENT_GUIDE.md#trace-a-feature-flow).
|
|
35
35
|
- **Onboarding**: `structure` for the codebase overview, `lookup` and `dependencies`/`dependents` for a unit's neighborhood, and `domain_clusters` for the domain map, then the default loop on the units that matter.
|
|
36
|
-
- **Conceptual questions**: `codebase_retrieve`
|
|
36
|
+
- **Conceptual questions**: check retrieval mode and data before `codebase_retrieve`; structural `ready` alone does not establish semantic availability. On readers supporting #549, inspect `retriever.corpus`; missing or unknown counts require checking embedding artifacts. Govern with `budget` (never `limit`), then verify key units with `lookup`.
|
|
37
37
|
|
|
38
38
|
## Boundaries
|
|
39
39
|
|
|
@@ -60,12 +60,12 @@ PORO and library targets. No dependents or test-only dependents do not establish
|
|
|
60
60
|
absence of production callers; check source before making that claim.
|
|
61
61
|
|
|
62
62
|
The response `graph_coverage` notice, `total_is_exact` field, and human label
|
|
63
|
-
`witness types unambiguous` (#470/#471) are
|
|
63
|
+
`witness types unambiguous` (#470/#471) are included in Woods `2.0.0`.
|
|
64
64
|
Verify the installed server version and actual response fields; this plugin does
|
|
65
65
|
not add them. Apply these limits to older servers even without the notice.
|
|
66
66
|
Supporting stdio and HTTP servers expose the paginated traversal payload in
|
|
67
67
|
`structuredContent.data` independently of the text renderer (#481, also
|
|
68
|
-
|
|
68
|
+
included in Woods `2.0.0`). Check the installed response; older default
|
|
69
69
|
responses may carry only text. Do not pass an unsupported `format` argument.
|
|
70
70
|
|
|
71
71
|
`total_is_exact: false` means a budget-limited prefix; a true value describes only
|
|
@@ -106,7 +106,7 @@ recorded reachability does not establish observed execution. See the
|
|
|
106
106
|
|
|
107
107
|
Pass explicit `limit` and `offset` when paging `graph_analysis`. Enforcing the
|
|
108
108
|
advertised default of 20 rows per section and preserving total/offset on last
|
|
109
|
-
and empty pages (#519) are
|
|
109
|
+
and empty pages (#519) are included in Woods `2.0.0`; check the installed
|
|
110
110
|
response rather than inferring support from the plugin version. On supporting
|
|
111
111
|
servers, read `<section>_total` and `<section>_offset` in JSON, or the human
|
|
112
112
|
pagination notice. An empty later page does not mean no findings. Totals count
|