suite gate fails BinMissingThorGuardTest under the runner's subprocess environment while the suite passes directly #594

Closed
opened 2026-09-15 14:19:52 +00:00 by jared · 4 comments
Owner

Context

Implementation 19 on ticket #588 ran the os-backlog target through the poodr-ticket-implementation map. The run ended implementation_failed with bound_exhausted:suite-check/fail after two programmer-repair rounds. The suite gate reported one red, BinMissingThorGuardTest, that a direct run of the same suite does not report.

Diagnosis (2026-09-15, verified by a reproducing loop)

plugins/os-sdlc/lib/os_sdlc/boot.rb requires bundler/setup. Bundler writes RUBYOPT=-r.../bundler/setup into the runner's own ENV. Every child the runner spawns without Bundler.with_unbundled_env inherits it. The test hides thor with ruby --disable-gems; that flag does not block an explicit -r from RUBYOPT, so the os-sdlc Gemfile's thor lands on the child's load path and the bin succeeds.

Load-bearing factor: RUBYOPT alone. BUNDLE_GEMFILE alone stays green.

Reproduce, red:

env RUBYOPT="-r$(ruby -e 'print Gem.default_dir')/../../site_ruby/$(ruby -e 'print RUBY_VERSION[/\d+\.\d+/]').0/bundler/setup" \
  ruby plugins/os-backlog/tests/cli_test.rb -n /thor_is_unavailable/

Simpler equivalent on this machine:

env RUBYOPT="-r/home/jared/.rbenv/versions/4.0.2/lib/ruby/site_ruby/4.0.0/bundler/setup" \
  ruby plugins/os-backlog/tests/cli_test.rb -n /thor_is_unavailable/

Green: the same command without RUBYOPT.

Blast radius audit: 13 spawn sites in os-sdlc. Three run a project-supplied command and inherit the leak. The rubocop sites are unaffected (rubocop resolves through the rbenv shim and loads Sdlc cops through its own require: paths; verified green with and without the scrub). git, tea, ast-grep, pbm are indifferent. hook/pipe.rb re-invokes the runner, which self-boots, so it must NOT be scrubbed.

Scope

Files that change:

  • plugins/os-sdlc/lib/os_sdlc/runner/gate_evaluator.rb run_gate (the named bug)
  • plugins/os-sdlc/lib/os_sdlc/test_runner.rb run (green/test command, same leak)
  • plugins/os-sdlc/lib/os_sdlc/project_config_command.rb test_command_clean_exit? (candidate test_command validation, same leak)

Out of scope, separate ticket: a map route for a suite red in a file outside the diff (suite-check/fail routes unconditionally to programmer-repair today).

Owner sketch

One object owns the fact "a project-supplied command runs with the environment a terminal would give it". The three call sites delegate to it.

# plugins/os-sdlc/lib/os_sdlc/project_shell.rb
module OsSdlc
  # Runs a command supplied by the target project. Bundler's setup leaves RUBYOPT and
  # BUNDLE_* in the runner's ENV; a project command must not inherit them, or its
  # verdict differs from a direct terminal run (#594).
  module ProjectShell
    module_function

    def capture3(command)
      Bundler.with_unbundled_env { Open3.capture3(command) }
    end

    def capture2e(command)
      Bundler.with_unbundled_env { Open3.capture2e(command) }
    end
  end
end

Callers:

# gate_evaluator.rb
stdout, stderr, status = ProjectShell.capture3(command)
# test_runner.rb
output, status = ProjectShell.capture2e(command)
# project_config_command.rb
_out, _err, status = ProjectShell.capture3(command)

Bundler is loaded in every entry point; all bin/ and hooks/ files require boot first (verified).

Cases

  1. Given the runner's ENV carries RUBYOPT=-rbundler/setup, when GateEvaluator runs ruby -e 'print ENV.fetch("RUBYOPT", "")', then stdout does not contain bundler/setup. Red today.
  2. Given the same ENV, when TestRunner#run runs that command, then output does not contain bundler/setup. Red today.
  3. Given the same ENV, when ProjectConfigCommand validates a candidate test_command that exits 0 only when RUBYOPT is unset, then it is accepted. Red today.
  4. Given no RUBYOPT in the runner's ENV, when a gate command runs, then behavior is unchanged from today. Green today, must stay green.
  5. After the block, the runner's own ENV still carries BUNDLE_GEMFILE and RUBYOPT (with_unbundled_env restores). Guards the runner's later requires.

Verification against the original symptom: run the suite gate from the runner on the os-backlog target; BinMissingThorGuardTest passes.

Expected

The gate's verdict matches a direct run for every project-supplied command.

Origin

  • Trigger: implementation 19 on ticket #588, os-backlog target, poodr-ticket-implementation map, dispatch 255, step "suite" (step_results row id 326)
  • Chain: suite gate false red ← child inherits RUBYOPT from Bundler setup in the runner ← three project-command spawn sites call Open3 without with_unbundled_env. Stop: DESIGN.
  • ADRs: no Accepted ADR decides child env. ADR-0166 (boot file) is amended, not reversed. ADR-0082 lint cops verified unaffected. Record the child-env convention with /os-adr:create amending 0166.
  • Session: b0afb79b-ca2f-425a-8701-d8cccbc2831a
