scout-essentials 1.9.1 → 1.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +18 -1
  3. data/Rakefile +9 -0
  4. data/VERSION +1 -1
  5. data/doc/Improvements.md +20 -0
  6. data/doc/StartHere.md +7 -4
  7. data/doc/developer/AnnotationSystem.md +10 -15
  8. data/doc/developer/Configuration.md +20 -30
  9. data/doc/developer/CoreUtilities.md +52 -22
  10. data/doc/developer/DesignPrinciples.md +7 -9
  11. data/doc/developer/DocumentationPractices.md +476 -0
  12. data/doc/developer/ErrorHandling.md +11 -20
  13. data/doc/developer/LockingAndConcurrency.md +20 -13
  14. data/doc/developer/StreamingModel.md +13 -14
  15. data/doc/developer/Testing.md +389 -0
  16. data/doc/user/AnnotatingData.md +9 -13
  17. data/doc/user/CommandLineOptions.md +9 -12
  18. data/doc/user/Cookbook.md +7 -10
  19. data/doc/user/HandlingStreams.md +22 -29
  20. data/doc/user/LoggingAndProgress.md +9 -11
  21. data/doc/user/ProducingResources.md +0 -1
  22. data/doc/user/RemoteData.md +10 -13
  23. data/doc/user/RunningCommands.md +12 -17
  24. data/doc/user/WorkingWithFiles.md +10 -2
  25. data/lib/scout/config.rb +1 -0
  26. data/lib/scout/indiferent_hash.rb +8 -2
  27. data/lib/scout/log/fingerprint.rb +1 -1
  28. data/lib/scout/named_array.rb +4 -0
  29. data/lib/scout/open/final.rb +1 -1
  30. data/lib/scout/open/stream.rb +14 -13
  31. data/lib/scout/path/util.rb +1 -1
  32. data/lib/scout/testing/availability.rb +264 -0
  33. data/lib/scout/testing/scout_state.rb +193 -0
  34. data/lib/scout/testing.rb +82 -0
  35. data/research/doc-audit-findings.md +7 -5
  36. data/research/doc-structure.md +89 -0
  37. data/research/lock-namespace-blindness.md +73 -0
  38. data/research/probing-guide.md +311 -0
  39. data/research/testing-availability-scout-ai-handoff.md +235 -0
  40. data/research/testing-availability-scout-rig-handoff.md +355 -0
  41. data/scout-essentials.gemspec +24 -3
  42. data/test/scout/annotation/test_dup_clone.rb +31 -0
  43. data/test/scout/indiferent_hash/test_case_insensitive_staleness.rb +27 -0
  44. data/test/scout/indiferent_hash/test_string2hash_false.rb +13 -0
  45. data/test/scout/misc/test_timespan.rb +30 -0
  46. data/test/scout/open/test_compression_detection.rb +32 -0
  47. data/test/scout/open/test_forced_gzip_broken.rb +25 -0
  48. data/test/scout/test_named_array_resolution.rb +49 -0
  49. data/test/scout/test_testing.rb +195 -0
  50. data/test/scout/test_tmpfile_cleanup.rb +23 -0
  51. data/test/scout/testing/test_availability.rb +222 -0
  52. data/test/scout/testing/test_state_bindings.rb +163 -0
  53. data/test/test_helper.rb +26 -12
  54. metadata +23 -2
