feat: give listeners the same output channel as tasks - #31
Merged
Conversation
A listener that ran a command produced nothing. Tasks got their executor from TaskRunner, built with the console output; listeners got the container service, built with no output at all - and it was the only consumer of that service. So a composer install in a listener ran silently for minutes, its failure went unreported, and listeners in the field fell back to echo, which bypasses the redacting output and can print a secret. OutputChannel is now the one place that knows where Sputnik writes. It is registered empty, filled once bin/sputnik has the redacting output, and shared by the executor, the tasks and the listeners. That removes the second executor path: TaskRunner no longer constructs its own but takes the container's, so a listener command is echoed, streams, and is masked exactly like a task command. Filling the channel at startup rather than in TaskRunner::run() matters for the case this started from: context:switch is a command, not a task, so its listeners never went through the task runner. An end-to-end test drives the real binary through a context switch and asserts the listener's command output appears; removing the channel fill makes it fail.
This was referenced Aug 19, 2026
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.
A listener that ran a command produced nothing at all. Not truncated, not unformatted — silent.
The cause is that there were two executor paths, and only one of them had an output:
TaskRunner::createContext()builtnew ShellExecutor($output)shellExecutorservice, built asShellExecutor::classwith no argumentsThe strongest evidence is the consumer count:
@shellExecutorwas referenced in exactly one place in the whole codebase — as theinnerof the listener executor. That service existed only for listeners, and it was the one without a destination. Acomposer installin a listener ran silently for minutes, a failure went unreported, and listeners in the field fell back toecho, which bypasses the redacting output and can print a secret.One place that knows where Sputnik writes
Console\OutputChannelis registered empty, filled oncebin/sputnikhas wrapped the output in the redacting decorator, and shared from there:ShellExecutortakes the channel instead of two nullable output parameters, andTaskRunnerno longer constructs its own executor but takes the container's. That is what removes the second path rather than giving it an output too.Filling the channel at startup, not in
TaskRunner::run(), is the part that matters for the case this started from:context:switchis a command, not a task, so its listeners never pass through the task runner. That is exactly where the reference project's reset-on-switch listener lives.Listeners can now write properly:
Deliberately not included
Variables, templates or task execution for listeners. The missing output channel is a defect with evidence behind it; the rest has no case yet.
Verification
The proof is an end-to-end test that drives the real binary through
context:switchwith a listener runningecho, and asserts the output appears. It is discriminating: removing the one line that fills the channel makes it fail, which I verified by mutation before committing. Four integration tests cover the channel's behaviour with a task-shaped output, with redaction, and unfilled (the command still runs, its output is on the result, nothing is printed and nothing fails), plus seven unit tests on the channel itself.docs/listeners.mdgains aWriting Outputsection that names whyechois the wrong tool here.🤖 Generated with Claude Code
https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A