## Context Implementation 19 on ticket #588 ran the os-backlog target through the poodr-ticket-implementation map. The run ended implementation_failed with bound_exhausted:suite-check/fail after two programmer-repair rounds. The suite gate reported one red, `BinMissingThorGuardTest`, that a direct run of the same suite does not report. ## Diagnosis (2026-09-15, verified by a reproducing loop) `plugins/os-sdlc/lib/os_sdlc/boot.rb` requires `bundler/setup`. Bundler writes `RUBYOPT=-r.../bundler/setup` into the runner's own ENV. Every child the runner spawns without `Bundler.with_unbundled_env` inherits it. The test hides thor with `ruby --disable-gems`; that flag does not block an explicit `-r` from `RUBYOPT`, so the os-sdlc Gemfile's thor lands on the child's load path and the bin succeeds. Load-bearing factor: `RUBYOPT` alone. `BUNDLE_GEMFILE` alone stays green. Reproduce, red: env RUBYOPT="-r$(ruby -e 'print Gem.default_dir')/../../site_ruby/$(ruby -e 'print RUBY_VERSION[/\d+\.\d+/]').0/bundler/setup" \ ruby plugins/os-backlog/tests/cli_test.rb -n /thor_is_unavailable/ Simpler equivalent on this machine: env RUBYOPT="-r/home/jared/.rbenv/versions/4.0.2/lib/ruby/site_ruby/4.0.0/bundler/setup" \ ruby plugins/os-backlog/tests/cli_test.rb -n /thor_is_unavailable/ Green: the same command without `RUBYOPT`. Blast radius audit: 13 spawn sites in os-sdlc. Three run a project-supplied command and inherit the leak. The rubocop sites are unaffected (rubocop resolves through the rbenv shim and loads Sdlc cops through its own `require:` paths; verified green with and without the scrub). git, tea, ast-grep, pbm are indifferent. `hook/pipe.rb` re-invokes the runner, which self-boots, so it must NOT be scrubbed. ## Scope Files that change: - `plugins/os-sdlc/lib/os_sdlc/runner/gate_evaluator.rb` `run_gate` (the named bug) - `plugins/os-sdlc/lib/os_sdlc/test_runner.rb` `run` (green/test command, same leak) - `plugins/os-sdlc/lib/os_sdlc/project_config_command.rb` `test_command_clean_exit?` (candidate test_command validation, same leak) Out of scope, separate ticket: a map route for a suite red in a file outside the diff (suite-check/fail routes unconditionally to programmer-repair today). ## Owner sketch One object owns the fact "a project-supplied command runs with the environment a terminal would give it". The three call sites delegate to it. # plugins/os-sdlc/lib/os_sdlc/project_shell.rb module OsSdlc # Runs a command supplied by the target project. Bundler's setup leaves RUBYOPT and # BUNDLE_* in the runner's ENV; a project command must not inherit them, or its # verdict differs from a direct terminal run (#594). module ProjectShell module_function def capture3(command) Bundler.with_unbundled_env { Open3.capture3(command) } end def capture2e(command) Bundler.with_unbundled_env { Open3.capture2e(command) } end end end Callers: # gate_evaluator.rb stdout, stderr, status = ProjectShell.capture3(command) # test_runner.rb output, status = ProjectShell.capture2e(command) # project_config_command.rb _out, _err, status = ProjectShell.capture3(command) `Bundler` is loaded in every entry point; all bin/ and hooks/ files require boot first (verified). ## Cases 1. Given the runner's ENV carries `RUBYOPT=-rbundler/setup`, when `GateEvaluator` runs `ruby -e 'print ENV.fetch("RUBYOPT", "")'`, then stdout does not contain `bundler/setup`. Red today. 2. Given the same ENV, when `TestRunner#run` runs that command, then output does not contain `bundler/setup`. Red today. 3. Given the same ENV, when `ProjectConfigCommand` validates a candidate test_command that exits 0 only when `RUBYOPT` is unset, then it is accepted. Red today. 4. Given no `RUBYOPT` in the runner's ENV, when a gate command runs, then behavior is unchanged from today. Green today, must stay green. 5. After the block, the runner's own ENV still carries `BUNDLE_GEMFILE` and `RUBYOPT` (with_unbundled_env restores). Guards the runner's later requires. Verification against the original symptom: run the suite gate from the runner on the os-backlog target; `BinMissingThorGuardTest` passes. ## Expected The gate's verdict matches a direct run for every project-supplied command. ## Origin - Trigger: implementation 19 on ticket #588, os-backlog target, poodr-ticket-implementation map, dispatch 255, step "suite" (step_results row id 326) - Chain: suite gate false red ← child inherits RUBYOPT from Bundler setup in the runner ← three project-command spawn sites call Open3 without with_unbundled_env. Stop: DESIGN. - ADRs: no Accepted ADR decides child env. ADR-0166 (boot file) is amended, not reversed. ADR-0082 lint cops verified unaffected. Record the child-env convention with /os-adr:create amending 0166. - Session: b0afb79b-ca2f-425a-8701-d8cccbc2831a
Author
Owner