@@ -0,0 +1,73 @@
1
+ # Lock liveness is PID-namespace-blind
2
+
3
+ **Status:** Non-normative investigation artifact. May be outdated.
4
+
5
+ **Origin:** promoted from the Cortex claim
6
+ `claims/persist_lock_theft_across_agents.md` (investigation of
7
+ 2026-09-08, after concurrent agents observed `StolenLockError` storms).
8
+
9
+ ## The mechanism
10
+
11
+ Every lock liveness check in scout-essentials reduces to
12
+ `Process.kill(0, pid)` (`Lockfile#alive?`,
13
+ `lib/scout/open/lock/lockfile.rb:379-387`):
14
+
15
+ ```ruby
16
+ def alive? pid
17
+ pid = Integer("#{ pid }")
18
+ Process.kill 0, pid # ESRCH ⇒ dead, anything else ⇒ alive
19
+ rescue Errno::ESRCH
20
+ false
21
+ end
22
+ ```
23
+
24
+ `Process.kill` resolves pids in the **PID namespace of the calling
25
+ process**. In the normal case — all processes on one host — a recorded
26
+ pid either belongs to a live process (signal permitted/delivered ⇒ alive)
27
+ or does not (ESRCH ⇒ dead), and the check is correct.
28
+
29
+ In a PID namespace (bubblewrap/sandboxed agents, containers) the caller
30
+ sees only its own namespace. A lock recorded by a *host* process carries
31
+ a pid that simply does not exist in the caller's namespace, so
32
+ `Process.kill 0, <host pid>` raises `Errno::ESRCH` and the holder is
33
+ reported **dead while it is alive**. Verified from inside a bwrap
34
+ sandbox (host pid 3151964 recorded in a live
35
+ `var·cache·ask·….persist.lock`): `Process.kill(0, 3151964)` →
36
+ `Errno::ESRCH`, while `Process.kill(0, <namespace pid 2>)` succeeds.
37
+
38
+ Two consumers act on that verdict:
39
+
40
+ 1. **`Lockfile#sweep`** (`lib/scout/open/lock/lockfile.rb:341-376`) runs
41
+ on every `Open.lock` in the directory of the lock target and removes
42
+ `.*lck` temp files whose embedded pid it believes dead. In a
43
+ directory shared across namespaces (e.g. `~/.scout/tmp/persist_locks`)
44
+ a sandboxed caller can therefore delete the lock bookkeeping of live
45
+ host holders.
46
+ 2. **`scout system clean`** (scout-gear `scout_commands/system/clean`) —
47
+ not in this repository, but it iterates `Scout.lock_info` over the
48
+ same `tmp/persist_locks` directory and unlinks any lock whose pid
49
+ `Misc.pid_alive?` reports dead, with the same namespace blindness.
50
+ The scout-gear implementation additionally has an inverted condition
51
+ in its persists/sensiblewrites sections (unlinks when the pid **is**
52
+ alive), which makes it more aggressive still.
53
+
54
+ The victim observes this as `Lockfile::StolenLockError` raised by the
55
+ refresher thread of every live holder at once (`new_refresher`,
56
+ `lib/scout/open/lock/lockfile.rb:414-433`) — matching the reported
57
+ "all agents hit simultaneously" signature.
58
+
59
+ ## Inferred rule
60
+
61
+ > Do not share `Persist.lock_dir` / produce / sensible_write lock
62
+ > directories across PID namespaces. Under bwrap or containers, pin
63
+ > `HOME` (and the `tmp` path map) per sandbox, so each namespace sweeps
64
+ > only its own locks.
65
+
66
+ A more robust library-side fix would treat `/proc/<pid>` unreadable —
67
+ or more generally "pid outside my namespace" — as *alive*, at the cost
68
+ of keeping genuinely orphaned locks when the last namespace dies.
69
+
70
+ ## Regression test idea (not implemented)
71
+
72
+ Holder takes a `Persist` lock; a child in a PID namespace runs the
73
+ sweep; the holder must not receive `StolenLockError`.
@@ -0,0 +1,311 @@
1
+ # Probing guide — general advice for empirical Scout investigation
2
+
3
+ Promoted from the Cortex artifact `guides/probing.md` (v3, 2026-09-09).
4
+ Source of truth for future revisions remains the Cortex artifact; this
5
+ file is the in-research copy for agents that do not have the Cortex
6
+ workspace mounted. Cortex addresses cited below resolve in the
7
+ scout-essentials / scout-gear Cortex path maps.
8
+
9
+ Audience: any coding agent probing behavior in **any** Scout package or
10
+ workflow (scout-gear, scout-essentials, scout-ai, scout-camp, scout-rig,
11
+ installed workflows). This guide distills the frictions and successes of
12
+ the 2026-09-07/09 CortexCoder documentation-and-probing campaigns
13
+ (scout-essentials + scout-gear, 106 probe artifacts, 8 claims documents,
14
+ ~7,600 bash and 60 `cortex_entity_property` calls across the session
15
+ chats), plus one new probe written to pin down the timeout mechanics.
16
+
17
+ Evidence is cited by Cortex address throughout. The guide is deliberately
18
+ general: subsystem-specific facts stay in the `scout-gear/*` claims
19
+ artifacts.
20
+
21
+ ## 1. The ladder (unchanged, it works)
22
+
23
+ ```text
24
+ uncertainty
25
+ → tmp/ experiment (throwaway)
26
+ → standalone run under an escalating timeout guard
27
+ → probe/ artifact (versioned, commented, JSON-returning)
28
+ → Observation/probe execution (receipt + registry record)
29
+ → claims/ artifact (proposition, observed vs inferred, citations)
30
+ → research / doc / test promotion
31
+ ```
32
+
33
+ This was executed end-to-end twice and is the single most reliable
34
+ pattern in the corpus (`harness/cortex-coder-first-episode-review.md` F5).
35
+ Nothing below replaces it; everything below is how to avoid the
36
+ frictions the same corpus recorded.
37
+
38
+ ## 2. Before writing a probe
39
+
40
+ - **Name the question first.** One probe answers one sharply-stated
41
+ question ("does `WorkQueue#abort` leave `join` blocked?"), not "learn
42
+ about concurrency". A probe that cannot be titled is a browsing
43
+ session, not an investigation.
44
+ - **Check what is already known.** `cortex_list type=artifacts prefix=probe/`,
45
+ `cortex_search`, and `cortex_activity` for the entity. Campaigns left
46
+ 106 probes behind; re-probing an answered question is the most common
47
+ wasted motion.
48
+ - **Read the docs and the code path before probing.** Probes are for
49
+ *resolving* disagreement between doc, code and expectation — state in
50
+ the probe header which doc sentence the question came from.
51
+ (`probe/persist_engine_selection.rb`, `probe/assoc_field_specs.rb` are
52
+ good templates: they quote the doc claim being tested.)
53
+ - **Record the environment you are testing.** Live checkout vs installed
54
+ gem (`-Ilib` vs plain `ruby`), HEAD hash, RUBY_VERSION. Campaign
55
+ artifacts drifted between batches because line numbers and HEAD moved
56
+ (`scout-gear/overview.md` open item 6); re-pin before quoting line
57
+ numbers.
58
+
59
+ ## 3. Probe anatomy
60
+
61
+ Follow the doctrine header, proven in the corpus:
62
+
63
+ ```ruby
64
+ # Observation: <snake_case_name>
65
+ #
66
+ # What it investigates
67
+ # Why it matters
68
+ # Assumptions / conditions (environment, isolation, what is NOT tested)
69
+ # Result kind (JSON object with these keys: ...)
70
+ ```
71
+
72
+ Rules that emerged as load-bearing:
73
+
74
+ - **Return one JSON-serializable object with string keys.** Every key you
75
+ intend to cite from a claim should be a distinct result key; claims
76
+ cite `Observation/probe/<job>.json` *key names*
77
+ (`scout-gear/concurrency.md` does this per claim).
78
+ - **Rescue each section independently** so one failure does not hide the
79
+ rest; collect per-section errors into an `errors` key instead of
80
+ letting the probe die (`probe/entity_persisted_property_invalidation.rb`).
81
+ - **Put the `puts JSON.pretty_generate` at the end AND return the
82
+ object** — standalone runs need stdout, property runs need the return
83
+ value.
84
+ - **Errors are results.** Capture `"#{e.class}: #{e.message[0, 60]}"`,
85
+ not just a boolean. The class/message pair is usually the interesting
86
+ fact (see `fork_each_raises` in the semaphore probe).
87
+
88
+ ## 4. Determinism and isolation
89
+
90
+ - **No network. Fixed tmpdirs. Close everything you open** (databases,
91
+ semaphores, sockets, queue dirs). Scratch under `tmp/probe_<name>_<pid>`
92
+ or `Dir.mktmpdir`, and remove it in the same run.
93
+ - **Prefer sleep-free observation.** Where you need timing facts, read
94
+ recorded trace timestamps (`Workflow.trace_job_times`) instead of
95
+ sleeping (`probe/deploy_local_end_to_end.rb`). Where you need
96
+ cache-hit vs recompute, use `rand` inside blocks so the two are
97
+ distinguishable without delays.
98
+ - **Isolate global state before declaring anything.** If the library
99
+ keeps process-global caches (`Entity.entity_property_cache`,
100
+ `Entity.formats`), reassign them to a tmpdir *before* registering
101
+ modules/properties — several defaults are captured at declaration
102
+ time (`probe/entity_persisted_property_invalidation.rb`).
103
+ - **Isolate the user environment.** For CLI/load-path probes pass
104
+ `HOME` = fresh tmpdir so per-user config cannot mask the failure you
105
+ are testing (`probe/cli_process_trace_broken_commands.rb`).
106
+ - **Normalize environment artifacts before claiming.** Anonymous module
107
+ names (`#<Module::…>::M9::AA`), PIDs, tmpdir paths, timestamps differ
108
+ between runs — compare shapes and counts, not raw strings
109
+ (`probe/wf_remote_step_api.rb` notes this explicitly: "an environment
110
+ artifact, not a behavioral difference").
111
+ - **Scope claims to what you ran.** Report `RUBY_VERSION` and the
112
+ interpreter (MRI/GVL) when concurrency semantics depend on it
113
+ (`probe/entity_formats_registry_concurrency.rb`).
114
+ - **Make rare interleavings deterministic by widening the window** (alias
115
+ patch around a check-compute-write-back), and *label it as an exhibit*
116
+ in both the probe and the claim — an exhibit proves the interleaving
117
+ exists, not that it occurs at stock timing.
118
+
119
+ ## 5. The execution environment (bwrap / property sandbox)
120
+
121
+ Probe bodies are `eval`ed by `Observation/probe` with `self` = a Scout
122
+ Step, and tasks run under bwrap. Facts that cost the campaigns real
123
+ debugging time:
124
+
125
+ - **`include` is unavailable** at top level (`NoMethodError: undefined
126
+ method 'include' for #<Step>`). Use modules explicitly:
127
+ `FU = FileUtils; FU.mkdir_p …` (`probe/persist_engine_selection.rb`).
128
+ - **`Kernel#include`-style sugar and some constants resolve differently.**
129
+ Prefer explicit `Process.clock_gettime(Process::CLOCK_MONOTONIC)`
130
+ forms over constants; define helpers as plain `def`s (they work).
131
+ - **Modifier-rescue precedence can make a body unparseable** in the
132
+ eval context: `File.read(lp).split… rescue []` inside a hash literal
133
+ broke `cli_preprocess_flags` until rewritten flat. Keep hash-literal
134
+ contents rescue-free (`scout-gear/cli.md` §7, v3→v4 fix).
135
+ - **Sandbox and plain-ruby values can differ.** The first episode
136
+ recorded `join_pid_fail*` differing between bwrap and plain ruby.
137
+ Never claim a value you only measured in one environment when the
138
+ question is about the other; state both, or say which one you ran.
139
+ - **Writability is layered**: the Ruby allowlist (read/write tasks) and
140
+ the bwrap mounts (bash/ruby/python) are different sets. When a path is
141
+ rejected, call `sandbox_paths` instead of guessing
142
+ (the campaign repeatedly re-discovered which repo tmp dirs were bound).
143
+
144
+ ## 6. Blocking, forking, and timeouts — the F1 class
145
+
146
+ The one friction that hung real executions. General rules:
147
+
148
+ - **Single-process by default.** Do not fork/join in probe bodies unless
149
+ the question *is* about forking. Static/no-fork variants answer most
150
+ questions (`wq_lifecycle_static` vs `wq_fork_run` split).
151
+ - **A forking probe must graduate:** standalone first, 3/3 clean runs
152
+ under the escalating guard, every blocking `join` boxed in
153
+ `Timeout.timeout(k) { … }`, executed through the property exactly once
154
+ (`probe/wq_fork_run.rb` header is the template;
155
+ `scout-gear/concurrency.md` "Probe discipline note").
156
+ - **Always use an escalating guard, never bare `timeout N`:**
157
+ `timeout --kill-after 5 60 ruby probe.rb`. Measured fact: against a
158
+ child that traps SIGTERM — exactly what forked Scout workers and
159
+ rescuing loops do — plain `timeout 2` let the child run to natural
160
+ exit (8.12 s under a 2 s bound) **while still reporting exit 124**, so
161
+ the guard lies; `--kill-after 1 2` killed it at 3.01 s with SIGKILL
162
+ (`Observation/probe/shell_timeout_kill_after_…3940752058….json`,
163
+ `claims/timeout_kill_after.md`). Keep the escalation short relative to
164
+ the bound.
165
+ - **Put the guard on the leaf command.** Nested timeouts do not compose:
166
+ the outer KILL reaches only its direct child, and a trapping
167
+ grandchild survives (measured: 8.12 s under a 2 s outer guard). Do not
168
+ wrap a wrapper.
169
+ - **Do not trust exit status 124 as proof of a bounded run** — check
170
+ elapsed time or wrap the measurement yourself.
171
+ - **Property execution has its own bound** (default 3600 s, no progress
172
+ signal). For probe-shaped bodies pass a short per-call `timeout:` to
173
+ `cortex_entity_property`. A hung probe inside a property is invisible
174
+ from the outside until the bound fires; the first-episode branch that
175
+ hung simply stopped making tool progress (F1).
176
+ - **Hung probes vanish from the transcript.** Killed executions leave no
177
+ completed tool output, so chat-level latency analysis shows nothing —
178
+ the evidence of a hang lives in harness reviews and forced-re-run
179
+ records. If a run "disappeared", suspect a hang, not a skip.
180
+
181
+ ## 7. Property execution and caching subtleties
182
+
183
+ - **Standalone first, then property.** The standalone run is your
184
+ pre-flight (parses, terminates, produces JSON); the property run is
185
+ your receipt.
186
+ - **Editing a probe artifact does NOT auto-invalidate its property job.**
187
+ The Step is content-addressed on entity + arguments + definition
188
+ identity — not on artifact contents, which are read by `eval` at run
189
+ time. After editing `probe/<name>.rb`, re-run with `update: true` or
190
+ you will be reading a stale cached result while believing you tested
191
+ the new body (observed live this session: v3 result served after the
192
+ v4 edit until `update: true` forced recompute).
193
+ - **Check the receipt:** `definition_version`, `definition_digest`,
194
+ `property_job` and the artifact `version` in `content` should match
195
+ what you wrote. A `version` in the receipt that lags your edit is the
196
+ stale-cache signature.
197
+ - **Registry records accumulate**: `cortex_list type=properties` /
198
+ `cortex_activity` show run counts and last-run times — cheap way to
199
+ check whether a probe has ever been executed and against which
200
+ definition version.
201
+
202
+ ## 8. Negative probes
203
+
204
+ Deliberate failure probes are first-class evidence (77 of the first
205
+ episode's tool-call failures were intentional):
206
+
207
+ - Test the documented error path, not only the happy path: unknown
208
+ workflow/task, missing args, broken `require`s, worker-block raises,
209
+ `abort` after `close`. Capture exit status + first stderr signature
210
+ (`probe/cli_dispatch_and_surface.rb`, `cli_process_trace_broken_commands`).
211
+ - A `require` that fails in a stock install is a stronger statement than
212
+ "has a bug" — say which level failed.
213
+ - Keep negative probes as *separate, tiny* probes or clearly-keyed
214
+ sections, so a claim can cite "exit_status" keys without ambiguity.
215
+
216
+ ## 9. Debugging a failed property run
217
+
218
+ - **Bisect wrapper pattern:** when a probe works standalone but fails in
219
+ the property context, run its lines cumulatively in the property
220
+ context to find the exact failing statement
221
+ (`probe/tmp_open3_sanity.rb` — returns the failing line number, code
222
+ and error).
223
+ - Read the Step files the receipt points at before re-running anything;
224
+ re-running blind is the expensive habit.
225
+ - The two-layer sandbox (§5) explains most "works in bash, fails in
226
+ read" mysteries — `sandbox_paths` settles it in one call.
227
+
228
+ ## 10. Evidence discipline
229
+
230
+ - **Never transcribe numbers a probe can return.** Claims cite the
231
+ property job and the result keys; humans (and later agents) re-run
232
+ rather than trust copy-paste.
233
+ - **Separate observed from inferred.** The claim format that worked:
234
+ proposition → *Observed* (probe evidence, exact keys) → *Inferred
235
+ rule* (scoped, justified) → *Docs impact* (gap/correct/wrong).
236
+ Code-reading-only findings are labelled as such and never mixed with
237
+ runtime receipts (`scout-gear/concurrency.md` Claim marked
238
+ "unprobed at runtime: cross-process effect").
239
+ - **One observation ≠ one universal claim.** Scope the rule to the
240
+ environment, Ruby version, and code revision you measured.
241
+
242
+ ## 11. Lifecycle
243
+
244
+ - Probes that support a durable claim stay under `probe/`; scratch and
245
+ bisect helpers get deleted once their question is answered.
246
+ - A probe with a crisp failure signature can be promoted **into a
247
+ regression test** in the target repo — prefer that over keeping an
248
+ out-of-cortex behavior pinned only in Cortex.
249
+ - When the claims are promoted out of Cortex (docs/tests), the probe may
250
+ be deleted; the property registry keeps the run history either way.
251
+ - Record cross-session harness learnings (hangs, concurrency, provenance
252
+ gaps) under `harness/` — that is where the F1–F5 evidence lives and
253
+ where this guide's timeout section came from.
254
+
255
+ ## 12. Quick checklist before executing any new probe
256
+
257
+ ```text
258
+ [ ] one named question, doc/code disagreement stated in the header
259
+ [ ] string-keyed JSON result; sections individually rescued
260
+ [ ] no network; fixed tmpdir; globals reassigned to scratch; cleanup
261
+ [ ] sleep-free where a trace/timestamp alternative exists
262
+ [ ] environment recorded (checkout vs gem, HEAD, RUBY_VERSION)
263
+ [ ] standalone run OK under: timeout --kill-after 5 60 ruby probe.rb
264
+ [ ] forking only if the question needs it; joins boxed in Timeout.timeout
265
+ [ ] property run with a short per-call timeout; receipt version matches
266
+ [ ] after editing the artifact: re-run with update: true
267
+ [ ] claim cites property_job + result keys; observed vs inferred separated
268
+ ```
269
+
270
+ ## Evidence index
271
+
272
+ - `claims/timeout_kill_after.md` +
273
+ `Observation/probe/shell_timeout_kill_after_3940752058e155e185253a096bb3d07f.json`
274
+ — the `--kill-after` mechanics (control / trap / kill-after / nested
275
+ guard), written for this guide.
276
+ - `harness/cortex-coder-first-episode-review.md` — F1 (hang), F2
277
+ (artifact edit races), F3–F4 (accounting/provenance), F5 (the ladder
278
+ executed correctly).
279
+ - `harness/cortex-coder-second-episode-record.md` — corpus consumption
280
+ and promotion decisions.
281
+ - `probe/wq_fork_run.rb`, `probe/wq_lifecycle_static.rb`,
282
+ `probe/entity_formats_registry_concurrency.rb`,
283
+ `probe/entity_persisted_property_invalidation.rb`,
284
+ `probe/persist_engine_selection.rb`,
285
+ `probe/cli_process_trace_broken_commands.rb`,
286
+ `probe/deploy_local_end_to_end.rb`, `probe/tmp_open3_sanity.rb` —
287
+ the pattern templates referenced above.
288
+ - `scout-gear/concurrency.md` (probe discipline note, abort/join hazard),
289
+ `scout-gear/cli.md` §7 (sandbox parse failure),
290
+ `scout-gear/overview.md` §5 (106-probe inventory & citation audit),
291
+ `scout-gear/consolidation-survey.md` (rev drift, HEAD pinning).
292
+
293
+ ### Where the session evidence lives (for future ChatAnalyst passes)
294
+
295
+ The `~/git/<repo>/chats/<name>` paths are not visible from inside the
296
+ sandbox; the same sessions persist as root chats under
297
+ `~/.scout/tmp/tmp-*.chat` (this campaign: `tmp-772567689` scout-gear CLI
298
+ owner, `tmp-869423640` scout-gear gap-closing, `tmp-725846580` /
299
+ `tmp-908860370` / `tmp-879155263` scout-essentials doc-review owners,
300
+ `tmp-171141240` scout-gear CLI batch 4, `tmp-373370430` scout-gear
301
+ consolidation, `tmp-72174883` synthesis). `chat_overview` /
302
+ `chat_tool_calls` / `provenance_relationships` work on those files
303
+ directly. Corpus stats extracted for this guide: `timeout` durations
304
+ used — 60 s ×350 (the norm), 30 ×82, 120 ×38, 90 ×9, 150 ×5, 300 ×2;
305
+ ~207 non-zero bash exit statuses out of ~3,900 recorded (status 1 ×207
306
+ plus stray 231/235/240/246 — 246 is the WorkQueue abort exit code),
307
+ overwhelmingly deliberate negative probes; bash dominates the tool mix
308
+ (~7,600 calls) with `cortex_read` (384) second — probing in this stack
309
+ is a bash + Cortex discipline. No `--kill-after` appears anywhere in
310
+ the corpus: the guard discipline existed, the escalation half did not,
311
+ which is precisely the friction that motivated this guide.
@@ -0,0 +1,235 @@
1
+ # Testing availability handoff — scout-ai
2
+
3
+ Provenance: salvage packet produced during the scout-essentials layering
4
+ cleanup (2026-10). scout-ai keeps the model/accelerator/endpoint probes that
5
+ were incorrectly drafted into scout-essentials. This note is self-contained:
6
+ verified call-site inventory, retention list, the 7-defect list with the
7
+ correction for each, a delegation example, the compat-adapter option, and
8
+ constraints.
9
+
10
+ ## Verified call-site inventory (read-only pass over /home/mvazque2/git/scout-ai)
11
+
12
+ - `python_modules?`
13
+ - `test/scout/model/python/test_torch.rb`
14
+ - `test/scout/model/python/huggingface/test_classification.rb`
15
+ - `test/scout/model/python/huggingface/causal/test_next_token.rb`
16
+ - `cuda?`
17
+ - `test/scout/model/python/torch/test_helpers.rb`
18
+ - `hf_model_cached?`
19
+ - `test/scout/model/python/huggingface/test_causal.rb`
20
+ - `test/scout/model/python/huggingface/test_classification.rb`
21
+ - `test/scout/model/python/huggingface/causal/test_next_token.rb`
22
+ - `hf_model_reason` — same three files as `hf_model_cached?` (omit messages).
23
+ - `rscript?`, `r_packages?`, `endpoint_reachable?` — **zero** call sites
24
+ outside `test/support/availability.rb` itself.
25
+ - `endpoint_configured?('test')` — `test/integration/scout/llm/test_infrastructure.rb:30`.
26
+ - `test/support/infrastructure_probes.rb` — used by
27
+ `test/integration/scout/llm/backends/test_endpoints.rb` and
28
+ `test/integration/scout/llm/test_infrastructure.rb`;
29
+ `test/integration/scout/model/test_base.rb` also uses `Availability`
30
+ (specifically `r_packages?('e1071')` / `r_packages_reason`).
31
+ - `SCOUT_TEST_PYTHON` appears **only** inside `availability.rb`.
32
+ - `test/test_helper.rb` requires `support/fixtures` and `support/mock_backend`
33
+ but **not** `support/availability`; each test file that needs it requires it
34
+ directly (e.g. `test_causal.rb` documents the dependency).
35
+
36
+ ## Layer table (what stays where)
37
+
38
+ | Probe / method | Layer | Destination |
39
+ |---|---|---|
40
+ | `executable?`, `run_command`, `tcp_reachable?` | scout-essentials | `Scout::Testing::Availability` (delivered, stdlib-only) |
41
+ | `cuda?` | scout-ai | `test/support/availability.rb` |
42
+ | `hf_model_cached?` / `hf_model_reason` (must honor `HF_HUB_CACHE`) | scout-ai | same |
43
+ | `endpoint_reachable?`, `endpoint_configured?`, `endpoint_reason`, `configured_endpoints`, `real_endpoints`, `endpoint_available?` | scout-ai | same |
44
+ | `InfrastructureProbes` | scout-ai | `test/support/infrastructure_probes.rb` — unchanged |
45
+ | `python?`, `python_modules?`(+reason), `rscript?`, `r_packages?`(+reason) | scout-rig | see `testing-availability-scout-rig-handoff.md`; scout-ai may still call them until its own suite stops depending on them (see note on `test_base.rb`) |
46
+
47
+ ## Retention list (do not delete in scout-ai)
48
+
49
+ - `cuda?` — one live call site.
50
+ - `hf_model_cached?` + `hf_model_reason` — three live call sites; must honor
51
+ `HF_HUB_CACHE`.
52
+ - `endpoint_reachable?`, `endpoint_configured?`, `endpoint_reason`,
53
+ `configured_endpoints`, `real_endpoints`, `endpoint_available?` — endpoint
54
+ family; memoization must key on the timeout argument.
55
+ - `InfrastructureProbes` — used by two integration suites; keep unchanged.
56
+
57
+ `rscript?`/`r_packages?` have exactly one live consumer
58
+ (`test/integration/scout/model/test_base.rb:12`, e1071) and no R integration
59
+ in scout-rig; keep them (reference-only, as in the rig handoff) until that
60
+ call site is either guarded by an R-availability decision or removed.
61
+
62
+ ## The 7-item defect list with corrections
63
+
64
+ 1. **`python?` ignores subprocess exit status.** A broken interpreter can
65
+ still print. Decide on `exit_status == 0`, not on output presence.
66
+ 2. **`python_modules_reason` reads stdout.** Import tracebacks go to stderr;
67
+ read `result.stderr`, falling back to a synthesized reason when it is
68
+ empty.
69
+ 3. **`memo` rescues `Exception`.** Rescue `StandardError` only; timeout and
70
+ launch failure are handled deliberately (recorded on the result), never
71
+ swallowed. Never cache a `SignalException` as "unavailable".
72
+ 4. **`run_bounded` drains stdout before stderr** (pipe deadlock with chatty
73
+ children), **spawns before its rescue** (launch failure escapes), and the
74
+ **`capture:false` path drains neither pipe**. Corrected shape: spawn inside
75
+ the rescue scope; drain both pipes concurrently (reader threads) with a
76
+ deadline; on timeout TERM the process group, wait a grace, then KILL;
77
+ report launch failure as a structured result.
78
+ 5. **`endpoint_reachable?` memoization ignores the timeout argument.** Key the
79
+ memo on `(endpoint_or_host, port, timeout)` — a 2s probe and a 30s probe
80
+ must not share an entry.
81
+ 6. **HF cache lookup ignores `HF_HUB_CACHE`.** Resolve the cache root as
82
+ `ENV['HF_HUB_CACHE']` (if set) else the default
83
+ `~/.cache/huggingface/hub`, then check for the model directory
84
+ (`models--<org>--<name>` with a snapshots dir) beneath it.
85
+ 7. **Module/package names interpolated unsafely into Python/R source.** Pass
86
+ them as argv to a fixed probe script, or use `Shellwords.escape` when a
87
+ source string is unavoidable.
88
+
89
+ The corrected generic primitives (`executable?`, `run_command`,
90
+ `tcp_reachable?`) now live in scout-essentials'
91
+ `Scout::Testing::Availability`; defects 3 and 4 are already fixed there, so
92
+ the cheapest correct move for scout-ai is delegation (below) rather than a
93
+ local `run_bounded`.
94
+
95
+ ## Delegation example
96
+
97
+ Reimplement scout-ai's internals on the primitives; keep behavior, drop the
98
+ local runner:
99
+
100
+ ```ruby
101
+ # scout-ai test/support/availability.rb (revised)
102
+ require 'scout/testing/availability' # scout-essentials primitives
103
+
104
+ module Availability
105
+ PROBE_TIMEOUT = 30
106
+ CACHE_TIMEOUT = 300
107
+
108
+ class << self
109
+ # defect 3: StandardError only; explicit reset.
110
+ def reset!
111
+ @memo = {}
112
+ end
113
+
114
+ def memoized(key)
115
+ @memo ||= {}
116
+ return @memo[key] if @memo.key?(key)
117
+
118
+ @memo[key] = yield
119
+ rescue StandardError => e
120
+ "probe error: #{e.class}: #{e.message}"
121
+ end
122
+
123
+ # defect 1 + 7: exit status decides; module names as argv.
124
+ def python_modules?(*modules)
125
+ !python_modules_reason(*modules)
126
+ end
127
+
128
+ def python_modules_reason(*modules)
129
+ python = ENV['SCOUT_TEST_PYTHON'].to_s.strip
130
+ python = 'python3' if python.empty? &&
131
+ Scout::Testing::Availability.executable?('python3')
132
+ return 'no python executable found' if python.nil? || python.empty?
133
+
134
+ probe = <<~PY
135
+ import importlib, sys
136
+ missing = []
137
+ for name in sys.argv[1:]:
138
+ try:
139
+ importlib.import_module(name)
140
+ except Exception as e:
141
+ missing.append("%s: %s: %s" % (name, type(e).__name__, e))
142
+ if missing:
143
+ sys.stderr.write("; ".join(missing))
144
+ sys.exit(1)
145
+ PY
146
+ result = Scout::Testing::Availability.run_command(
147
+ [python, '-c', probe, *modules], timeout: PROBE_TIMEOUT
148
+ )
149
+ return nil if result.success? && result.exit_status == 0
150
+
151
+ # Launch failure and timeout are the concrete reasons; otherwise the
152
+ # trimmed stderr (defect 2), else the exit status.
153
+ detail = result.launch_error
154
+ detail ||= "timed out after #{PROBE_TIMEOUT}s" if result.timed_out
155
+ detail ||=
156
+ if result.stderr.to_s.strip.empty?
157
+ "python exited #{result.exit_status}"
158
+ else
159
+ result.stderr.to_s.strip
160
+ end
161
+ detail
162
+ end
163
+
164
+ # defect 5: memo key includes the timeout.
165
+ def endpoint_reachable?(name, timeout: PROBE_TIMEOUT)
166
+ endpoint = configured_endpoints[name]
167
+ return "no endpoint '#{name}' configured" unless endpoint
168
+
169
+ host, port = endpoint[:host], endpoint[:port]
170
+ key = [:tcp, host, port, timeout] # <-- timeout in the key
171
+ hit = memoized(key) { Scout::Testing::Availability.tcp_reachable?(host, port, timeout: timeout) }
172
+ hit ? nil : "endpoint '#{name}' (#{host}:#{port}) not reachable within #{timeout}s"
173
+ end
174
+
175
+ # defect 6: honor HF_HUB_CACHE.
176
+ def hf_model_cached?(model)
177
+ !hf_model_reason(model)
178
+ end
179
+
180
+ def hf_model_reason(model)
181
+ cache = ENV['HF_HUB_CACHE'].to_s.strip
182
+ cache = File.join(ENV['HOME'].to_s, '.cache', 'huggingface', 'hub') if cache.empty?
183
+ dir = File.join(cache, 'models--' + model.tr('/', '--'))
184
+ return "model #{model} not in cache (#{dir})" unless File.directory?(dir)
185
+
186
+ snapshots = File.join(dir, 'snapshots')
187
+ return "model #{model} cached but empty" unless File.directory?(snapshots) &&
188
+ Dir.children(snapshots).any?
189
+
190
+ nil
191
+ end
192
+ end
193
+ end
194
+ ```
195
+
196
+ The reason-selection block in `python_modules_reason` picks, in order: the
197
+ launch error, the timeout note, the trimmed stderr, and finally the exit
198
+ status.
199
+
200
+ ## Compat-adapter option
201
+
202
+ If touching call sites is out of scope for the first pass, keep the existing
203
+ public method names and reimplement the internals:
204
+
205
+ ```ruby
206
+ module Availability
207
+ def self.endpoint_reachable?(endpoint_name, timeout: PROBE_TIMEOUT)
208
+ host, port = resolve(endpoint_name)
209
+ key = [:tcp, host, port, timeout]
210
+ memoized(key) { Scout::Testing::Availability.tcp_reachable?(host, port, timeout: timeout) }
211
+ end
212
+ # same for any local bounded execution: delegate to
213
+ # Scout::Testing::Availability.run_command
214
+ end
215
+ ```
216
+
217
+ The four files with `python_modules?`/`cuda?`/`hf_model_cached?` call sites
218
+ keep working unchanged; the internals become the fixed primitives.
219
+
220
+ ## Constraints
221
+
222
+ - **Never download models**: `hf_model_cached?` inspects the local cache only;
223
+ absence reports a reason, it never fetches.
224
+ - Ordinary unit tests stay **offline**; integration tests may probe
225
+ **configured** endpoints (that is their purpose).
226
+ - Product-failure assertions must never become skips: probes omit only on
227
+ genuinely absent optional dependencies.
228
+ - Do not reintroduce interpreter/package probes into scout-essentials; the
229
+ Python/R probes belong to scout-rig (see its handoff note), and the
230
+ primitives layer is frozen at `executable?`/`run_command`/`tcp_reachable?`.
231
+ - scout-ai's `test/support/availability.rb` must keep the same public surface
232
+ (`python?`, `python_modules?`/`_reason`, `cuda?`, `hf_model_cached?`/
233
+ `_reason`, `endpoint_reachable?`, `endpoint_configured?`, `endpoint_reason`,
234
+ `configured_endpoints`, `real_endpoints`, `endpoint_available?`,
235
+ `InfrastructureProbes` unchanged) so the seven call-site files need no edits.