os-sdlc-runner: Pipeline::CLI receives an Invocation; bin/os-sdlc-runner is the only reader of process globals #638

Closed
opened 2026-09-23 13:04:18 +00:00 by jared · 3 comments
Owner

Map: #631

Context

Child of #631. Absorbs the in-process test conversion and the per-binary smoke test from #628; #628 keeps only the template-DB copy. Most Cli*Test tests already run in-process since 3871ecb5 (2026-08-23) through the test-side InProcessRunnerCli (plugins/os-sdlc/tests/support/in_process_runner_cli.rb). That helper bypasses Thor with its own copy of the command table: it has no implementation-close, retired-tests-check, violations, or implementation-report, and it drops every option such as --target. The tests for those commands still fork the binary.

Observed

OsSdlc::Pipeline::CLI (plugins/os-sdlc/lib/os_sdlc/pipeline/cli.rb) reads process globals on 12 lines: Dir.pwd at 31, 39, 51, 54, 59, 64, 71, 76, 87, 91 and $stdin at 37, 45, 51. Its output uses puts/warn on the process streams (46, 65, 76, 97; start rescue at 23).

Test methods that still fork bin/os-sdlc-runner:

tests/pipeline/cli_implementation_open_test.rb         11 of 11 (private run_cli, line 126)
tests/pipeline/cli_implementation_open_target_test.rb   4 of 4  (private run_cli, line 67)
tests/pipeline/cli_implementation_close_test.rb         3 of 3  (private run_cli, line 76)
tests/model/implementation_report_cli_test.rb           1       (run_sweep_cli, line 84)
tests/pipeline/cli_next_instruction_test.rb             4 test_smoke_* methods
tests/pipeline/cli_subagent_start_test.rb               3 test_smoke_* methods
tests/pipeline/cli_subagent_stop_test.rb                3 test_smoke_* methods
tests/pipeline/gate_cli_test.rb                         3 test_smoke_* methods

Measured 2026-09-23: test_next_routes_a_pass_verdict_to_the_maps_next_node takes 0.33 s alone and 1.4-1.8 s when its file runs in parallel. Most of the per-test cost in CliNextInstructionTest is in-process contention or per-test DB setup, not forks.

Reproduce

cd plugins/os-sdlc && ruby -Itests tests/pipeline/cli_implementation_open_test.rb -v

Expected

def test_implementation_open_writes_to_the_invocation_cwd
  invocation = OsSdlc::CLI::Invocation.new(cwd: project, stdin: StringIO.new, stdout: out, stderr: err, env: {})
  status = exit_status { OsSdlc::Pipeline::CLI.start(["implementation-open", "session-1", "442", TOY_MAP], invocation: invocation) }
  assert_equal 0, status
  assert_equal "session-1", JSON.parse(out.string)["session_id"]
end

Origin

  • Trigger: decomposition of #631, 2026-09-23 (user-decided split)
  • Improvised this session: none
  • Chain: slow suite ← per-test binary fork ← Pipeline::CLI reads Dir.pwd/$stdin (DESIGN, pipeline/cli.rb) ← test-side InProcessRunnerCli copies the Thor table instead of calling it (DESIGN, 3871ecb5)
  • Root candidate: this ticket
  • Where: plugins/os-sdlc/lib/os_sdlc/pipeline/cli.rb; plugins/os-sdlc/bin/os-sdlc-runner; plugins/os-sdlc/tests/support/in_process_runner_cli.rb
  • Session: 5bb052ff-ddf2-4d47-a283-d00d071e9b79
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/5bb052ff-ddf2-4d47-a283-d00d071e9b79.jsonl

Owner sketch

class OsSdlc::CLI::Invocation   # lib/os_sdlc/cli/invocation.rb; Data.define(:cwd, :stdin, :stdout, :stderr, :env)
  def self.from_process
end

class OsSdlc::Pipeline::CLI < Thor
  def self.start(given_args, config)
  def initialize(args = [], local_options = {}, config = {})
  def implementation_open(*args)
  def next(*args)
  def subagent_start
  def subagent_stop
  def gate(*args)
  def brief
  def retired_tests_check(*args)
  def implementation_close(*args)
  def violations(*args)
  def implementation_report(*args)
end

Cases

  1. Given an Invocation whose cwd is a temp project other than the process cwd, when Pipeline::CLI.start(["implementation-open", "session-1", "442", map], invocation:) runs, then the implementation row is written to that project's pipeline DB.
  2. Given that run, when it exits, then the JSON result is on invocation.stdout, the process $stdout receives nothing, and the exit status is 0.
  3. Given a project with targets, when implementation-open --target os-backlog runs through the seam, then the implementation row records target os-backlog.
  4. Given a subagent-stop JSON payload on invocation.stdin, when subagent-stop runs, then the dispatch records the verdict from that payload.
  5. Given a PostToolUse event JSON on invocation.stdin and no argv, when next runs, then its output equals the output of next session-1 for the same session.
  6. Given an unknown session, when next does-not-exist runs through the seam, then invocation.stderr holds one line that starts with os-sdlc-runner: harness error: and the exit status is 3.
  7. Given a seeded project, when the real bin/os-sdlc-runner implementation-open runs as a subprocess, then it exits 0 and prints JSON with the session id on stdout.
  8. Given plugins/os-sdlc/lib/os_sdlc/pipeline/cli.rb, when searched for Dir.pwd and $stdin, then there is no match.

Retired tests

plugins/os-sdlc/tests/pipeline/cli_next_instruction_test.rb#test_smoke_next_boots_the_real_binary_and_returns_json_on_stdout
plugins/os-sdlc/tests/pipeline/cli_next_instruction_test.rb#test_smoke_next_exits_nonzero_over_a_real_process_on_an_unknown_session
plugins/os-sdlc/tests/pipeline/cli_next_instruction_test.rb#test_smoke_next_returns_the_same_json_across_repeated_real_process_calls
plugins/os-sdlc/tests/pipeline/cli_next_instruction_test.rb#test_smoke_next_from_a_stdin_event_matches_the_argv_form
plugins/os-sdlc/tests/pipeline/cli_subagent_start_test.rb#test_smoke_subagent_start_boots_the_real_binary_and_returns_a_dispatch_brief
plugins/os-sdlc/tests/pipeline/cli_subagent_start_test.rb#test_smoke_subagent_start_exits_nonzero_over_a_real_process_without_an_implementation
plugins/os-sdlc/tests/pipeline/cli_subagent_start_test.rb#test_smoke_subagent_start_creates_a_real_dispatch_row_via_the_real_process
plugins/os-sdlc/tests/pipeline/cli_subagent_stop_test.rb#test_smoke_subagent_stop_boots_the_real_binary_and_records_a_handoff
plugins/os-sdlc/tests/pipeline/cli_subagent_stop_test.rb#test_smoke_subagent_stop_exits_nonzero_over_a_real_process_on_an_unknown_dispatch
plugins/os-sdlc/tests/pipeline/cli_subagent_stop_test.rb#test_smoke_subagent_stop_records_a_real_verdict_via_the_real_process
plugins/os-sdlc/tests/pipeline/gate_cli_test.rb#test_smoke_gate_boots_the_real_binary_and_stamps_a_pass_verdict
plugins/os-sdlc/tests/pipeline/gate_cli_test.rb#test_smoke_gate_exits_nonzero_over_a_real_process_on_an_unknown_dispatch
plugins/os-sdlc/tests/pipeline/gate_cli_test.rb#test_smoke_gate_stamps_a_real_fail_verdict_via_the_real_process

Approach

# bin/os-sdlc-runner
OsSdlc::Pipeline::CLI.start(ARGV, invocation: OsSdlc::CLI::Invocation.from_process)

# Pipeline::CLI: Thor passes the start config to new(args, opts, config) (thor 1.5.0 thor.rb:534)
def initialize(args = [], local_options = {}, config = {})
  super
  @invocation = config.fetch(:invocation)
