Runner#run sequences the installed stamp after the command:
cmd.execute(args:, context:) # runner.rb:78
stamp_installed(cmd_name, context.project_root) # runner.rb:79
But project run: commands exec-replace the dev process (CommandRunner#run_in_container at command_runner.rb:101, #run_replace_process at :240, #run_exec_with_status at :248), so line 79 only ever runs for fully in-process builtins. In a repo whose dev.yml defines its own up:, dev up execs into the project command and never stamps — the staleness gate then reports "never installed" forever, which is fatal under CI=true. Hit live in the dev#73 build pass; captured on dev#84 as the command-runner-exec learning.
The wrong fix
Stamping before cmd.execute (what the initial capture recommended) breaks the stamp's semantics: stamp_installed's contract is "record the stamp after a fully-successful provisioning command" (runner.rb:117–120). A failed dev up would look installed and the staleness guard would go quiet exactly when it should be loud.
The fix
When the resolved command needs post-execute work (the stamping commands: up, install-deps) and it is an exec-style project command, run it spawn-and-wait instead of exec-replace:
CommandRunner gains a wait mode — Kernel.system(child_env, "shadowenv", "exec", "--", "sh", "-c", shell_command) (and the docker equivalent for containerized commands) with the child's exit status propagated — selected by the caller when post-steps exist.
Runner#run stamps only on success, then exits with the child's status, preserving today's exit-code behavior.
- Generic
run: commands keep the exec tail-call: it's the right shape for a leaf command (TTY/signal passthrough, no double process tree), and nothing sequences after them.
The command-runner-exec skill on dev#84 states the underlying invariant (must-happen work can't be sequenced after a maybe-exec point) and points here for the fix.
Runner#runsequences the installed stamp after the command:But project
run:commands exec-replace the dev process (CommandRunner#run_in_containerat command_runner.rb:101,#run_replace_processat :240,#run_exec_with_statusat :248), so line 79 only ever runs for fully in-process builtins. In a repo whosedev.ymldefines its ownup:,dev upexecs into the project command and never stamps — the staleness gate then reports "never installed" forever, which is fatal underCI=true. Hit live in the dev#73 build pass; captured on dev#84 as thecommand-runner-execlearning.The wrong fix
Stamping before
cmd.execute(what the initial capture recommended) breaks the stamp's semantics:stamp_installed's contract is "record the stamp after a fully-successful provisioning command" (runner.rb:117–120). A faileddev upwould look installed and the staleness guard would go quiet exactly when it should be loud.The fix
When the resolved command needs post-execute work (the stamping commands:
up,install-deps) and it is an exec-style project command, run it spawn-and-wait instead of exec-replace:CommandRunnergains a wait mode —Kernel.system(child_env, "shadowenv", "exec", "--", "sh", "-c", shell_command)(and the docker equivalent for containerized commands) with the child's exit status propagated — selected by the caller when post-steps exist.Runner#runstamps only on success, then exits with the child's status, preserving today's exit-code behavior.run:commands keep the exec tail-call: it's the right shape for a leaf command (TTY/signal passthrough, no double process tree), and nothing sequences after them.The
command-runner-execskill on dev#84 states the underlying invariant (must-happen work can't be sequenced after a maybe-exec point) and points here for the fix.