feat(run): --shell=ADDRESS names which target gets the terminal - #248
Open
raphaelvigee wants to merge 1 commit into
Open
feat(run): --shell=ADDRESS names which target gets the terminal#248raphaelvigee wants to merge 1 commit into
raphaelvigee wants to merge 1 commit into
Conversation
raphaelvigee
force-pushed
the
raphaelvigee/shell-flag-value
branch
3 times, most recently
from
July 30, 2026 09:31
fb4ec18 to
d821cb4
Compare
`--shell` declared `require_equals` + `default_missing_value`, so `heph run --shell=//pkg:a //pkg:...` parsed — but `args.shell.is_some()` was the only consumer and the captured String was never read anywhere in the tree. The value was accepted and thrown away, against the repo's fail-or-fix rule. #225 recorded it as pre-existing and out of scope, and sharpened it: its new `ShellNeedsSingleTarget` message tells the user to name the member they want, which was precisely the form being ignored. The value now selects the target. Bare `--shell` keeps #225's inference (the run's single target, refused when there isn't one, single-member group carve-out intact); `--shell=ADDRESS` names it explicitly, and every other target in the run builds with no terminal. The shell target rides on `RequestState`, not on `ResultOptions`, for two reasons that both reduce to "a `ResultOptions` does not reach every frame". It travels down the selection and group-inlining legs only — dependency resolution resets it and `meta` has no `opts` in scope — so an addr named with `--shell=` would be unreachable the moment it appeared as a dependency, which is the same silent discard in a subtler form. And `mem_locked_result` is keyed by `Addr` alone: were "does this frame shell?" a function of the frame's options, an addr reached both as a selected target and as a sibling's dependency would bake whichever frame won that memoizer race. Read from the request it is a pure function of the addr, so every frame agrees and a dependency can be entered — which is the workflow this mainly unlocks. The at-most-one-terminal invariant #225 established holds by construction: exactly one address matches, and `inner_result_addr` — the tree's only `ExecuteOptions` — hands every other frame no terminal. `classify_failure` now reads "did this frame get the terminal" from the same place, so a silent target is never told it streamed and never loses its process-log tail. A bad `--shell=` value warns; it does not fail the build. `--shell` is an interactive convenience, and a target that turns out not to be in the run is no reason to throw away a build that did exactly what was asked — its outputs are real and cached either way. But it is never silent: the user asked for a shell and has to hear that they are not getting one. An address no target has is warned about *before* the run, in one memoized spec lookup, so they learn it while the build is still going rather than after waiting it out; an address that exists but the run never resolved is warned about at the end, since graph membership needs the walk. One `shell_settled` flag covers both so the same address is never reported twice. Two refusals remain refusals, and are separate cases: `--shell` on something that is not a single target, and `--shell=<a group>` — a transparent target never executes, so there is no sandbox to enter. CLI-behavior change (not parsing): `--shell=X` previously parsed but silently discarded X, behaving identically to bare `--shell`. X is now meaningful. Anyone passing one gets a shell in X, or a warning and a normal build if X is not part of the run. There is no CLI schema version to bump and no prior release documented what `--shell=X` did, so this lands as a stated change rather than a migration. Review board: - product-vision SHIP WITH CHANGES — every `try:` line is runnable verbatim (a template there is a command an agent executes for real), the typo case moved before the run, and the long help leads with examples. - code-quality BLOCKED on two findings, both fixed: the batch owner judged the shell claim even when per-target failures were collected (`fail_fast` is off by default), talking over the actionable group refusal; and the terminal wrapper left in `ResultOptions` told `classify_failure` that silent targets had streamed, dropping their log tails. - compatibility COMPATIBLE — no versioned artifact records the flag's arity or the message text; `ResultOptions` and `new_state_full` are unchanged, `ShellTarget` is purely additive. Known asymmetry, deliberately not fixed here: `--shell=<single-member group>` is refused where bare `--shell <same group>` enters its member. Forwarding the claim through an alias has to preserve the property the whole design rests on — that the answer is a pure function of the addr, resolved identically by every frame — and that is a larger change than this one. The refusal names the member and the exact command to run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A76DChMohieGMbRbnV1E7x
raphaelvigee
force-pushed
the
raphaelvigee/shell-flag-value
branch
from
July 30, 2026 09:40
d821cb4 to
0026169
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
--shellwas declared withrequire_equals = true+default_missing_value = "", soheph run --shell=//pkg:a //pkg:...parsed fine. Butself.args.shell.is_some()was the only consumer, and the capturedStringwas never read anywhere in the tree. The value was accepted and thrown away — against this repo's fail-or-fix rule.#225 recorded it as pre-existing and out of scope, and sharpened it: the
ShellNeedsSingleTargetmessage it added tells the user to name the member they want, which is precisely the form that was being ignored.What the value now means
--shell--shell=ADDRESS--shell=ADDRESSnaming a group--shell=ADDRESSthat no target has--shell=ADDRESSthat exists but this run never reached--shell/--shell=ADDRESSwith no terminalA bad
--shell=value warns; it does not fail the build.--shellis an interactive convenience, and a target that turns out not to be in the run is no reason to throw away a build that did exactly what was asked — its outputs are real and cached either way.It is never silent, though: the user asked for a shell and has to hear that they are not getting one, so both warnings name the address, state the consequence outright, and give the command that finds the right address. The split is about when: a typo is decidable up front, so it is reported while the build is still running rather than after waiting one out; graph membership needs the walk, which is the run. A single
shell_settledflag covers both, so the same address is never reported twice.The two refusals are separate cases and stay refusals:
--shellon something that is not a single target, and--shell=<a group>— a transparent target never executes, so there is no sandbox to enter.ADDRESS may be any address the run resolves — a selected target, a member a group inlines, or a dependency of either.
heph run --shell=//pkg:lib //pkg:appbuildsapp's graph and drops you intolib's sandbox. That is the workflow this mainly unlocks, and it is why the mechanism below matters.heph run --shell=//pkg:awith no positional now runs that target rather than answering "missing TARGET_ADDRESS/LABEL" to a command line that named one.Why the shell target lives on
RequestState, notResultOptionsTwo reasons, both reducing to a
ResultOptionsdoes not reach every frame:ResultOptions::default()ininputs_result_exec) andmetahas nooptsin scope at all. An addr named with--shell=would be unreachable the moment it appeared as a dependency — the same silent discard this PR exists to delete, in a subtler form.mem_locked_resultis keyed byAddralone and bakes the winning caller'sshell/interactiveinto the shared cell. Were "does this frame shell?" a function of the frame's options, an addr reached both as a selected target and as some sibling's dependency would resolve differently depending on which frame won that race.//pkg:helpernamed on the CLI and also a dep of//pkg:appis the ordinary case, andmetaresolves it withOutputMatcher::Nonewhileinputs_result_execusesExact(names)— twomem_resultcells, onemem_locked_resultcell. Read from the request it is a pure function of the addr, so every cell computes the same answer.ShellTargetis set once, before resolution starts;Engine::new_state_full's signature is unchanged.The #225 invariant holds by construction
At most one live terminal wrapper (
TtyReaderon fd 0) per request. Exactly one address matches, andinner_result_addr— the tree's onlyExecuteOptionsconstruction — hands every other frame(shell: false, interactive: None).probe.max_live == 1is asserted on both the group and selection legs.classify_failurenow reads "did this frame actually get the terminal" from the same place rather than fromopts. Believingoptsthere says a silent target streamed, which suppresses the process-log tail of a failing one and renders an empty failure box — the regression #225 fixed for group members, re-entered through the other door.Diagnosability
Every
try:line is runnable verbatim. A template carryingtry:is a command an agent will execute for real, so the placeholder moved out of it:The two refusals above fail the run. The two bad-value cases only warn, and the build carries on:
Each names the address, states the consequence in the same breath ("no shell will be opened" / "was opened"), and ends on a command that finds the right address — because the failure mode a warning permits is a typo quietly costing the user their shell.
--shell's long help leads with three examples, including the dependency form.Compatibility
BREAKING — CLI behavior, not parsing. The clap grammar for
--shellis byte-for-byte unchanged.compatibilitywrote that sentence when a badXstill failed the run; the exit-code half has since softened — anXthat is not part of the run now warns and the build succeeds — but the change of meaning on an accepted input, which is the part that matters to anyone already passing--shell=X, is unchanged.The refusals that remain (not a single target,
--shell=<group>, no terminal) exit 1, the project's single command-failure code. Nothing bakes the flag's arity or the message text:gen-docsand completions are generated live from the clap tree, and the changedDisplaytext is asserted only incrates/engine's own unit tests.ResultOptionsis unchanged;ShellTargetand the twoRequestStatemethods are purely additive.Verification
22 tests (17
--shell, 5 CLI arg-layer); the full--shell+ terminal + group suite is 28 and passes.cargo fmt --checkandcargo clippy --all-targets --locked -- -D warningsclean from the workspace root.The three warning tests assert on the warning itself, not merely on "the build survived" — a warning nobody emits is the same as no warning.
tracingdelivers rather than returns it, and it fires on tokio worker threads where a thread-local subscriber is never consulted, so a capturing subscriber is installed once globally and each test names an address no other test uses.tracing-subscriberbecomes anenginedev-dependency for this; it is already the workspace's subscriber (the binary uses it), so it is not a second copy of anything — the lockfile diff is one line.Mutation-verified red — 12 mutations, each killing its test:
shell_target_picks_which_group_member_is_entered,..._which_selected_target_is_entered,shell_target_reaches_a_dependency_of_the_runerrorsa_shell_refusal_survives_a_batch_that_did_not_fail_fastclassify_failurereadsopts.interactiveagaina_failing_sibling_of_the_shell_target_keeps_its_log_tailshell_target_that_does_not_exist_warns_before_the_runsettle_shell(so both sites warn)shell_target_that_does_not_exist_warns_before_the_run— "warned exactly once", 2 vs 1shell_target_outside_the_run_warns_and_the_build_still_succeeds,..._outside_a_selection_warns_once--shell=<group>refusal disabledshell_target_naming_a_group_names_its_members_insteadshell_target_without_a_terminal_is_refusedrequire_equalsdroppedbare_shell_names_no_targetnum_args = 0..=0shell_captures_its_valueresolve_shell_target_distinguishes_the_three_forms,shell_target_resolves_relative_to_the_current_packageunparseable_shell_target_is_rejectedThree of these carry more than "the test works":
classify_failuremutation, fix(engine): group members are dependencies; they never get the terminal #225's ownfailing_member_of_multi_member_group_keeps_its_log_tailstays green while mine reddens — so the new test is not duplicating fix(engine): group members are dependencies; they never get the terminal #225's coverage, it is holding a door fix(engine): group members are dependencies; they never get the terminal #225 never had to close.settle_shellmutation proves the de-duplication is real rather than incidental: without it the same mistyped address is reported twice, once by each site.Re-verified in full after rebasing onto
dfc4e5cc(#241).Re-verified in full after rebasing onto
dfc4e5cc(#241) — all 11 still red, 33 tests green.Review board
product-vision: SHIP WITH CHANGES — consulted at design (where it recommended rejecting the value; the user decided the opposite and specified these semantics) and again on the finished UX. Its five changes are in: runnabletry:lines, the typo case moved before the run, the examples-first long help,value_name = ADDRESS(the value is parsed byparse_addr_with_base, so "TARGET" over-promised), and a not-in-run message that leads with what happened to the user's build. That last one was a typed error leading with "the build succeeded, but…"; on the decision below it became a warning, which says the same thing by construction — the build simply succeeds.code-quality: BLOCKED on two, both fixed. (1) The batch owner judged the shell claim even when per-target failures were collected — andfail_fastis off by default — so--shell=<group>inside a selection replaced the actionable group message with "not part of this run", every clause of which was false. (2) The terminal wrapper left inResultOptionson theMatcher::Addrleg toldclassify_failurethat silent targets had streamed, dropping their log tails. It also confirmed themem_locked_resultrace analysis, that the fix closes rather than moves it, and thatRelaxedis sound here (the flag publishes no data; every read is separated from every write by a completedawait). Its MINORs on allocation-per-frame, theclaim_matched_streamoverload, the nestedOption, and the missingDebugare all fixed.compatibility: COMPATIBLE, with the stated break above.User decision after review: a
--shell=target that turns out not to be in the run was a typedShellTargetNotInRunthat failed the build. That is too strong for an interactive flag — it does not justify discarding a build that did what was asked — so it is now a warning and the run proceeds. The pre-flight stayed (warning early beats warning late), andShellTargetNotInRunwas deleted rather than left as an unreachable variant.Known asymmetry, deliberately not fixed here
--shell=<single-member group>is refused where bare--shell <same group>enters its member. Forwarding the claim through an alias has to preserve the property the whole design rests on — that the answer is a pure function of the addr, resolved identically by every frame, before any frame can observe it — andcode-qualitytraced why routing it throughoptscannot work. That is a larger change than this one. The refusal names the member and the exact command to run, so it is actionable rather than a dead end.Also noted, not fixed:
"cannot use --shell in non-interactive mode"is still an untypedbail!with no addr and no next action. It predates this PR and is the message an agent in CI hits first; typing it belongs with the other request-shape errors.No per-platform divergence: engine control flow and clap parsing, no
cfg, nothing intui/tty.rstouched.🤖 Generated with Claude Code
https://claude.ai/code/session_01A76DChMohieGMbRbnV1E7x