Work starting via /os-sdlc:implement in a worktree. Diagnosis and scope written to the body (three spawn sites, ProjectShell owner).

Work starting via /os-sdlc:implement in a worktree. Diagnosis and scope written to the body (three spawn sites, ProjectShell owner).
Author
Owner

Implementation 20 (poodr-ticket-implementation, target os-sdlc, worktree ticket-594) ended implementation_failed: bound_exhausted:test-reviewer/fail. Three test-writer rounds (258, 260, 262), three reviewer rejections (259, 261, 264). Diff-test gate was red for the intended reason each round; test-lint green. Reviewing the reviewer handoffs to see whether the feedback was actionable before a rerun.

Implementation 20 (poodr-ticket-implementation, target os-sdlc, worktree ticket-594) ended implementation_failed: bound_exhausted:test-reviewer/fail. Three test-writer rounds (258, 260, 262), three reviewer rejections (259, 261, 264). Diff-test gate was red for the intended reason each round; test-lint green. Reviewing the reviewer handoffs to see whether the feedback was actionable before a rerun.
Author
Owner

Implementation 22 on branch ticket-594: 11 dispatches, one refactorer round (require hygiene), suite 1209/0 twice. Second contract-auditor returned error on a test-side defect only: the test-writer had overwritten the entry file plugins/os-sdlc/tests/project_config_command_test.rb, orphaning project_root_and_config_ticket_583_test.rb. Hand-restored both requires, full suite 1209 runs 0 failures, rubocop clean on 8 changed files, BinMissingThorGuardTest passes through the real GateEvaluator with the runner's RUBYOPT set. Commit bc19155. Also filed #596 (seam-designer public-boundary self-check) from the implementation-20 handoff audit.

Implementation 22 on branch ticket-594: 11 dispatches, one refactorer round (require hygiene), suite 1209/0 twice. Second contract-auditor returned error on a test-side defect only: the test-writer had overwritten the entry file plugins/os-sdlc/tests/project_config_command_test.rb, orphaning project_root_and_config_ticket_583_test.rb. Hand-restored both requires, full suite 1209 runs 0 failures, rubocop clean on 8 changed files, BinMissingThorGuardTest passes through the real GateEvaluator with the runner's RUBYOPT set. Commit bc19155. Also filed #596 (seam-designer public-boundary self-check) from the implementation-20 handoff audit.
Author
Owner

Resolution

Done: OsSdlc::ProjectShell owns the project-command environment; GateEvaluator, TestRunner and ProjectConfigCommand spawn project-supplied commands through Bundler.with_unbundled_env. Gate verdicts now match a direct terminal run. ADR-0191 amends ADR-0166; ADR-0166 stale affected-path corrected.

Evidence: Merged to main be6d047 (fix bc19155, docs 3c7862a). Loop: env RUBYOPT=-r/bundler/setup ruby plugins/os-backlog/tests/cli_test.rb -n /thor_is_unavailable/ was red before; BinMissingThorGuardTest passes through the real GateEvaluator with the runner's RUBYOPT set after. os-sdlc suite 1209 runs 0 failures; rubocop clean on 8 changed files. Blast-radius audit: 13 spawn sites, 3 changed, rubocop/git/tea/ast-grep/pbm/Hook::Pipe verified indifferent.

Follow-ups: #596 seam-designer public-boundary self-check (filed, user-approved). Map route for a suite red in a file outside the diff: not filed, user decides. Test-writer replacing an existing entry file's require (orphaned ticket-583 tests, hand-fixed): none, lesson recorded in the os-sdlc status leaf.

## Resolution **Done:** OsSdlc::ProjectShell owns the project-command environment; GateEvaluator, TestRunner and ProjectConfigCommand spawn project-supplied commands through Bundler.with_unbundled_env. Gate verdicts now match a direct terminal run. ADR-0191 amends ADR-0166; ADR-0166 stale affected-path corrected. **Evidence:** Merged to main be6d047 (fix bc19155, docs 3c7862a). Loop: env RUBYOPT=-r<sitelib>/bundler/setup ruby plugins/os-backlog/tests/cli_test.rb -n /thor_is_unavailable/ was red before; BinMissingThorGuardTest passes through the real GateEvaluator with the runner's RUBYOPT set after. os-sdlc suite 1209 runs 0 failures; rubocop clean on 8 changed files. Blast-radius audit: 13 spawn sites, 3 changed, rubocop/git/tea/ast-grep/pbm/Hook::Pipe verified indifferent. **Follow-ups:** #596 seam-designer public-boundary self-check (filed, user-approved). Map route for a suite red in a file outside the diff: not filed, user decides. Test-writer replacing an existing entry file's require (orphaned ticket-583 tests, hand-fixed): none, lesson recorded in the os-sdlc status leaf.
jared 2026-09-15 15:47:54 +00:00
  • closed this issue
  • removed the
    waiting
    label
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#594
No description provided.