Skip to content

fix(opencode): tool-call telemetry captured NOTHING β€” 2,699/2,699 rows read text='unknown' - #298

Merged
ZacxDev merged 2 commits into
mainfrom
fix/opencode-tool-call-capture
Aug 3, 2026
Merged

fix(opencode): tool-call telemetry captured NOTHING β€” 2,699/2,699 rows read text='unknown'#298
ZacxDev merged 2 commits into
mainfrom
fix/opencode-tool-call-capture

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The bug

All 2,699 source='opencode' kind='tool-call' rows on the workbench carry text='unknown', and a payload that is not valid JSON. OpenCode tool distribution has never been measurable, so we cannot say whether opencode has the same Bash-over-Grep bias Claude Code demonstrably has.

Root cause β€” two independent defects

1. The tool name was never read. tool.execute.after did input?.tool?.name || input?.name || "unknown". The installed contract (~/.config/opencode/node_modules/@opencode-ai/plugin/dist/index.d.ts:249) is:

"tool.execute.after"?: (input: { tool: string; sessionID: string; callID: string; args: any },
                        output: { title: string; output: string; metadata: any }) => Promise<void>;

input.tool is a string, so .name is undefined on every single call and the literal fallback won 100% of the time.

2. The payload was shell-mangled. emitEvent ran execSync with the arguments joined into one shell string, values unquoted. Bash brace-expanded the unquoted JSON object into three separate b64:payload= arguments; emit keeps the last. Reproduced byte-for-byte in test_harness_negative_control_sees_the_mangled_payload:

b64:payload=duration_ms:0
b64:payload=success:true
b64:payload=args_summary:{"name":"customize-opencode"}     <- exactly the stored value

Values with spaces were word-split too: b64:cwd=/tmp/dir with space β†’ 3 argv entries.

The fix

  • execFileSync(emit, argv) β€” no shell, no splitting, no quote removal.
  • extractToolName() reads the string form (tolerating a {name} object form for contract drift).
  • A capture failure is now distinguishable from a real tool. unknown was a plausible tool name; failures now write the namespaced sentinel __name_capture_failed__ plus name_captured:false and name_capture_shape:<typeof>. A tool genuinely called unknown still records name_captured:true.
  • duration_ms was output.duration_ms β€” a field that does not exist on this hook β€” so it was always 0. Now measured across tool.execute.before β†’ .after via a bounded callID map, and absent (not 0) when unmeasurable.
  • success:true was hard-coded; the hook cannot observe a failure at all (OpenCode throws past it). Replaced with outcome:"completed".
  • session came from currentSession, set only by a session.created handler the contract never calls. Now input.sessionID.
  • args_summary was a raw .slice(0,200) of a JSON string β€” invalid JSON whenever it truncated. Now degrades to {"_truncated":true,"keys":[…],"bytes":N}.

Why this shipped: the suite was never gated

scripts/collector/opencode/tests was not in run-tests.sh's target list β€” 166 tests that no gate has ever run. Added. This is the only line of run-tests.sh this PR touches: the pytest dir list (~line 172). MIN_TESTS deliberately untouched.

The pre-existing test_plugin.py could not have caught either bug: it tests a copy of emitEvent re-typed inside the test string and builds emit's argv itself with subprocess.run([...]), so neither the real function nor the real hook handler is ever executed. Both of its tool-call tests pass unchanged against the broken code.

Harness validation + control pair

Negative controls (harness must go RED on known-bad code) β€” a vendored reproduction of the two pre-fix mechanisms:

control asserts result
test_harness_negative_control_sees_the_lost_tool_name pre-fix extraction yields text == 'unknown' passes β†’ harness can observe the name
test_harness_negative_control_sees_the_mangled_payload pre-fix payload is unparseable and equals the live value verbatim passes β†’ harness can observe the corruption

Positive control for the reassuring zero β€” corrupted_arg_count() returns 0 for the fixed code:

corrupted argv entries
positive control (buggy emitter) 3 (proj, with, space) + 3 b64:payload= fragments
under test (fixed) 0

⚠ Worth recording: my first positive control measured 0 and failed. It asserted on a spacey value inside args_summary, but JSON.stringify wraps string values in ", so the shell preserves those spaces. The splitting hits the fields the plugin does not quote (project, cwd). Without that control, test_no_argv_corruption's 0 would have been read as proof while the counter was blind to the case it exists for.