end
  • Replace every Dir.pwd with @invocation.cwd, every $stdin with @invocation.stdin, and route puts/warn through @invocation.stdout/stderr. start's rescue writes to config[:invocation].stderr. Keep the exit calls: in-process callers rescue SystemExit.
  • Pipeline::CLI never names OsSdlc::CLI::Invocation, so the pipeline slice does not depend on the cli slice (ADR-0196). Only bin/ builds it.
  • Test side: rewrite InProcessRunnerCli#call to run Pipeline::CLI.start(args, invocation:) and delete its private command table. In the four files listed in Observed, change the private run_cli/run_sweep_cli to call InProcessRunnerCli. Delete the unused run_cli_subprocess, run_subagent_start_subprocess, run_stop_subprocess helpers and BIN constants in tests/support/{cli_next_instruction_helpers,subagent_start_cli,subagent_stop_cli}.rb. Put the single smoke test in tests/os_sdlc_runner_bin_test.rb.
  • env is carried but not read in this ticket; #633 consumes it.
  • Out of scope: the hook-test harness runner forks (tests/support/post_tool_use_hook_test_case.rb, tests/pipeline/subagent_{start,stop}_hook_test.rb) go to the guard child; hooks/*.rb in-process is #632; the other os-sdlc binaries (evidence, os-sdlc, lint-worklist) are a proposed follow-up.
  • Rejected: keep InProcessRunnerCli as a second command table (it drifts; it already lost four commands and all options). Rejected: stub Dir.pwd in tests (process-global write, ADR-0205 rule 4).
  • ADRs to respect: ADR-0123 (thin bin + Thor subclass), ADR-0172 (hook subcommands parse the raw event from stdin once into Hook::Event), ADR-0194/0195 (one constant per file), ADR-0196 (cli slice placement, inward dependencies), ADR-0205 (no process-global writes), ADR-0206 (brief exits 0), ADR-0101 (tests/support top-level constants), ADR-0127 (no comments).

Structural follow-through

  • ADR via /os-adr:create: CLI command classes receive an Invocation; only bin/ entry points read process globals; tests call command classes in-process with one real-binary smoke test per binary (extends ADR-0205 rule 4 to reads; consistent with ADR-0123).
  • One History row in docs/implementation-status/os-sdlc.md.
Map: #631 ## Context Child of #631. Absorbs the in-process test conversion and the per-binary smoke test from #628; #628 keeps only the template-DB copy. Most Cli*Test tests already run in-process since 3871ecb5 (2026-08-23) through the test-side `InProcessRunnerCli` (plugins/os-sdlc/tests/support/in_process_runner_cli.rb). That helper bypasses Thor with its own copy of the command table: it has no `implementation-close`, `retired-tests-check`, `violations`, or `implementation-report`, and it drops every option such as `--target`. The tests for those commands still fork the binary. ## Observed `OsSdlc::Pipeline::CLI` (plugins/os-sdlc/lib/os_sdlc/pipeline/cli.rb) reads process globals on 12 lines: `Dir.pwd` at 31, 39, 51, 54, 59, 64, 71, 76, 87, 91 and `$stdin` at 37, 45, 51. Its output uses `puts`/`warn` on the process streams (46, 65, 76, 97; `start` rescue at 23). Test methods that still fork bin/os-sdlc-runner: ``` tests/pipeline/cli_implementation_open_test.rb 11 of 11 (private run_cli, line 126) tests/pipeline/cli_implementation_open_target_test.rb 4 of 4 (private run_cli, line 67) tests/pipeline/cli_implementation_close_test.rb 3 of 3 (private run_cli, line 76) tests/model/implementation_report_cli_test.rb 1 (run_sweep_cli, line 84) tests/pipeline/cli_next_instruction_test.rb 4 test_smoke_* methods tests/pipeline/cli_subagent_start_test.rb 3 test_smoke_* methods tests/pipeline/cli_subagent_stop_test.rb 3 test_smoke_* methods tests/pipeline/gate_cli_test.rb 3 test_smoke_* methods ``` Measured 2026-09-23: `test_next_routes_a_pass_verdict_to_the_maps_next_node` takes 0.33 s alone and 1.4-1.8 s when its file runs in parallel. Most of the per-test cost in CliNextInstructionTest is in-process contention or per-test DB setup, not forks. ## Reproduce ``` cd plugins/os-sdlc && ruby -Itests tests/pipeline/cli_implementation_open_test.rb -v ``` ## Expected ```ruby def test_implementation_open_writes_to_the_invocation_cwd invocation = OsSdlc::CLI::Invocation.new(cwd: project, stdin: StringIO.new, stdout: out, stderr: err, env: {}) status = exit_status { OsSdlc::Pipeline::CLI.start(["implementation-open", "session-1", "442", TOY_MAP], invocation: invocation) } assert_equal 0, status assert_equal "session-1", JSON.parse(out.string)["session_id"] end ``` ## Origin - Trigger: decomposition of #631, 2026-09-23 (user-decided split) - Improvised this session: none - Chain: slow suite ← per-test binary fork ← Pipeline::CLI reads Dir.pwd/$stdin (DESIGN, pipeline/cli.rb) ← test-side InProcessRunnerCli copies the Thor table instead of calling it (DESIGN, 3871ecb5) - Root candidate: this ticket - Where: plugins/os-sdlc/lib/os_sdlc/pipeline/cli.rb; plugins/os-sdlc/bin/os-sdlc-runner; plugins/os-sdlc/tests/support/in_process_runner_cli.rb - Session: 5bb052ff-ddf2-4d47-a283-d00d071e9b79 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/5bb052ff-ddf2-4d47-a283-d00d071e9b79.jsonl ## Owner sketch ```ruby class OsSdlc::CLI::Invocation # lib/os_sdlc/cli/invocation.rb; Data.define(:cwd, :stdin, :stdout, :stderr, :env) def self.from_process end class OsSdlc::Pipeline::CLI < Thor def self.start(given_args, config) def initialize(args = [], local_options = {}, config = {}) def implementation_open(*args) def next(*args) def subagent_start def subagent_stop def gate(*args) def brief def retired_tests_check(*args) def implementation_close(*args) def violations(*args) def implementation_report(*args) end ``` ## Cases 1. Given an Invocation whose cwd is a temp project other than the process cwd, when `Pipeline::CLI.start(["implementation-open", "session-1", "442", map], invocation:)` runs, then the implementation row is written to that project's pipeline DB. 2. Given that run, when it exits, then the JSON result is on `invocation.stdout`, the process `$stdout` receives nothing, and the exit status is 0. 3. Given a project with targets, when `implementation-open --target os-backlog` runs through the seam, then the implementation row records target `os-backlog`. 4. Given a subagent-stop JSON payload on `invocation.stdin`, when `subagent-stop` runs, then the dispatch records the verdict from that payload. 5. Given a PostToolUse event JSON on `invocation.stdin` and no argv, when `next` runs, then its output equals the output of `next session-1` for the same session. 6. Given an unknown session, when `next does-not-exist` runs through the seam, then `invocation.stderr` holds one line that starts with `os-sdlc-runner: harness error:` and the exit status is 3. 7. Given a seeded project, when the real `bin/os-sdlc-runner implementation-open` runs as a subprocess, then it exits 0 and prints JSON with the session id on stdout. 8. Given plugins/os-sdlc/lib/os_sdlc/pipeline/cli.rb, when searched for `Dir.pwd` and `$stdin`, then there is no match. ## Retired tests plugins/os-sdlc/tests/pipeline/cli_next_instruction_test.rb#test_smoke_next_boots_the_real_binary_and_returns_json_on_stdout plugins/os-sdlc/tests/pipeline/cli_next_instruction_test.rb#test_smoke_next_exits_nonzero_over_a_real_process_on_an_unknown_session plugins/os-sdlc/tests/pipeline/cli_next_instruction_test.rb#test_smoke_next_returns_the_same_json_across_repeated_real_process_calls plugins/os-sdlc/tests/pipeline/cli_next_instruction_test.rb#test_smoke_next_from_a_stdin_event_matches_the_argv_form plugins/os-sdlc/tests/pipeline/cli_subagent_start_test.rb#test_smoke_subagent_start_boots_the_real_binary_and_returns_a_dispatch_brief plugins/os-sdlc/tests/pipeline/cli_subagent_start_test.rb#test_smoke_subagent_start_exits_nonzero_over_a_real_process_without_an_implementation plugins/os-sdlc/tests/pipeline/cli_subagent_start_test.rb#test_smoke_subagent_start_creates_a_real_dispatch_row_via_the_real_process plugins/os-sdlc/tests/pipeline/cli_subagent_stop_test.rb#test_smoke_subagent_stop_boots_the_real_binary_and_records_a_handoff plugins/os-sdlc/tests/pipeline/cli_subagent_stop_test.rb#test_smoke_subagent_stop_exits_nonzero_over_a_real_process_on_an_unknown_dispatch plugins/os-sdlc/tests/pipeline/cli_subagent_stop_test.rb#test_smoke_subagent_stop_records_a_real_verdict_via_the_real_process plugins/os-sdlc/tests/pipeline/gate_cli_test.rb#test_smoke_gate_boots_the_real_binary_and_stamps_a_pass_verdict plugins/os-sdlc/tests/pipeline/gate_cli_test.rb#test_smoke_gate_exits_nonzero_over_a_real_process_on_an_unknown_dispatch plugins/os-sdlc/tests/pipeline/gate_cli_test.rb#test_smoke_gate_stamps_a_real_fail_verdict_via_the_real_process ## Approach ```ruby # bin/os-sdlc-runner OsSdlc::Pipeline::CLI.start(ARGV, invocation: OsSdlc::CLI::Invocation.from_process) # Pipeline::CLI: Thor passes the start config to new(args, opts, config) (thor 1.5.0 thor.rb:534) def initialize(args = [], local_options = {}, config = {}) super @invocation = config.fetch(:invocation) end ``` - Replace every `Dir.pwd` with `@invocation.cwd`, every `$stdin` with `@invocation.stdin`, and route `puts`/`warn` through `@invocation.stdout`/`stderr`. `start`'s rescue writes to `config[:invocation].stderr`. Keep the `exit` calls: in-process callers rescue SystemExit. - Pipeline::CLI never names `OsSdlc::CLI::Invocation`, so the pipeline slice does not depend on the cli slice (ADR-0196). Only bin/ builds it. - Test side: rewrite `InProcessRunnerCli#call` to run `Pipeline::CLI.start(args, invocation:)` and delete its private command table. In the four files listed in Observed, change the private `run_cli`/`run_sweep_cli` to call `InProcessRunnerCli`. Delete the unused `run_cli_subprocess`, `run_subagent_start_subprocess`, `run_stop_subprocess` helpers and `BIN` constants in tests/support/{cli_next_instruction_helpers,subagent_start_cli,subagent_stop_cli}.rb. Put the single smoke test in tests/os_sdlc_runner_bin_test.rb. - `env` is carried but not read in this ticket; #633 consumes it. - Out of scope: the hook-test harness runner forks (tests/support/post_tool_use_hook_test_case.rb, tests/pipeline/subagent_{start,stop}_hook_test.rb) go to the guard child; hooks/*.rb in-process is #632; the other os-sdlc binaries (evidence, os-sdlc, lint-worklist) are a proposed follow-up. - Rejected: keep InProcessRunnerCli as a second command table (it drifts; it already lost four commands and all options). Rejected: stub Dir.pwd in tests (process-global write, ADR-0205 rule 4). - ADRs to respect: ADR-0123 (thin bin + Thor subclass), ADR-0172 (hook subcommands parse the raw event from stdin once into Hook::Event), ADR-0194/0195 (one constant per file), ADR-0196 (cli slice placement, inward dependencies), ADR-0205 (no process-global writes), ADR-0206 (brief exits 0), ADR-0101 (tests/support top-level constants), ADR-0127 (no comments). ## Structural follow-through - ADR via /os-adr:create: CLI command classes receive an Invocation; only bin/ entry points read process globals; tests call command classes in-process with one real-binary smoke test per binary (extends ADR-0205 rule 4 to reads; consistent with ADR-0123). - One History row in docs/implementation-status/os-sdlc.md.
Author
Owner

Work started on branch ticket-638 (worktree .claude/worktrees/ticket-638), via the os-sdlc pipeline map poodr-ticket-implementation.

Work started on branch ticket-638 (worktree .claude/worktrees/ticket-638), via the os-sdlc pipeline map poodr-ticket-implementation.
Author
Owner

Follow-up dropped by ticket-skeptic (depth-1 root-vs-symptom): ADR-0207 already records the DispatchCommands exit_loudly / CLI unknown-subcommand raw-stream residue as known, deferred work in this ticket's own scope; not filed as a new ticket.

Follow-up dropped by ticket-skeptic (depth-1 root-vs-symptom): ADR-0207 already records the DispatchCommands exit_loudly / CLI unknown-subcommand raw-stream residue as known, deferred work in this ticket's own scope; not filed as a new ticket.
Author
Owner

Resolution

Done: Pipeline::CLI receives OsSdlc::CLI::Invocation via Thor start config; bin/os-sdlc-runner is the only reader of process globals; InProcessRunnerCli routes through Pipeline::CLI (its private command table deleted); four forking CLI test files run in-process; one real-binary smoke test (tests/os_sdlc_runner_bin_test.rb); ADR-0207 + os-sdlc leaf History row.

Evidence: Branch ticket-638 commit 4b0ef96c (merged to main by worktree finish); suite 1390 runs, 3176 assertions, 0 failures, 34.6 s; rubocop clean on touched files; only the smoke test spawns the binary. Pipeline implementation 13 escalated (unexpected_pass from #629 verifier evidence scope; Approach work unchecked, filed #643); hand-finished by user decision; Opus design review in place of contract-auditor, its one required change (delete duplicate DispatchCommands::HarnessError) applied.

Follow-ups: dropped: ticket-skeptic verdict DROP (depth-1 root-vs-symptom) on DispatchCommands exit_loudly / CLI unknown-subcommand Invocation routing, folded back into ADR-0207's known residue; dropped: unused BIN constant in tests/pipeline/cli_brief_test.rb — dead, harmless; hook-test harness forks already owned by #642; related #629, #643.

## Resolution **Done:** Pipeline::CLI receives OsSdlc::CLI::Invocation via Thor start config; bin/os-sdlc-runner is the only reader of process globals; InProcessRunnerCli routes through Pipeline::CLI (its private command table deleted); four forking CLI test files run in-process; one real-binary smoke test (tests/os_sdlc_runner_bin_test.rb); ADR-0207 + os-sdlc leaf History row. **Evidence:** Branch ticket-638 commit 4b0ef96c (merged to main by worktree finish); suite 1390 runs, 3176 assertions, 0 failures, 34.6 s; rubocop clean on touched files; only the smoke test spawns the binary. Pipeline implementation 13 escalated (unexpected_pass from #629 verifier evidence scope; Approach work unchecked, filed #643); hand-finished by user decision; Opus design review in place of contract-auditor, its one required change (delete duplicate DispatchCommands::HarnessError) applied. **Follow-ups:** dropped: ticket-skeptic verdict DROP (depth-1 root-vs-symptom) on DispatchCommands exit_loudly / CLI unknown-subcommand Invocation routing, folded back into ADR-0207's known residue; dropped: unused BIN constant in tests/pipeline/cli_brief_test.rb — dead, harmless; hook-test harness forks already owned by #642; related #629, #643.
jared closed this issue 2026-09-23 13:57:57 +00:00
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#638
No description provided.