woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
# The Watch Daemon (`woods:watch`)
|
|
2
|
+
|
|
3
|
+
A resident, booted-app process that keeps the index current as files change,
|
|
4
|
+
instead of as-fresh-as-the-last-explicit-rake-run.
|
|
5
|
+
|
|
6
|
+
Background: [#164](https://github.com/lost-in-the/woods/issues/164), phase 2.
|
|
7
|
+
The correctness work it stands on is in
|
|
8
|
+
[INCREMENTAL_EXTRACTION.md](INCREMENTAL_EXTRACTION.md), a watcher on top of an
|
|
9
|
+
incorrect incremental path just delivers wrong answers with lower latency.
|
|
10
|
+
|
|
11
|
+
> **Development only.** The daemon adds no network listener and no new
|
|
12
|
+
> transport. It watches the filesystem and writes to `tmp/woods`. Don't run it
|
|
13
|
+
> in production; there is nothing to gain and a booted process to lose.
|
|
14
|
+
|
|
15
|
+
## Running it
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle exec rake woods:watch # alias: woods:guard
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
Watching /app, index at /app/tmp/woods
|
|
23
|
+
Ctrl-C to stop.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Environment variable | Default | Meaning |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `WOODS_OUTPUT` | `tmp/woods` | Index directory |
|
|
29
|
+
| `WOODS_WATCH_DEBOUNCE` | `0.4` | Seconds of quiet before a batch is considered settled |
|
|
30
|
+
| `WOODS_WATCH_FULL_THRESHOLD` | `50` | Actionable changed-file count above which a full extraction replaces incremental |
|
|
31
|
+
| `WOODS_WATCH_POLL` | unset | `1` forces the polling backend, set this inside a container watching a bind mount |
|
|
32
|
+
| `WOODS_WATCH_IDLE_TIMEOUT` | unset | Seconds of quiet after which a dormant daemon exits |
|
|
33
|
+
| `WOODS_WATCH_CATCH_UP` | `1` | `0` skips the startup reconciliation |
|
|
34
|
+
|
|
35
|
+
Run it under a supervisor. When boot-captured configuration changes the daemon
|
|
36
|
+
exits `75` (`EX_TEMPFAIL`) on purpose, see [Restart triggers](#restart-triggers).
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
# Procfile.dev
|
|
40
|
+
web: bin/rails server
|
|
41
|
+
woods: bundle exec rake woods:watch
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## One cycle
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
watch → debounce → classify → reload if needed → extract → publish
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Classify** is the step that matters. Extraction reads the *runtime*, `ActiveRecord::Base.descendants`, `Rails.application.routes`, resolved config,
|
|
51
|
+
callback chains on loaded classes, so "a file changed" and "re-reading it is
|
|
52
|
+
now worth anything" are different questions. `Woods::ReloadPolicy` answers the
|
|
53
|
+
second one; the table of path classes lives in
|
|
54
|
+
[INCREMENTAL_EXTRACTION.md](INCREMENTAL_EXTRACTION.md#what-a-change-actually-requires-reload-restart-or-neither).
|
|
55
|
+
|
|
56
|
+
**Publish** bumps `generation.json`, and only ever after a successful write.
|
|
57
|
+
A reader that sees generation N knows the files for N are already on disk, and
|
|
58
|
+
a run that failed leaves the number alone, so staleness stays honest.
|
|
59
|
+
|
|
60
|
+
## Restart triggers
|
|
61
|
+
|
|
62
|
+
Rails' reloader replaces autoloaded constants and nothing else. It does not
|
|
63
|
+
re-run initializers, re-resolve `Rails.application.config`, or rebuild the
|
|
64
|
+
schema cache, all of which Woods captures. Changes to dependency/Ruby selection
|
|
65
|
+
files (`Gemfile`, `Gemfile.lock`, `.ruby-version`), `.env*`, Rails
|
|
66
|
+
application/boot/environment files, initializers, environments, credentials,
|
|
67
|
+
database/schema files, `config/settings.yml`, `config/settings/*.yml`, or
|
|
68
|
+
boot-captured service config
|
|
69
|
+
(`config/{cable,storage,sidekiq,puma,cache,queue}.yml`, including `.yaml`)
|
|
70
|
+
make the daemon write a degraded status, stop, and exit `75` for a supervisor
|
|
71
|
+
to restart it. Scheduled-job YAML remains an in-process re-extraction input.
|
|
72
|
+
The exact matchers live in `lib/woods/reload_policy.rb`.
|
|
73
|
+
|
|
74
|
+
This is `rails/spring`'s contract, copied deliberately: Spring's staleness bugs
|
|
75
|
+
came from under-scoping exactly this set, so the boundary here is drawn on the
|
|
76
|
+
generous side.
|
|
77
|
+
|
|
78
|
+
The same escalation happens when the app *can't* reload at all, a boot with
|
|
79
|
+
`config.enable_reloading = false`. Extracting against constants that no longer
|
|
80
|
+
match their source would be worse than saying so.
|
|
81
|
+
|
|
82
|
+
## Failure posture
|
|
83
|
+
|
|
84
|
+
A syntax error mid-edit is normal; it happens every time someone saves halfway
|
|
85
|
+
through a thought. The daemon therefore never crash-loops and never publishes a
|
|
86
|
+
partial write:
|
|
87
|
+
|
|
88
|
+
| Failure | What happens |
|
|
89
|
+
|---|---|
|
|
90
|
+
| Reload raises (`SyntaxError`, `NameError`) | Degraded status naming the reason; index intact at generation N; retried on the next event |
|
|
91
|
+
| Extraction raises | Degraded status; generation not advanced |
|
|
92
|
+
| Payload directory can't be opened, over a payload-born index | Degraded status; generation not advanced. An incremental run only writes the units it touched, so there is no complete flat index it could fall back to publishing, see [Payload publishing](#payload-publishing) |
|
|
93
|
+
| Index written but the generation bump failed | Degraded status; paths carried forward. The extractor deliberately does not fail an otherwise-good extraction over an unwritable marker, but the marker *is* what readers refresh on, so the daemon cross-checks that the number moved rather than reporting `running` over an index nothing can see |
|
|
94
|
+
| Boot-captured config changed | Degraded status; daemon exits `75` |
|
|
95
|
+
| Watcher dies | Degraded status; daemon exits |
|
|
96
|
+
|
|
97
|
+
`tmp/woods/watch_status.json` carries the state:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{ "state": "degraded",
|
|
101
|
+
"reason": "SyntaxError: unexpected end-of-input",
|
|
102
|
+
"generation": 41,
|
|
103
|
+
"pid": 4821,
|
|
104
|
+
"updated_at": "2026-07-27T04:55:12Z" }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Three states, and the middle one is the point: `running` (current, or current
|
|
108
|
+
within a debounce window), `degraded` (alive but *cannot* update, index frozen
|
|
109
|
+
at a known generation, reason attached), `stopped` (nothing is maintaining this
|
|
110
|
+
index). A stale answer is only dangerous when nothing says so.
|
|
111
|
+
|
|
112
|
+
The file is written world-readable (0644) by design: host-side hooks read it
|
|
113
|
+
through a bind mount. Every other artifact Woods writes stays at 0600.
|
|
114
|
+
|
|
115
|
+
Note that `SyntaxError` is a `ScriptError`, not a `StandardError`. Rescuing
|
|
116
|
+
only the latter would let a half-typed file kill the daemon.
|
|
117
|
+
|
|
118
|
+
A cycle that fails to land its work never loses its paths. Lock contention, a
|
|
119
|
+
failed reload, and a raising extraction all carry the batch into `@pending`, and
|
|
120
|
+
the next cycle folds it back in, the files really did change, and no later
|
|
121
|
+
event will mention them again. The retry is not a tight loop: a degraded cycle
|
|
122
|
+
ends the drain and waits for the next event, because the cause needs an edit to
|
|
123
|
+
clear.
|
|
124
|
+
|
|
125
|
+
### The heartbeat
|
|
126
|
+
|
|
127
|
+
`alive?` disbelieves a record older than `STALE_AFTER` (15 minutes), and cycle
|
|
128
|
+
boundaries are otherwise the only thing that writes one. So the daemon re-stamps
|
|
129
|
+
its record every `HEARTBEAT_INTERVAL` (a third of the window). Without it a
|
|
130
|
+
perfectly healthy daemon reads as dead after a quiet quarter-hour, the most
|
|
131
|
+
common state for a worktree nobody is typing in, and every caller that stands
|
|
132
|
+
down for a live daemon starts contending with it instead.
|
|
133
|
+
|
|
134
|
+
The heartbeat republishes the **last** state, not `running`. A degraded daemon
|
|
135
|
+
is still degraded between events, and saying otherwise is the one thing this
|
|
136
|
+
file exists to prevent.
|
|
137
|
+
|
|
138
|
+
The same tick is also when carried-forward paths get retried, but the retry
|
|
139
|
+
drain runs on **its own thread**, not the heartbeat's. Running it inline meant a
|
|
140
|
+
retried storm (`extract_all` on a large host) stopped the re-stamping and the
|
|
141
|
+
`PipelineLock` touch for its whole duration: past `LOCK_STALE_TIMEOUT` (600 s)
|
|
142
|
+
any waiting writer retires the live lock and two writers clobber one index, and
|
|
143
|
+
past `STALE_AFTER` (900 s) `woods:incremental` stops standing down at the same
|
|
144
|
+
moment. `drain`'s own `try_lock` still refuses overlapping drains, so at most
|
|
145
|
+
one retry is ever in flight.
|
|
146
|
+
|
|
147
|
+
## Startup is not a clean slate
|
|
148
|
+
|
|
149
|
+
A daemon that only reacts to events it personally witnessed is stale the moment
|
|
150
|
+
it starts: edits and pulled commits that landed while nothing was watching are
|
|
151
|
+
invisible to it forever. That matters because callers stand down when a daemon
|
|
152
|
+
is alive, so *alive has to mean covered*.
|
|
153
|
+
|
|
154
|
+
So `run` reconciles before it waits. The watermark is `generation.json`'s mtime, written last on every successful run, so it means "when this index was last
|
|
155
|
+
known good", and everything modified since is uncovered, whoever changed it.
|
|
156
|
+
With no generation file there is no index, every file is uncovered, and the
|
|
157
|
+
storm threshold correctly turns that into one full extraction. A marker whose
|
|
158
|
+
payload pointer no longer resolves counts as no index too: the marker can
|
|
159
|
+
outlive the directory it names (a partial restore from a CI artifact, an
|
|
160
|
+
external cleanup targeting the large directories), and readers deliberately
|
|
161
|
+
degrade a dangling pointer to the index root, so trusting the mtime there would
|
|
162
|
+
report "current at startup" over a directory holding nothing.
|
|
163
|
+
|
|
164
|
+
**The watcher thread starts before this reconciliation runs, not after.** A
|
|
165
|
+
file saved while catch-up's own extraction is still in flight (which can take
|
|
166
|
+
minutes on a storm-triggered full run) used to be lost twice: no watcher
|
|
167
|
+
existed yet to see it, and the polling watcher takes its baseline snapshot
|
|
168
|
+
inside `start`, after the save, so its first diff already excluded it. Worse,
|
|
169
|
+
the save's mtime predates the generation bump catch-up publishes at the end, so
|
|
170
|
+
a future restart's watermark check would read the file as already covered,
|
|
171
|
+
permanently. Starting the watcher first closes that window; `enqueue`/`drain`
|
|
172
|
+
already tolerate the duplicate paths this produces against whatever catch-up
|
|
173
|
+
finds on its own via the tree scan.
|
|
174
|
+
|
|
175
|
+
Deletions need one extra step, because a deleted file leaves no mtime to scan:
|
|
176
|
+
if any path the index attributes a unit to is gone from disk, the daemon runs
|
|
177
|
+
one cycle with an *empty* change set, which reaches the ghost units through the
|
|
178
|
+
extractor's bounded deletion sweep. Deliberately empty, naming the paths would
|
|
179
|
+
make the deletions authoritative for every unit type, and some registered paths
|
|
180
|
+
are nominal (on Rails < 7.1, `ActiveRecord::SchemaMigration` registers a
|
|
181
|
+
convention path no app has), which authoritative deletion would wrongly remove.
|
|
182
|
+
The sweep carries the bounds that make reconciliation safe; the daemon only
|
|
183
|
+
supplies the trigger.
|
|
184
|
+
|
|
185
|
+
This is what makes the documented hook pattern safe:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
bundle exec rake woods:watch_status || start_the_daemon
|
|
189
|
+
bundle exec rake woods:incremental # stands down, the daemon has these
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Without the catch-up, the sync exits 0 while the changes that prompted it never
|
|
193
|
+
reach the index. `woods:incremental` still runs when the daemon is *degraded*:
|
|
194
|
+
alive but not updating is not coverage.
|
|
195
|
+
|
|
196
|
+
## Storms
|
|
197
|
+
|
|
198
|
+
A branch switch or rebase touches hundreds of files at once. Above
|
|
199
|
+
`WOODS_WATCH_FULL_THRESHOLD`, N incremental steps cost more than one full
|
|
200
|
+
extraction and risk interleaving with a still-settling tree, so the daemon
|
|
201
|
+
falls back to a full run and logs that it did.
|
|
202
|
+
|
|
203
|
+
## Watcher backends
|
|
204
|
+
|
|
205
|
+
| Backend | When | Trade-off |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| `listen` gem | Used automatically when the host has it | Native FS events; low latency, no idle CPU |
|
|
208
|
+
| Polling | Fallback; no dependency | Costs a scan per interval, but works across container bind mounts |
|
|
209
|
+
|
|
210
|
+
The fallback is not a consolation prize. Native FS events **do not propagate
|
|
211
|
+
reliably across container bind mounts**, `listen` documents this, and macOS
|
|
212
|
+
Docker VMs are the usual casualty. Since extraction typically runs inside a dev
|
|
213
|
+
container with the source bind-mounted, a host in that position should force
|
|
214
|
+
polling rather than trust a watcher that may sit silent while files change
|
|
215
|
+
under it:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
WOODS_WATCH_POLL=1 bundle exec rake woods:watch
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Selection is also self-correcting at runtime. If `listen` cannot start at all, inotify watch exhaustion (`ENOSPC`) is the usual reason on a large tree, the
|
|
222
|
+
daemon logs it and falls back to polling rather than exiting, because a daemon
|
|
223
|
+
costing some CPU beats one that never fires. Failures *after* startup are not
|
|
224
|
+
treated as backend failures: the rescue covers only the setup, so an error
|
|
225
|
+
raised by the extraction inside a callback surfaces as itself.
|
|
226
|
+
|
|
227
|
+
Polling compares `[mtime, size]` at full float resolution. Truncating mtime to
|
|
228
|
+
whole seconds loses a second write inside the same second permanently, there is
|
|
229
|
+
no later event to catch it, and save-then-formatter at a 1s interval is
|
|
230
|
+
entirely ordinary. Size is the tiebreaker for filesystems that really do offer
|
|
231
|
+
only whole seconds.
|
|
232
|
+
|
|
233
|
+
Ignored by default: `.git`, `node_modules`, `tmp`, `log`, `coverage`,
|
|
234
|
+
`vendor/bundle`, `public/assets`, `public/packs`, `storage`. That ignore list is
|
|
235
|
+
what keeps a polling scan bounded.
|
|
236
|
+
|
|
237
|
+
## Placement
|
|
238
|
+
|
|
239
|
+
The spike asked for three placements to be compared and one chosen. Every
|
|
240
|
+
collaborator on `Woods::Watch::Daemon` is injected, so all three are reachable
|
|
241
|
+
from the same class, but the default is **(b), a dedicated daemon per
|
|
242
|
+
worktree**:
|
|
243
|
+
|
|
244
|
+
| Option | Verdict |
|
|
245
|
+
|---|---|
|
|
246
|
+
| **(b) Dedicated daemon**: *recommended default* | One extra booted app per worktree. Isolated: a crash, a restart, or a storm affects only the index. Lifecycle is drivable from worktree hooks. |
|
|
247
|
+
| **(a) Embedded in the dev server** via the Railtie | Marginal memory cost ~0 where a booted app already exists, but couples index freshness to the dev server running and puts extraction on its threads. `Daemon#process` is public precisely so a host can do this deliberately. |
|
|
248
|
+
| **(c) Host watcher + in-container session** | Solves bind-mount event unreliability, but with the most moving parts. Forcing the polling backend solves the same problem with none. |
|
|
249
|
+
|
|
250
|
+
Measured on the fixture app (Ruby 3.3, Rails 8.0):
|
|
251
|
+
|
|
252
|
+
| Measurement | Value |
|
|
253
|
+
|---|---|
|
|
254
|
+
| Ruby baseline RSS | 27.8 MB |
|
|
255
|
+
| + booted Rails app | 64.9 MB (+37.1) |
|
|
256
|
+
| + Woods daemon on top | 72.1 MB (+7.2) |
|
|
257
|
+
| Single-file cycle | 48–81 ms (p95 81 ms) |
|
|
258
|
+
| 8-file storm → full extraction | 227 ms |
|
|
259
|
+
|
|
260
|
+
The daemon's own footprint is small; the cost of option (b) is the booted app,
|
|
261
|
+
not Woods. That is why (a) is worth keeping available for hosts that already
|
|
262
|
+
pay for one.
|
|
263
|
+
|
|
264
|
+
#### Where a full extraction of the fixture app spends its time
|
|
265
|
+
|
|
266
|
+
One cold `extract_all` over `spec/dummy`, 147 units of which 119 are framework
|
|
267
|
+
sources, 7 repetitions, Ruby 4.0.6 / Rails 8.0.5.1, phase timers around the
|
|
268
|
+
orchestrator's own methods. Total **190 ms** at p50.
|
|
269
|
+
|
|
270
|
+
Every share below is that phase's milliseconds over the 190 ms total, so the
|
|
271
|
+
top-level rows add up to the total. Indented rows break their parent down and
|
|
272
|
+
are already counted in it.
|
|
273
|
+
|
|
274
|
+
| Phase | ms | share of 190 ms |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| extraction | 115 | 60.5% |
|
|
277
|
+
| of which `RailsSourceExtractor` | 100 | 52.6% |
|
|
278
|
+
| of which `ModelExtractor` | 4.4 | 2.3% |
|
|
279
|
+
| of which every other extractor | 10 | 5.3% |
|
|
280
|
+
| `write_results` | 23 | 12.1% |
|
|
281
|
+
| git enrichment | 22 | 11.6% |
|
|
282
|
+
| graph analysis (`GraphAnalyzer#analyze`) | 6.1 | 3.2% |
|
|
283
|
+
| of which PageRank | 3.9 | 2.1% |
|
|
284
|
+
| manifest, graph and analysis writes | 6.9 | 3.6% |
|
|
285
|
+
| orphan sweep | 2.6 | 1.4% |
|
|
286
|
+
| dedupe, package annotation, dependents, path normalisation, publish | 0.6 | 0.3% |
|
|
287
|
+
| not attributed to a timed phase | 13.8 | 7.3% |
|
|
288
|
+
| **total** | **190** | **100%** |
|
|
289
|
+
|
|
290
|
+
The unattributed row is the orchestration between the timed phases: output
|
|
291
|
+
directory setup, the `ModelNameCache` reset, rebuilding the graph from the
|
|
292
|
+
deduped results, and the payload bookkeeping. It is named rather than dropped so
|
|
293
|
+
the column is a real accounting.
|
|
294
|
+
|
|
295
|
+
**The graph layers are not where the time goes.** PageRank moving inside
|
|
296
|
+
`analyze`, and the three reports added beside it, come to 3.2% of the run
|
|
297
|
+
together. Git enrichment, the other suspect, is 11.6%: real, but not a phase to
|
|
298
|
+
rewrite. The dependents pass and path normalisation are below a millisecond
|
|
299
|
+
each.
|
|
300
|
+
|
|
301
|
+
One phase clears 15%: `RailsSourceExtractor`, at 52.6%. Read it with the
|
|
302
|
+
fixture's shape, though. 119 of 147 units *are* framework sources here, so this
|
|
303
|
+
figure is a property of a fixture app with almost no application code, not a
|
|
304
|
+
finding about a real host. Turning it off is one flag
|
|
305
|
+
(`include_framework_sources`), and it does not touch the incremental path at
|
|
306
|
+
all. Filed as B-187 rather than acted on here: sizing it needs a host where
|
|
307
|
+
framework sources are the minority.
|
|
308
|
+
|
|
309
|
+
### Measured at scale
|
|
310
|
+
|
|
311
|
+
The numbers above are fixture-app numbers. Below are the same measurements on a
|
|
312
|
+
**1,940-unit app**: `apps/rails-8.0-large` in
|
|
313
|
+
[woods-testbed](https://github.com/lost-in-the/woods-testbed), a hand-written
|
|
314
|
+
kernel covering all 35 extractors plus a deterministically generated tree, run by
|
|
315
|
+
`scripts/woods_bench.rb` in woods-testbed (Ruby 3.3.1 / Rails 8.0.5, in-container, 5 reps per
|
|
316
|
+
scenario). See [woods-testbed#2](https://github.com/lost-in-the/woods-testbed/issues/2).
|
|
317
|
+
|
|
318
|
+
Cold full extraction: **5,541 ms**, and the phase split is the surprise:
|
|
319
|
+
|
|
320
|
+
| Phase | ms | share |
|
|
321
|
+
|---|---|---|
|
|
322
|
+
| `write_and_publish` | 2,925 | 53% |
|
|
323
|
+
| extraction | 2,556 | 46% |
|
|
324
|
+
| graph analysis (PageRank + structural) | 27.8 | 0.5% |
|
|
325
|
+
| dedupe | 12.5 | 0.2% |
|
|
326
|
+
| git enrichment | 10.6 | 0.2% |
|
|
327
|
+
| path normalisation | 4.6 | 0.1% |
|
|
328
|
+
| dependents resolution | 4.3 | 0.1% |
|
|
329
|
+
|
|
330
|
+
**PageRank and the dependents pass do not dominate.** Together they are 32 ms of
|
|
331
|
+
5,541, six tenths of one percent. The cost is extraction itself plus *writing
|
|
332
|
+
the output*, and the latter is dominated by `AtomicFile`'s fsync per unit file.
|
|
333
|
+
Anyone optimising the graph passes here would be tuning 0.5% of the runtime; the
|
|
334
|
+
lever is the write path.
|
|
335
|
+
|
|
336
|
+
Incremental, per scenario, with the units each change causes to be rewritten:
|
|
337
|
+
|
|
338
|
+
| Change | p50 | p95 | Units written | % of index |
|
|
339
|
+
|---|---|---|---|---|
|
|
340
|
+
| a controller | 274 ms | 341 ms | 6 | 0.3% |
|
|
341
|
+
| a model | 402 ms | 451 ms | 38 | 2.0% |
|
|
342
|
+
| **`config/routes.rb`** | **2,534 ms** | 2,830 ms | **1,036** | **53.4%** |
|
|
343
|
+
| `db/schema.rb` | 107 ms | 134 ms | 0 | 0.0% |
|
|
344
|
+
|
|
345
|
+
The routes row is the wholesale re-run of `ROUTE_CONSUMER_EXTRACTORS`. Read the
|
|
346
|
+
**shape** alongside the size: 53.4% is higher than the ~24% measured on a
|
|
347
|
+
production host, because the testbed's generated tree is deliberately dense in
|
|
348
|
+
controllers and view templates, exactly the route-consumer types. A real app
|
|
349
|
+
with more models per controller sits lower. Any figure quoted from that variant
|
|
350
|
+
therefore carries its scale *and* its composition, which is why the harness
|
|
351
|
+
embeds the generator manifest in every result.
|
|
352
|
+
|
|
353
|
+
`db/schema.rb` writing zero units is correct, not a gap: `ReloadPolicy`
|
|
354
|
+
classifies it `:restart`, and a plain `extract_changed` touches nothing because
|
|
355
|
+
the models are class-based and their constants have not changed.
|
|
356
|
+
|
|
357
|
+
**Still not measured:** event latency across a **macOS** Docker Desktop bind
|
|
358
|
+
mount. A Linux bind mount measures 723–824 ms from write to generation bump, but
|
|
359
|
+
osxfs/gRPC-FUSE is the behaviour actually in question and needs a macOS host, so that gap stays open rather than being closed with a Linux number.
|
|
360
|
+
|
|
361
|
+
## The freshness contract
|
|
362
|
+
|
|
363
|
+
A daemon that keeps the index current is only half the problem. The other half
|
|
364
|
+
is a reader that notices.
|
|
365
|
+
|
|
366
|
+
### Generations
|
|
367
|
+
|
|
368
|
+
Every extraction mode, full, incremental, targeted refresh, daemon cycle, writes `tmp/woods/generation.json` as its **last** action:
|
|
369
|
+
|
|
370
|
+
```json
|
|
371
|
+
{ "number": 42, "token": "9f2c81ad3e4b7c05", "updated_at": "2026-07-27T04:55:12Z", "reason": "incremental" }
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Two properties, both from the same rule, never advance a cursor over work that
|
|
375
|
+
didn't land:
|
|
376
|
+
|
|
377
|
+
- **Bumped last**, so a reader that sees generation N knows N's files are
|
|
378
|
+
already on disk.
|
|
379
|
+
- **Not bumped on failure or on a no-op run**, so staleness stays honest.
|
|
380
|
+
|
|
381
|
+
`IndexReader` checks it at the top of every read: one `File.stat` of a
|
|
382
|
+
~100-byte file, with caches dropped only when the generation number actually
|
|
383
|
+
advanced. The stat signature is `[mtime, size, inode]`, the inode is
|
|
384
|
+
load-bearing, because two same-second bumps with an identical payload length
|
|
385
|
+
are the daemon's steady state and `[mtime, size]` alone cannot tell them apart
|
|
386
|
+
on a coarse-mtime filesystem. Since `AtomicFile` renames a fresh tempfile on
|
|
387
|
+
every publish the inode always moves, so in practice the file is re-parsed once
|
|
388
|
+
per publish; the saving is on the reads *between* publishes, which is the
|
|
389
|
+
common case. That makes the MCP `reload` tool an
|
|
390
|
+
*optimization* rather than a correctness requirement, previously a long-lived
|
|
391
|
+
server held whatever it read at boot, so an agent working alongside a running
|
|
392
|
+
extraction silently got answers describing the tree as of the last server
|
|
393
|
+
start.
|
|
394
|
+
|
|
395
|
+
An index with no generation file (written before this existed, or by a third
|
|
396
|
+
party) behaves exactly as it always did.
|
|
397
|
+
|
|
398
|
+
### `woods_status`
|
|
399
|
+
|
|
400
|
+
```jsonc
|
|
401
|
+
{ "index": {
|
|
402
|
+
"generation": 42,
|
|
403
|
+
"generation_reason": "incremental",
|
|
404
|
+
"generation_updated_at": "2026-07-27T04:55:12Z",
|
|
405
|
+
"git_sha_matches_head": true,
|
|
406
|
+
"working_tree_dirty": true, // git_sha_matches_head only sees committed HEAD
|
|
407
|
+
"working_tree_fingerprint": "3f9a2c81ad3e4b7c",
|
|
408
|
+
"staleness_seconds": 12
|
|
409
|
+
},
|
|
410
|
+
"watch": {
|
|
411
|
+
"state": "degraded", // running | degraded | stopped | absent
|
|
412
|
+
"reason": "SyntaxError: unexpected end-of-input",
|
|
413
|
+
"generation": 41
|
|
414
|
+
} }
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
`working_tree_dirty` closes a real hole: an agent forty uncommitted edits deep
|
|
418
|
+
was told the index matched HEAD while every answer described the tree before
|
|
419
|
+
those edits.
|
|
420
|
+
|
|
421
|
+
The fingerprint (a digest of `git status --porcelain`) is *as of the call*.
|
|
422
|
+
Nothing records the digest the index was built at, so it cannot tell you "this
|
|
423
|
+
is the same dirty state the index describes", it gives a stable identity for
|
|
424
|
+
the current dirty state, so two of your own calls can be compared to detect the
|
|
425
|
+
tree moving underneath you. Pair it with `generation` to distinguish "tree
|
|
426
|
+
changed and the index followed" from "tree changed and the index has not caught
|
|
427
|
+
up".
|
|
428
|
+
|
|
429
|
+
### Multi-file read consistency
|
|
430
|
+
|
|
431
|
+
The index is a directory, not a file, so "read the index" is many reads. Two
|
|
432
|
+
options were on the table.
|
|
433
|
+
|
|
434
|
+
**Per-request generation re-check, implemented.** Each read checks the
|
|
435
|
+
generation first, so an *unpinned* read never serves from a cache older than
|
|
436
|
+
what is published. `IndexReader#with_pinned_generation` extends that across a
|
|
437
|
+
sequence: freshness is checked once on entry and then held, so nothing already
|
|
438
|
+
cached is dropped and re-read at a newer generation partway through. `warmup!`
|
|
439
|
+
uses it.
|
|
440
|
+
|
|
441
|
+
The pin is the deliberate exception to the sentence above, and it is reader-wide
|
|
442
|
+
rather than per-request: while any pin is held, `refresh_if_stale` returns early
|
|
443
|
+
and *every* read on that reader, including ones outside the pinned block, under
|
|
444
|
+
a threaded transport, is served at the pinned generation. Pins are refcounted,
|
|
445
|
+
so invalidation resumes when the last one releases. Consistency within a
|
|
446
|
+
sequence is bought with bounded staleness across concurrent ones; for a
|
|
447
|
+
development-time index that is the right side of the trade, but it is a trade.
|
|
448
|
+
|
|
449
|
+
Its documented limit: pinning suppresses invalidation, it does not snapshot. An
|
|
450
|
+
artifact never read before is still loaded from disk as it stands when the
|
|
451
|
+
block reaches it. Guaranteeing more would mean materializing the whole index on
|
|
452
|
+
entry, which is what `warmup!` costs, per request.
|
|
453
|
+
|
|
454
|
+
### Payload publishing
|
|
455
|
+
|
|
456
|
+
**Atomic pointer over the whole payload, adopted.** Every writer now
|
|
457
|
+
publishes into `payloads/gen-<N>/` (`Woods::PayloadStore`) instead of
|
|
458
|
+
`tmp/woods/models/…` directly, and `generation.json` carries a `payload`
|
|
459
|
+
pointer naming which directory the current generation lives in
|
|
460
|
+
(`Woods::Generation#payload_dir`). A reader resolves every artifact through
|
|
461
|
+
that one pointer, so a single atomic write of `generation.json` is the commit
|
|
462
|
+
point for the whole payload, no reader can see a manifest from generation
|
|
463
|
+
N+1 next to a unit from N. An index written before this existed (or a
|
|
464
|
+
third-party writer that still writes flat) has no `payload` key, and every
|
|
465
|
+
reader falls back to the index root unchanged.
|
|
466
|
+
|
|
467
|
+
Retention does not invalidate an in-flight read. While
|
|
468
|
+
`IndexReader#with_pinned_generation` serves a payload, it holds a shared
|
|
469
|
+
advisory lock on that generation's `manifest.json`; pruning takes an exclusive
|
|
470
|
+
non-blocking lock on the same file and skips a busy generation. This works
|
|
471
|
+
across Index MCP and extraction processes without reader-created lease files,
|
|
472
|
+
so read-only index mounts remain sufficient for ordinary tools and a crashed
|
|
473
|
+
reader leaves no stale lease. A skipped directory can temporarily exceed
|
|
474
|
+
`WOODS_PAYLOAD_RETENTION` and is reconsidered by the next successful publish.
|
|
475
|
+
The protocol relies on the same filesystem advisory-lock support as Woods'
|
|
476
|
+
pipeline coordination.
|
|
477
|
+
|
|
478
|
+
A **full** extraction (`Extractor#extract_all`) degrades to a flat publish if
|
|
479
|
+
it can't open a fresh payload directory, the write set is the whole app, so a
|
|
480
|
+
flat publish is still a complete index. An **incremental** run
|
|
481
|
+
(`extract_changed` / `refresh`) writes only the units it touched, so there is
|
|
482
|
+
no complete flat index it could fall back to: over a payload-born index it
|
|
483
|
+
raises `Woods::ExtractionError` instead of publishing a corrupt-looking
|
|
484
|
+
mixture. The generation is never bumped over a raised run, so readers keep
|
|
485
|
+
serving the last good index. See `Extractor#begin_payload!(strict:)`.
|
|
486
|
+
|
|
487
|
+
### MCP `resources/updated`: evaluated, not implemented
|
|
488
|
+
|
|
489
|
+
The MCP spec supports server-initiated `notifications/resources/updated`, and
|
|
490
|
+
#164 asked whether it is worth adding as a push channel. It is not, yet:
|
|
491
|
+
|
|
492
|
+
- The `mcp` gem gives the server `notify_resources_list_changed` but no
|
|
493
|
+
`notify_resources_updated`, and gates the method behind a
|
|
494
|
+
`resources.subscribe` capability with no handler hooks for
|
|
495
|
+
`resources/subscribe` / `unsubscribe`.
|
|
496
|
+
- The index server runs over stdio as a request/response loop. Pushing would
|
|
497
|
+
mean writing unsolicited frames from a background thread while the main loop
|
|
498
|
+
reads stdin.
|
|
499
|
+
- Client support is not something we could depend on anyway, so it would be
|
|
500
|
+
strictly additive on top of a reader-side check that already delivers the
|
|
501
|
+
correctness property for every client.
|
|
502
|
+
|
|
503
|
+
The reader-side generation check is the robust answer, and it is the one
|
|
504
|
+
implemented. Revisit if the gem grows the server-side API and a client we care
|
|
505
|
+
about acts on it.
|
|
506
|
+
|
|
507
|
+
## Multiple worktrees
|
|
508
|
+
|
|
509
|
+
The topology to design for: a worktree manager provisions a canonical checkout
|
|
510
|
+
plus N agent slots (commonly ~5), each an independent `Rails.root` with its own
|
|
511
|
+
container stack and its own `tmp/woods`, while several sessions run
|
|
512
|
+
concurrently, sometimes sharing a worktree.
|
|
513
|
+
|
|
514
|
+
### Disjointness is structural
|
|
515
|
+
|
|
516
|
+
Per-worktree output directories mean daemons never contend *across* worktrees.
|
|
517
|
+
There is deliberately **no** shared cross-worktree index and **no** daemon
|
|
518
|
+
multiplexing several worktrees from one process: the single-active-project
|
|
519
|
+
failure mode of stateful multiplexed servers is well documented in adjacent
|
|
520
|
+
tools, and disjoint-by-construction is what makes this design safe.
|
|
521
|
+
|
|
522
|
+
### Within one worktree, writers serialize
|
|
523
|
+
|
|
524
|
+
Three writers can want the same index: the daemon, a manual `woods:extract`,
|
|
525
|
+
and a hook-triggered `woods:incremental`. They share the existing file-based
|
|
526
|
+
`PipelineLock`, and the policy is:
|
|
527
|
+
|
|
528
|
+
| Situation | Behaviour |
|
|
529
|
+
|---|---|
|
|
530
|
+
| Daemon cycle while another writer holds the lock | Daemon yields, publishes a `contended` degraded status, and **carries its paths into the next cycle** so nothing is lost |
|
|
531
|
+
| Manual `woods:extract` / `woods:incremental` / `woods:refresh` (including `woods:extract_framework`) | Waits up to `LOCK_STALE_TIMEOUT` (600 s; override with `WOODS_LOCK_WAIT`) for the lock, then **exits non-zero** rather than proceeding unlocked. It also exits non-zero with a typed error when the final generation marker cannot be published; the previous generation remains readable. A storm-triggered `extract_all` can hold the lock for minutes on a large host, and two concurrent writers rewrite the dependency graph from divergent copies, so the loser's work is silently discarded under a generation that says "fresh" |
|
|
532
|
+
| Hook sync on a tree a daemon is already watching | Skips entirely: the daemon has already seen those changes. `WOODS_IGNORE_WATCH=1` overrides |
|
|
533
|
+
|
|
534
|
+
A hook can check cheaply:
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
bundle exec rake woods:watch_status || start_the_daemon # exit 0 = alive
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
The check does not boot Rails. Without `WOODS_OUTPUT`, it resolves
|
|
541
|
+
`tmp/woods/watch_status.json` relative to the active Rakefile, not the
|
|
542
|
+
launcher's current directory, so `rake -f /app/Rakefile woods:watch_status`
|
|
543
|
+
and worktree-manager invocations inspect the same per-app status. Set
|
|
544
|
+
`WOODS_OUTPUT` when the daemon uses a non-default index directory.
|
|
545
|
+
|
|
546
|
+
Liveness needs three things to agree, each ruling out a different way the
|
|
547
|
+
status file lies: a state a live daemon writes, a pid that still exists (a
|
|
548
|
+
`kill -9` leaves the file behind), and a recent timestamp (a machine that lost
|
|
549
|
+
power leaves a `running` record whose pid some unrelated process now owns).
|
|
550
|
+
|
|
551
|
+
One known limit: the pid check sees only the caller's own pid namespace. In the
|
|
552
|
+
Docker layout, daemon in the container, output volume-mounted to the host, a
|
|
553
|
+
host-side `watch_status` tests a host pid that has nothing to do with the
|
|
554
|
+
containerized daemon, so it can misread liveness in either direction for up to
|
|
555
|
+
`STALE_AFTER` (the timestamp check still bounds it, and the heartbeat keeps a
|
|
556
|
+
live daemon inside that bound). Run `watch_status` on the same side as the
|
|
557
|
+
daemon; a cross-namespace liveness protocol isn't worth its complexity here.
|
|
558
|
+
|
|
559
|
+
### Hooks for agent sessions
|
|
560
|
+
|
|
561
|
+
The daemon covers a human's editor session. A `claude -p` run in a worktree
|
|
562
|
+
with no daemon needs a different trigger, so the Woods plugin ships two
|
|
563
|
+
hooks (`plugin/hooks/hooks.json`), both shipped disabled:
|
|
564
|
+
|
|
565
|
+
| Hook | When | What it does |
|
|
566
|
+
|---|---|---|
|
|
567
|
+
| `PostToolUse` (`Edit`, `Write`, `MultiEdit`), async | An edit under `app/models`, `config/routes*`, `db/migrate`, `db/*_migrate`, `db/schema.rb`, `db/structure.sql`, or any `package.yml` / `packwerk.yml` | Appends the path to `hook-pending.txt` under a lock, then runs `CHANGED_FILES=<paths> woods:incremental` for whatever is pending, output to `hook.log` |
|
|
568
|
+
| `SessionStart` (`startup`, `resume`) | Session begins | Prints a warning when `generation.json`'s `updated_at` predates `git log -1` |
|
|
569
|
+
|
|
570
|
+
Both read `cwd` from the hook payload, not `CLAUDE_PROJECT_DIR`, which stays
|
|
571
|
+
at the launch root inside a worktree. Both do nothing until
|
|
572
|
+
`tmp/woods/generation.json` exists, and neither runs at all until
|
|
573
|
+
`WOODS_HOOKS_ENABLED=1` is set; `WOODS_HOOKS_DISABLED=1` turns them back off
|
|
574
|
+
without touching that setting. `woods:incremental` still stands down under a
|
|
575
|
+
`:running` daemon, so a hook and a daemon on the same worktree never
|
|
576
|
+
contend. `WOODS_HOOK_RAKE` sets the command prefix (Docker:
|
|
577
|
+
`docker compose exec -T app bundle exec rake`); `WOODS_OUTPUT` points the
|
|
578
|
+
hooks at a non-default index directory, the same variable
|
|
579
|
+
`woods:incremental`/`woods:watch_status` already read.
|
|
580
|
+
|
|
581
|
+
A hook invocation that finds another one already draining the pending file
|
|
582
|
+
does not wait for it: it appends its own path and returns, and the
|
|
583
|
+
in-progress drainer picks that path up on its next pass, looping until a
|
|
584
|
+
drain comes back empty. The one gap this leaves is an append that lands
|
|
585
|
+
between the drainer's last (empty) drain and its releasing the lock: that
|
|
586
|
+
edit is delayed to the next graph-changing edit rather than lost outright,
|
|
587
|
+
and the `SessionStart` warning is the backstop for it.
|
|
588
|
+
|
|
589
|
+
On a host without `flock`, the mkdir-based fallback lock has no kernel-enforced
|
|
590
|
+
release, so a hook killed mid-drain would otherwise leave a lock directory
|
|
591
|
+
behind forever; each lock directory is reclaimed once its mtime is older than
|
|
592
|
+
`WOODS_HOOK_LOCK_STALE_SECONDS` (default 1800), while a fresh one is still
|
|
593
|
+
respected as busy.
|
|
594
|
+
The age check needs `stat`; on a host with neither `flock` nor `stat`, a crashed
|
|
595
|
+
pending-lock holder can still make the next hook wait until the hook timeout.
|
|
596
|
+
|
|
597
|
+
The `SessionStart` warning compares two commit-adjacent timestamps only:
|
|
598
|
+
the generation's `updated_at` against the last commit's time. It says
|
|
599
|
+
nothing about uncommitted changes in the working tree, and a checkout
|
|
600
|
+
sitting on an older commit than the one that produced the generation can
|
|
601
|
+
still read as fresh under this check. Treat a quiet session start as "not
|
|
602
|
+
behind the last commit," not as a general freshness guarantee.
|
|
603
|
+
|
|
604
|
+
### Reader multiplicity is free
|
|
605
|
+
|
|
606
|
+
Several sessions in one worktree each spawn their own stdio `woods-mcp`. With
|
|
607
|
+
the generation check they converge on fresh data with no coordination and no
|
|
608
|
+
shared server, which is the property worth protecting, since a persisted
|
|
609
|
+
index served by many cheap readers is exactly what Woods has that a
|
|
610
|
+
per-process language-server index does not.
|
|
611
|
+
|
|
612
|
+
### Idle TTL
|
|
613
|
+
|
|
614
|
+
N resident daemons is N booted apps, and most slots are dormant most of the
|
|
615
|
+
time. `idle_timeout` (off by default) stops a daemon after that many seconds
|
|
616
|
+
without a file event, so a slot nobody is working in stops holding ~65 MB. A
|
|
617
|
+
worktree hook or session start revives it.
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
Woods::Watch::Daemon.new(output_dir: …, idle_timeout: 900).run # 15 minutes
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Off by default because a single-worktree host wants the daemon to stay up.
|
|
624
|
+
|
|
625
|
+
### What is verified, and what isn't
|
|
626
|
+
|
|
627
|
+
| Property | Where |
|
|
628
|
+
|---|---|
|
|
629
|
+
| A real file write reaches `extract_changed`, watcher thread, callback, debounce and drain loop end to end | `spec/watch/watcher_integration_spec.rb` |
|
|
630
|
+
| A burst coalesces into one extraction; a same-second rewrite is not lost; a `stop` racing startup is honoured | same |
|
|
631
|
+
| A real `Rails.application.reloader` picks up changed source, under the interlock unload lock | `spec/integration/watch_daemon_spec.rb` |
|
|
632
|
+
| Concurrent cycles serialize; no deadlock; no orphaned lock, even when extraction raises | `spec/watch/multi_instance_spec.rb` |
|
|
633
|
+
| Contended cycle carries its paths forward | same |
|
|
634
|
+
| Idle TTL exits and records why | same |
|
|
635
|
+
| Six real worktrees stay disjoint, validate-green, independently versioned | `spec/integration/multi_worktree_spec.rb` |
|
|
636
|
+
| Many concurrent readers per worktree converge without coordination | same |
|
|
637
|
+
|
|
638
|
+
**Per-daemon memory at six-worktree scale, measured.** `Rails.root` is a
|
|
639
|
+
process singleton, so six *concurrently extracting* booted apps cannot exist in
|
|
640
|
+
one Ruby process. That constraint is about processes, not containers, so six
|
|
641
|
+
forks with disjoint output directories satisfy it, `scripts/woods_daemon_scale_smoke.rb` in woods-testbed does exactly that:
|
|
642
|
+
|
|
643
|
+
| | |
|
|
644
|
+
|---|---|
|
|
645
|
+
| Parent booted app, no extraction | 83.3 MB |
|
|
646
|
+
| Per worktree after two full extractions | 165.9 – 166.3 MB (mean **166.0**) |
|
|
647
|
+
| Summed across six | 996.2 MB |
|
|
648
|
+
|
|
649
|
+
**The 72.1 MB figure above is a fixture-app number; at 1,940 units it is 166 MB, 2.3× that.** Plan for the measured figure, not the extrapolation.
|
|
650
|
+
|
|
651
|
+
Two caveats the harness prints itself: forks share the parent heap
|
|
652
|
+
copy-on-write, so the 996 MB sum is an upper bound rather than true additional
|
|
653
|
+
memory and the mean is the per-daemon figure; and this measures repeated
|
|
654
|
+
`Extractor` cycles, so it is the extraction footprint rather than the idle
|
|
655
|
+
steady state a dormant daemon holds.
|
|
656
|
+
|
|
657
|
+
## Embedding it
|
|
658
|
+
|
|
659
|
+
```ruby
|
|
660
|
+
# A host that owns its own event loop
|
|
661
|
+
daemon = Woods::Watch::Daemon.new(output_dir: Rails.root.join("tmp/woods"))
|
|
662
|
+
result = daemon.process(changed_paths)
|
|
663
|
+
# => { action: :incremental, state: :running, generation: 42, count: 1, duration_ms: 61 }
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
`#process` is one whole cycle and is the supported embedding point. `#run` only
|
|
667
|
+
supplies batches to it.
|