⚠ Correction β€” the first push was RED on the authoritative gate

The original version of this section reported 4568 collected / 4567 passed / 0 failed as "the full gate". That was the dev-host tier (nix-shell with the flake's package set). It is not the gate. The authoritative gate is nix build .#checks.x86_64-linux.pytests, and on the first push it was RED.

I reproduced it independently before fixing anything, and ran the discriminating control the coordinator also ran: the branch fails alone, so it is not a cross-PR interaction.

Why the dev host could not see it

Both write_mock_emit (pre-existing) and write_argv_recorder (new) write a stub script at runtime with #!/usr/bin/env bash.

  • NixOS dev host β€” /usr/bin/env exists (lrwxrwxrwx /usr/bin/env -> /nix/store/…-coreutils-9.10/bin/env) β†’ the stub execs, suite green.
  • nix build sandbox β€” no /usr/bin/env β†’ execve fails ENOENT.

nixpkgs' patchShebangs src/scripts (flake.nix) rewrites shebangs of files in the source tree β€” which is why the real scripts/collector/emit works in the sandbox β€” but it cannot touch a file a test creates while running. The defect was structurally unobservable in the tier I measured.

It was also mostly silent: emitEvent swallows every error by design, so node still exits 0 when the stub cannot exec; tests then failed on an empty list (IndexError), pointing at the wrong thing.

The fix β€” deterministic, one place, assertions untouched

New tests/_mockbin.py owns the shebang (/bin/sh, POSIX bodies β€” the convention four other suites in this repo already carry as hand-written comments) and raises if a call site supplies its own. Both call sites go through it. No assertion was weakened.

Added so the class cannot return:

  • test_the_mock_helper_can_actually_exec β€” named tier precondition, runs in both tiers, never skips.
  • test_no_runtime_written_shebang_uses_usr_bin_env β€” structural scan of the suite's sources, with a positive control proving the scan can flag an offender. Its needles are assembled from char codes: the first version matched its own source lines (the pgrep -f self-match).
  • drive_tool_call now names the rival mechanism on an empty log instead of letting IndexError misreport it.

Two further defects the sandbox exposed β€” neither fixed by weakening

1. _mockbin.py was untracked, so the flake source omitted it (flakes only see tracked files) and the run collapsed to opencode collected=2 errors=2 β€” 168 tests silently not collected, while the global floor (2850) was nowhere near tripping. Caught by comparing collected counts across runs, not by an exit code. Staged.

2. The corruption's SHAPE is shell-dependent. The pre-fix execSync bug has two effects and only one is universal:

  • word-splitting + quote removal β€” every POSIX shell, both tiers;
  • brace expansion of the unquoted JSON object into three b64:payload= args β€” bash only, and it is what produced the exact stored value args_summary:{"name":"…"} in the 2,699 live rows (the workbench runs bash). The sandbox's /bin/sh does not brace-expand.

The negative controls now assert the universal half unconditionally and gate the bash-only signature on _mockbin.shell_does_brace_expansion() β€” a measured probe, with test_brace_expansion_probe_agrees_with_the_shell_node_actually_uses pinning that the probe describes the same shell node's execSync actually runs. Both shapes are asserted; neither branch is a skip.

Both tiers, both numbers

tier command result
dev host nix-shell … python -m pytest scripts/collector/opencode/tests/ 170 passed, 0 skipped
authoritative nix build .#checks.x86_64-linux.pytests opencode collected=170 passed=170 skipped=0
TOTAL collected=4543 passed=4542 skipped=1 failed=0 Β· RESULT: PASS

Progression on the authoritative gate:

revision opencode TOTAL verdict
first push 8306719 166 / 152 passed / 14 failed 4539 / 4524 / 14 failed FAIL
helper added but untracked collected=2 / errors=2 4375 / 4372 / 2 failed FAIL (collection collapse)
403f65f (this branch) 170 / 170 passed 4543 / 4542 / 1 skipped / 0 failed PASS

origin/main baseline in this tier = 4373 collected (the opencode suite was not in the target list at all).

Red/green matrix β€” SANDBOX TIER

Base ref origin/main = 73f5ec0. Method: at 403f65f, git checkout origin/main -- scripts/collector/opencode/activity-plugin.js, run nix build .#checks.x86_64-linux.pytests, restore.

plugin at 73f5ec0 plugin at 403f65f
scripts/collector/opencode/tests collected=170, 159 passed, 11 failed collected=170, 170 passed
TOTAL 4543 / 4531 / 11 failed Β· FAIL 4543 / 4542 / 0 failed Β· PASS

The 11 red-at-base, in the authoritative tier:

test_tool_name_captured_from_string_input
test_tool_name_captured_from_object_form
test_capture_failure_is_distinguishable_from_a_real_tool
test_a_tool_literally_named_unknown_is_not_a_failure
test_no_argv_corruption
test_payload_is_valid_json_with_spaces_and_quotes
test_free_text_fields_with_spaces_survive
test_args_truncation_keeps_payload_parseable
test_duration_is_measured_not_hardcoded_zero
test_duration_absent_when_before_hook_never_fired
test_roundtrip_through_real_emit_and_collector

Green at both are the 2 harness negative controls + 2 positive controls (they drive the vendored buggy code, so they must be green in both β€” correct by construction), and 4 tier/structural guards + test_b64_values_are_real_base64, all labelled in-file as preconditions/invariant guards, not regression coverage.

Crucially, the harness negative controls now pass in the tier that gates merges. On the first push they were 2 of the 14 failures β€” so the harness had never been validated where it counts, and every surviving green there proved nothing.

Control pairs re-confirmed in the sandbox tier

All are pinned equalities that passed inside nix build:

counter positive control under test
corrupted_arg_count (argv entries with no =) 3 β€” universal, asserted unconditionally in both tiers 0
b64:payload= fragments from the buggy emitter 3 on the dev host (bash brace-expands) / 1 in the sandbox β€” branch chosen by the measured probe, both asserted n/a
payload parses as JSON every fragment raises JSONDecodeError (tier-independent) valid JSON

Adjacent finding (not fixed here β€” would re-block this PR)

scripts/dl-router/tests is also absent from run-tests.sh's target list, and test_setup_script.py:134 writes a #!/usr/bin/env bash stub and execs it β€” the same latent sandbox failure. Adding that suite is a separate change; flagging it rather than expanding this PR.

πŸ€– Generated with Claude Code

Existing rows

activity.events is append-only with a 180d TTL. No backfill, no DELETE, no ALTER was attempted. The 2,699 malformed rows stay until they expire. Readers must tolerate both shapes: treat text='unknown' with a non-JSON payload as pre-fix garbage. The clean discriminator is payload.name_captured, which only exists post-fix.

Not verified

The fixed plugin has not been exercised by a real OpenCode session β€” that needs a home-manager switch plus an opencode run, and this task was explicitly told not to switch or restart anything. Every claim above rests on the real hook handlers being driven under node through the real emit into the real collector.parse_line, against the hook shape taken from the installed typings.

ZacxDev added 2 commits August 2, 2026 20:25
…s read text='unknown'

Root-caused two independent defects in scripts/collector/opencode/activity-plugin.js,
both verified against the installed `@opencode-ai/plugin` typings and reproduced
byte-for-byte in tests.

1. THE TOOL NAME WAS NEVER READ.
   `tool.execute.after` did `input?.tool?.name || input?.name || "unknown"`.
   The plugin contract (node_modules/@opencode-ai/plugin/dist/index.d.ts:249) is
   `(input: { tool: string, sessionID, callID, args }, output: {title,output,metadata})`
   β€” `input.tool` is a STRING, so `.name` is undefined on every call and the
   literal fallback won 100% of the time. Fixed by reading the string form
   (with a tolerated `{name}` object form for contract drift).

   The `"unknown"` fallback is itself the hazard: it is a plausible tool name,
   so a totally-broken capture was indistinguishable from real data. A failure
   now writes the namespaced sentinel `__name_capture_failed__` plus
   `name_captured:false` and `name_capture_shape:<typeof>` in the payload, and a
   tool genuinely called "unknown" is still recorded as a real capture.

2. THE PAYLOAD WAS SHELL-MANGLED.
   `emitEvent` ran execSync with the arguments joined into one shell string, all
   values unquoted. Bash then BRACE-EXPANDED the unquoted JSON object into three
   separate `b64:payload=` arguments and `emit` kept the last, which is exactly
   the corruption seen in the live rows:

       b64:payload=duration_ms:0
       b64:payload=success:true
       b64:payload=args_summary:{"name":"customize-opencode"}   <- stored

   Values containing spaces were also word-split (`b64:cwd=/tmp/dir with space`
   became three argv entries). Fixed with `execFileSync(emit, argv)` β€” no shell,
   no splitting, no quote removal.

Also corrected while in there, because both were fabricated measurements:
  * `duration_ms` was `output.duration_ms` β€” a field that does not exist on this
    hook β€” so it was always 0. Now measured across `tool.execute.before` β†’
    `.after` via a bounded callID map, and ABSENT (not 0) when unmeasurable.
  * `success: true` was hard-coded; the hook cannot observe a failure at all
    (OpenCode throws past it). Replaced with `outcome: "completed"`.
  * `session` was `currentSession`, set only by a `session.created` handler that
    the plugin contract never calls. Now taken from `input.sessionID`.
  * `args_summary` was a raw `.slice(0, 200)` of a JSON string, which produces
    invalid JSON on truncation. Over-budget args now degrade to a structured
    marker `{"_truncated":true,"keys":[...],"bytes":N}`.

GATE: scripts/collector/opencode/tests was NOT in run-tests.sh's target list β€”
166 tests that no gate has ever run, which is why this shipped. Added (this is
the only line of run-tests.sh touched: the pytest dir list, ~line 172).

READS MUST TOLERATE BOTH SHAPES. activity.events is append-only with a 180d TTL,
so the 2,699 malformed rows stay until they expire. No backfill and no
DELETE/ALTER was attempted. Any consumer of source='opencode' kind='tool-call'
must treat text='unknown' and a non-JSON payload as pre-fix garbage; the clean
discriminator is `payload.name_captured`, which only exists post-fix.
…sandbox

The previous commit was green on the dev host (`nix-shell`, 166/166) and RED on
the authoritative gate (`nix build .#checks.x86_64-linux.pytests`):
`collected=4539 passed=4524 skipped=1 failed=14 RESULT: FAIL`. I reported the
dev-host number as if it were the gate. It is not.

ROOT CAUSE. Both `write_mock_emit` (pre-existing) and `write_argv_recorder` (new)
write a stub script AT RUNTIME with `#!/usr/bin/env bash`.
  * NixOS dev host: /usr/bin/env exists (symlink into coreutils) β†’ stub execs.
  * nix build sandbox: no /usr/bin/env β†’ execve fails ENOENT.
nixpkgs' `patchShebangs src/scripts` (flake.nix) fixes shebangs of files IN THE
SOURCE TREE β€” which is why the real `scripts/collector/emit` works in the sandbox
β€” but it cannot touch a file a test creates while running. So the defect was
structurally unobservable in the tier I measured.

It was also mostly SILENT: `emitEvent` swallows every error by design, so node
still exits 0 when the stub cannot be exec'd; the tests failed later on an empty
list, pointing at the wrong thing.

FIX β€” deterministic, one place. New `tests/_mockbin.py` owns the shebang
(`/bin/sh`, POSIX bodies β€” the convention four other suites in this repo already
carry as hand-written comments) and refuses a body that supplies its own. Both
call sites go through it. Assertions are unchanged; the helper is fixed.

Plus, so the class cannot return:
  * `test_the_mock_helper_can_actually_exec` β€” named tier precondition, runs in
    both tiers, never skips.
  * `test_no_runtime_written_shebang_uses_usr_bin_env` β€” structural scan of the
    suite's sources, with a positive control proving the scan can flag an
    offender. Its needles are assembled from char codes: the first version
    matched its OWN source lines (the `pgrep -f` self-match).
  * `drive_tool_call` now names the rival mechanism on an empty log instead of
    letting `IndexError` misreport it.

TWO FURTHER DEFECTS THE SANDBOX FOUND, both real, neither fixed by weakening:

1. `_mockbin.py` was UNTRACKED, so the flake source omitted it and the sandbox
   run collapsed to `collected=2 errors=2` β€” 168 tests silently not collected
   while the per-directory floor (2850 global) was nowhere near tripping. Caught
   by comparing COLLECTED COUNTS across runs, not by the exit code. Staged.

2. The corruption's SHAPE is shell-dependent. The pre-fix `execSync` bug has two
   effects and only one is universal:
     * word-splitting + quote removal β€” every POSIX shell, both tiers;
     * brace expansion of the unquoted JSON object into three `b64:payload=`
       args β€” bash only, and it is what produced the exact stored value
       `args_summary:{"name":"…"}` in the 2,699 live rows (the workbench runs
       bash). The sandbox's /bin/sh does not brace-expand.
   The negative controls now assert the universal half unconditionally and gate
   the bash-only signature on `_mockbin.shell_does_brace_expansion()`, a MEASURED
   probe β€” with `test_brace_expansion_probe_agrees_with_the_shell_node_actually_uses`
   pinning that the probe describes the same shell node's execSync runs. Both
   shapes are asserted; neither branch is a skip.

TIERS, both reported:
  dev host  `nix-shell`                        opencode 170/170
  SANDBOX   `nix build .#checks…pytests`       opencode collected=170 passed=170
                                               TOTAL collected=4543 passed=4542
                                               skipped=1 failed=0  RESULT: PASS
@ZacxDev
ZacxDev merged commit 34152d6 into main Aug 3, 2026
ZacxDev added a commit that referenced this pull request Aug 3, 2026
…eploy the plugin declaratively (#302)

* fix(opencode): a non-function export in #298 silently killed ALL activity telemetry

opencode's plugin loader iterates EVERY named export of a plugin module and
requires each to be a function. #298 added `export const _internals = {...}`
alongside three exported helpers, so opencode rejected activity-plugin.js
outright β€” "Plugin export is not a function" β€” and every hook stopped running
the moment ship.sh deployed it. `emitEvent` swallows all errors by design, so
the outage was completely silent.

Measured on opencode 1.18.4 (2026-08-02) with probe plugins in the real
~/.config/opencode/plugin/, read off `opencode run --print-logs`:
  - object export + plugin factory  β†’ whole module rejected
  - plugin factory alone            β†’ loads clean
  - two function exports            β†’ BOTH invoked as plugin factories
The last kind=tool-call row is 2026-08-03 02:32 UTC; #298 landed 21:47 local
and ship.sh deployed it at 21:48.

Two things this proves are NOT the cause, both of which looked plausible:
the 1.18.9 β†’ 1.18.4 downgrade (the probes discriminate at a fixed version),
and the plugin directory (the loader glob is {plugin,plugins}/*.{ts,js} and
1.18.4 demonstrably reads BOTH β€” it logged a load error for each path).

Fixes:
  - export only ActivityPlugin; helpers are module-private again.
  - deploy declaratively from nix/home.nix into the singular plugin/ dir,
    exactly like guard.js and env.js. This replaces deploy-plugin.sh, a
    hand-run script that had to be remembered per host and was not: it ran on
    the workbench on 2026-07-29 and NEVER on the laptop, which recorded zero
    kind=tool-call rows for the plugin's entire existence.
  - an activation step removes the plural-dir symlink the old script left on
    the workbench, which would otherwise load the plugin twice and double-emit.

Tests: the three tests removed here exercised deploy-plugin.sh and stayed green
through the whole outage β€” "the script makes a symlink" says nothing about
whether opencode can load what it points at. Replaced with the loader contract
(every named export is a function; exactly one export) and the deployment
declaration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(opencode): the switch that deploys the plugin would have ABORTED on the workbench

home.file now owns ~/.config/opencode/plugin/activity.js, but a hand-made
symlink already sits at exactly that path on the workbench (created 2026-08-02
while diagnosing the outage). checkLinkTargets refuses to clobber a
non-store file at a managed path and aborts the ENTIRE switch β€” and `force`
does not displace it. So the activation step has to run entryBefore
checkLinkTargets, not after writeBoundary.

Widened to clean BOTH stale copies: the singular path for the reason above,
and the plural plugins/ path because opencode globs {plugin,plugins}/*.{ts,js}
and reads both, so leaving it would load the plugin twice and double-emit.

Only ever removes a symlink pointing at the repo's activity-plugin.js; a store
symlink, a real file, or a foreign plugin is left untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev added a commit that referenced this pull request Aug 3, 2026
…run (#306)

`scripts/dl-router/tests` was absent from `scripts/run-tests.sh`'s target
list since the suite was written: 989 tests, zero of them gated. Same
declarations-vs-instances shape as #298 (166 opencode tests) and #276 (913
guard-core tests) β€” one missing line in a target list.

Adding it surfaced two SANDBOX-ONLY portability defects, both of the kind
that are structurally invisible on a dev host:

  * test_setup_script.py's write_pgrep() wrote a stub with
    `#!/usr/bin/env bash`. The nix build sandbox has no /usr/bin/env;
    every NixOS dev host does. Now goes through the helper #298 landed.
  * test_cli.py / test_server_wiring.py hard-coded port 8799 for their
    "sidecar is DOWN/unreachable" assertions. That is a claim about the
    whole machine, not the test β€” an orphaned python3 (pid 2994086, started
    Aug 1 13:40, ppid 1) was listening on it and the test reached a REAL
    dl-router, failing with `sidecar HTTP 409: not_owned_tab`. Ports now
    come from a `closed_port` fixture.

Shared instead of re-derived (RULES.md "One rule, one place"):
  * `scripts/collector/opencode/tests/_mockbin.py` -> `scripts/testlib/
    mockbin.py`, importable by any suite.
  * #298's runtime-shebang scan was scoped to ONE directory, which is
    exactly why it could not see the dl-router defect. The scanner moved to
    `scripts/testlib/shebang_scan.py` and the guard is now REPO-WIDE
    (`scripts/tests/test_runtime_shebangs.py`) with a pinned allowlist that
    fails BOTH ways β€” an unpinned offender and a pin that matches nothing.

MIN_TESTS 2850 -> 5600. The old floor had drifted below HALF the real
total, so a whole 989-test suite could have vanished underneath it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev added a commit that referenced this pull request Aug 3, 2026
…laims it disproved (#308)

Records the arc: an audit of the Claude/opencode setup + activity telemetry that
turned into 12 merged PRs across devrc and one Flux commit on homelab-talos.

Headline measurements: always-on session context 63,954 -> 43,420 B; opencode
AGENTS.md 47,741 -> 39,280 B; ClickHouse store 112.4 GB -> 82.1 MB;
MEMORY_LIMIT_EXCEEDED 113,378 -> 0; gated tests 4,373 -> 5,792.

Four claims asserted during the session and later DISPROVED are recorded with
the evidence, because each was believed and acted on for a while:

  1. "ClickHouse is full because activity.events/payload is too big" β€” it is
     27 MiB, 0.03% of the instance; the cause was un-TTL'd system.trace_log.
  2. "opencode ignores its declared model, and nav has zero turns" β€” the config
     had landed 29 days AFTER the telemetry window started, and zero opencode
     turns had occurred since the deploy.
  3. "the clickup skill does not ship to the workbench" β€” ship.sh:368 already
     rsyncs it; and moving it into the nix store would have turned a 0600
     credential file into 0444 world-readable.
  4. "A1 fixed insights.py" β€” it fixed the memory ceiling and unmasked a
     different fault (Code 209 socket timeout on the nebula path only).

Also records the outage this session CAUSED: PR #298's `export const _internals`
killed all opencode telemetry on both hosts for ~11 hours, because opencode's
loader rejects a whole plugin module if any named export is not a function, and
emitEvent swallows every error by design. It was found by luck, which is the
justification for the telemetry deadman work now in flight.

The through-line, and the reason this doc exists: every single defect found was
SILENT. A guard that structurally could not fire, a plugin that did not load,
1,497 tests never in a runner's target list, insights.py exiting 0 on a healthy
pipeline, harnesses that tested nothing, and a runner printing FAIL while
exiting 0.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev added a commit that referenced this pull request Aug 3, 2026
…ver run (#309)

`scripts/run-node-tests.sh` hard-coded its collection to one directory:

    FILES=(scripts/browser-bridge/tests/*.test.mjs)

so every other `.test.mjs` suite in the repo was invisible to the only check
that runs node. Measured at origin/main (13bc8bd):

  * scripts/dl-router/tests              508 tests, never gated
  * scripts/collector/browser-ext/tests   21 tests, never gated

529 tests, ungated since each suite was written. The gate reported
`RESULT: PASS` with a 468-test total the whole time β€” a hard-coded list cannot
know what it is not looking at. Fourth instance of the same shape (#276: 913
pytest tests behind one list entry, #298: 166, #306: 989).

Adding two lines would leave the NEXT suite ungated identically, so collection
is now DISCOVERY (bash globstar over scripts/**/*.test.mjs) plus a TWO-WAY PIN:

  * a discovered directory absent from SUITES  -> FATAL (forces an accounting
    entry with a measured floor, rather than being swept in under the total)
  * a pinned suite discovery does not find     -> FATAL (the suite vanished)

Discovery alone would reintroduce the silent-collapse hole the old hard-coded
glob at least did not have: an emptied dl-router/tests would just collect fewer
files and still pass over a global floor.

Each suite also runs in its OWN `node --test` invocation with its own TAP
summary and its own floor. A single global floor of 970 is fully satisfied by
browser-bridge (468) + dl-router (508) with browser-ext's 21 tests entirely
gone; per-suite floors make that loud.

A PORTABILITY DEFECT found while building this, worth recording because the
first draft shipped it and the harness hid it: discovery used
`find -printf '%h\n' 2>/dev/null`. `-printf` is a GNU extension, and THREE
different `find`s are reachable from this repo β€” busybox under bash
(~/.nix-profile/bin/find, rejects it), bfs 4.1.1 under the interactive zsh, GNU
findutils in the nix sandbox. Paired with `2>/dev/null` the rejection became an
EMPTY discovery list with no error β€” a false "no suites found". Collection is
now a bash builtin (globstar), which depends on no external binary and behaves
identically in every tier; `test_runner_does_not_use_find_printf` pins it.

MEASURED, both tiers:

  nix build .#checks.x86_64-linux.nodetests   997 tests / 997 pass / 0 fail
  bash scripts/run-node-tests.sh (dev host)   997 tests / 997 pass / 0 fail

  468 (baseline, browser-bridge) + 508 + 21 = 997. No unexplained delta.

scripts/tests/test_run_node_tests_suites.py guards the pin, with its regression
and invariant guards labelled honestly in the module docstring, mutation proofs
that the guard goes red naming the offending directory, and a positive control
on both parsers (the reassuring answer here is an empty set, which an unwired
harness also produces).

The 529 newly-gated tests are NOT regression coverage for this change β€” they
are pre-existing tests whose value is that they now run at all.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev added a commit that referenced this pull request Aug 3, 2026
…onger be silent (#311)

PR #298 killed all opencode telemetry on both hosts for ~11 hours and nothing
reported it; `emitEvent` swallows every error by design, and "no rows" is exactly
what a healthy-but-idle source looks like. It was found by luck. Two more of the
same shape were already sitting in the data: the laptop had zero `kind=tool-call`
rows for its entire existence, and `kind=session-create` has never emitted a row.

New: `scripts/collector/deadman.py` β€” per (host, source) liveness over
`activity.events`, surfaced as the `tlm` pill on the workbench i3 bar (a new
`telemetry` source in `bar-status-poll`, signal 17, + a rising-edge dunst toast).
One workbench runner covers BOTH hosts because it reads the shared table.

What makes it not cry wolf, and not lie:

- Silence is counted in ACTIVE time (5-min buckets in which ANY source on that
  host emitted), not wall time. Overnight/away time is not in the set, so an
  on-demand source is not punished for the operator being asleep.
- The budget is MEASURED per pair: clamp(2 x p99 active-gap, 2h, 48h). Measured
  2026-08-03: keys/i3/tmux land on the 2h floor, workbench/opencode 11.5h,
  workbench/tool 31.1h. Nothing is hand-tuned; the cap (48 active hours) sits
  above the largest gap observed anywhere in 14 days (224 buckets).
- Expected-present is measured too: a pair is judged only if it cleared a
  baseline, so workbench/browser (0 rows, correctly absent) can never alarm.
- "Cannot tell" is not "healthy": not-configured / unreachable / query-failed /
  no-data are each their own state, and `ok` is unreachable unless rows came back
  AND >=1 pair was measured. A persistent unknown shows a VISIBLE `tlm ?` pill
  (grace-gated 30 min) rather than the empty block every other source renders.

Docs corrected against measurement, not prose: the activity SKILL.md claimed
keylog/i3/browser were "GUI-only -> laptop only; the workbench is headless". The
workbench emits i3 (41,001 rows) and keys (37,376), both fresh; only `browser` is
genuinely laptop-only.

Controls (a zero is never reported alone):
  positive  1 dead on live data with workbench/keys silenced 6h; 0 on the same
            data untouched, same code path
  negative  dead endpoint -> `unreachable` + `tlm ?`, never green
  mutation  17/17 mutants killed, incl. a byte-identical no-op control that
            stayed green

Gate: pytests 5850 collected / 5849 passed / 1 skipped / 0 failed in BOTH tiers
(nix sandbox and dev host); nodetests 468/468.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant