Skip to content

feat: give listeners the same output channel as tasks - #32

Merged
refsz merged 1 commit into
mainfrom
feat/output-channel-on-main
Aug 19, 2026
Merged

feat: give listeners the same output channel as tasks#32
refsz merged 1 commit into
mainfrom
feat/output-channel-on-main

Conversation

@refsz

@refsz refsz commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Same content as #31, retargeted at main. #31 is marked merged, but its base was feature/argv-exec, and that branch had already been squash-merged into main as #30 — so the commit landed on a branch that no longer leads anywhere and src/Console/OutputChannel.php is absent from main. This is the identical commit cherry-picked onto current main; nothing was rewritten. My mistake for stacking the PR instead of waiting for #30 to land.


A listener that ran a command produced nothing at all. Not truncated, not unformatted — silent.

There were two executor paths, and only one had an output:

Caller Executor Output
Task TaskRunner::createContext() built new ShellExecutor($output) visible
Listener the container's shellExecutor service, built with no arguments discarded

The evidence is the consumer count: @shellExecutor was referenced in exactly one place in the codebase — as the inner of the listener executor. That service existed only for listeners, and it was the one without a destination. A composer install in a listener ran silently for minutes, a failure went unreported, and listeners in the field fell back to echo, which bypasses the redacting output and can print a secret.

One place that knows where Sputnik writes

Console\OutputChannel is registered empty, filled once bin/sputnik has wrapped the output in the redacting decorator, and shared from there:

bin/sputnik → OutputChannel ← ShellExecutor (one instance, tasks and listeners)
                           ← TaskRunner (adds the per-run SputnikOutput)
                           ← listeners (inject it to write)

ShellExecutor takes the channel instead of two nullable output parameters, and TaskRunner no longer constructs its own executor but takes the container's. That removes the second path rather than giving it an output too.

Filling the channel at startup, not in TaskRunner::run(), is what matters for the case this started from: context:switch is a command, not a task, so its listeners never pass through the task runner — which is exactly where the reference project's reset-on-switch listener lives.

Listeners can now write properly:

public function __construct(private readonly OutputChannel $output) {}

public function __invoke(ContextSwitchedEvent $event): void
{
    $this->output->writeln('Switched to ' . $event->newContext);
}

Deliberately not included: variables, templates or task execution for listeners. The missing output channel is a defect with evidence; the rest has no case yet.

Verification on top of current main

vendor/bin/phpunit          742 tests, 1281 assertions, OK
vendor/bin/phpstan analyse  [OK] No errors
vendor/bin/php-cs-fixer     0 of 153 files
vendor/bin/rector --dry-run [OK]

The proof is an end-to-end test driving the real binary through context:switch with a listener running echo, asserting the output appears. It is discriminating: removing the single line that fills the channel makes it fail, verified by mutation.

🤖 Generated with Claude Code

https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A

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.
@refsz
refsz merged commit 5fc4be5 into main Aug 19, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant