Progress Logs Are Interface Contracts

automation reliability cli progress developer experience output contracts Jun 17, 2026

Some engineering tasks look like polish until they touch a contract.

"Add progress logs" sounds small. A long-running controller is hard to watch, and an operator needs to know whether the system is dispatching work, waiting at a gate, retrying, or failing. Human-visible progress is a reasonable request.

But a workflow CLI is not just a terminal surface. It may also be an automation interface. Another process may parse its stdout. Tests may depend on quiet stderr. A wrapper may rely on a final JSON payload. If progress output leaks into the wrong stream, a useful UX improvement becomes a breaking protocol change.

That is the lesson: progress logs are interface contracts.

The Boundary Is the Product

The right question is not "where can we print a status line?"

The right question is "what promises does this command already make?"

For a controller-style CLI, those promises often include:

  • stdout is reserved for the final machine-readable result
  • stderr may already carry some exceptional human-readable messages
  • non-interactive runs should stay quiet unless progress is explicitly requested
  • exit codes and final JSON remain the automation contract
  • lifecycle state is owned by the controller, not by the progress text

Once those promises exist, progress output has to fit around them.

In this case, the safe shape was clear:

  • final JSON stays on stdout
  • progress goes to stderr
  • progress defaults on only when process.stderr.isTTY === true
  • --progress forces progress output
  • --no-progress suppresses it
  • raw subprocess output is not streamed just to make the run feel alive

That last point matters. Raw worker output can be noisy, sensitive, unstable, or misleading. It is not the same thing as lifecycle progress.

Wrapper-Level Logging Is Usually Too Shallow

A tempting implementation is to add a few lines around the CLI wrapper:

  • started
  • running
  • done

That may make the terminal less blank, but it does not tell the operator what is actually happening.

Controller truth often lives deeper:

  • a runner enters a stage loop
  • an adapter dispatches a subprocess
  • a gate pauses for human review
  • a rework limit is reached
  • a model worker refuses or fails
  • a branch writes state and returns to classification

If progress is only wrapped around the outside, it misses the events an operator actually needs.

The better design is structural. Define a progress sink, inject it into the places that know the truth, and keep the sink separate from lifecycle authority.

The sink can say:

  • this stage started
  • this adapter is dispatching
  • this gate paused
  • this failure path was reached
  • this run is returning a final result

It should not decide what stage is legal, whether a gate is satisfied, or whether the workflow may advance.

Human-Readable Does Not Mean Machine-Stable

There is another trap: once progress text exists, someone may start treating it as a protocol.

That is why the contract should be explicit. The final JSON is the machine contract. The progress text is for humans. It can be stable enough to test with durable tokens and broad assertions, but it should not become the source of lifecycle truth unless the system intentionally defines a formal progress protocol.

This distinction keeps the system honest.

Human-readable progress can be useful without becoming an API. It can explain what the controller is doing without becoming the thing the controller depends on.

Failure Paths Need Progress Too

Observability work has a bias toward happy paths.

It is easy to emit progress when a stage starts, a subprocess launches, or a run completes successfully. It is just as important to emit progress when the run fails.

Failure paths are where operators most need visibility:

  • parser failure
  • refusal
  • timeout
  • rework limit reached
  • missing artifact
  • invalid transition
  • blocked gate

If those branches return silently, the new progress layer creates an uneven experience: success feels legible, while failure remains opaque.

The practical pattern is to make failure helpers emit progress before returning or throwing. That keeps negative paths visible without scattering ad hoc print calls through every branch.

Test the Stream Contract, Not Just the Text

Progress work should have tests, but the tests need the right target.

The core proof is not that every line has exactly the expected wording. Human-readable text will evolve. Exact timing strings are especially brittle.

The better proof is:

  • stdout remains parseable
  • stderr is quiet in non-TTY default runs
  • --progress produces progress on stderr
  • --no-progress suppresses progress
  • raw subprocess output is not streamed
  • success events and failure events are both represented
  • injected sinks receive internal runner and adapter events

That proof protects the contract while leaving the copy flexible.

A Practical Review Checklist

Before adding progress to a workflow CLI, ask:

  • What is stdout currently for?
  • Who parses it?
  • What is stderr currently allowed to contain?
  • Do existing tests or scripts assume quiet stderr?
  • Should progress default on only for an interactive terminal?
  • Which flag forces it on?
  • Which flag suppresses it?
  • Where does the real workflow state live?
  • Is progress injected there, or only wrapped around the outside?
  • Are failure paths instrumented?
  • Are progress lines human-readable rather than lifecycle authority?
  • Are tests checking streams, flags, and event coverage without freezing fragile copy?

This is more ceremony than a few print statements. It is also what keeps a small UX improvement from corrupting the automation surface underneath it.

The Larger Point

Operator experience matters. A blank terminal during a long controller run erodes confidence.

But trustworthy automation depends on contracts: parseable stdout, intentional stderr, durable state, explicit flags, and clear ownership of lifecycle decisions.

The best progress design respects both sides. It makes the run legible to the human at the keyboard while preserving the machine contract that lets the workflow be automated at all.

Progress logs are not just decoration. In a serious CLI, they are interface design.