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.
- checksums.yaml +4 -4
- data/.vimproject +18 -1
- data/Rakefile +9 -0
- data/VERSION +1 -1
- data/doc/Improvements.md +20 -0
- data/doc/StartHere.md +7 -4
- data/doc/developer/AnnotationSystem.md +10 -15
- data/doc/developer/Configuration.md +20 -30
- data/doc/developer/CoreUtilities.md +52 -22
- data/doc/developer/DesignPrinciples.md +7 -9
- data/doc/developer/DocumentationPractices.md +476 -0
- data/doc/developer/ErrorHandling.md +11 -20
- data/doc/developer/LockingAndConcurrency.md +20 -13
- data/doc/developer/StreamingModel.md +13 -14
- data/doc/developer/Testing.md +389 -0
- data/doc/user/AnnotatingData.md +9 -13
- data/doc/user/CommandLineOptions.md +9 -12
- data/doc/user/Cookbook.md +7 -10
- data/doc/user/HandlingStreams.md +22 -29
- data/doc/user/LoggingAndProgress.md +9 -11
- data/doc/user/ProducingResources.md +0 -1
- data/doc/user/RemoteData.md +10 -13
- data/doc/user/RunningCommands.md +12 -17
- data/doc/user/WorkingWithFiles.md +10 -2
- data/lib/scout/config.rb +1 -0
- data/lib/scout/indiferent_hash.rb +8 -2
- data/lib/scout/log/fingerprint.rb +1 -1
- data/lib/scout/named_array.rb +4 -0
- data/lib/scout/open/final.rb +1 -1
- data/lib/scout/open/stream.rb +14 -13
- data/lib/scout/path/util.rb +1 -1
- data/lib/scout/testing/availability.rb +264 -0
- data/lib/scout/testing/scout_state.rb +193 -0
- data/lib/scout/testing.rb +82 -0
- data/research/doc-audit-findings.md +7 -5
- data/research/doc-structure.md +89 -0
- data/research/lock-namespace-blindness.md +73 -0
- data/research/probing-guide.md +311 -0
- data/research/testing-availability-scout-ai-handoff.md +235 -0
- data/research/testing-availability-scout-rig-handoff.md +355 -0
- data/scout-essentials.gemspec +24 -3
- data/test/scout/annotation/test_dup_clone.rb +31 -0
- data/test/scout/indiferent_hash/test_case_insensitive_staleness.rb +27 -0
- data/test/scout/indiferent_hash/test_string2hash_false.rb +13 -0
- data/test/scout/misc/test_timespan.rb +30 -0
- data/test/scout/open/test_compression_detection.rb +32 -0
- data/test/scout/open/test_forced_gzip_broken.rb +25 -0
- data/test/scout/test_named_array_resolution.rb +49 -0
- data/test/scout/test_testing.rb +195 -0
- data/test/scout/test_tmpfile_cleanup.rb +23 -0
- data/test/scout/testing/test_availability.rb +222 -0
- data/test/scout/testing/test_state_bindings.rb +163 -0
- data/test/test_helper.rb +26 -12
- 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.
|