Skip to content

dhq deploy --wait does not wait when stdout is not a TTY (CI, pipes, agents) #42

Description

@thdurante

dhq deploy --wait silently does not wait whenever stdout is not a TTY. It exits 0 as soon as the deployment is queued, so any pipe, CI job, redirect or agent invocation reads success from a deployment that has not run yet — and may still fail.

Verified against v0.21.0. Documented in the agent skill (#40), not fixed.

The two paths

  • --wait is skipped in JSON mode. WantsJSON() returns JSONMode || !IsTTY (internal/output/envelope.go:51-55) — the --json flag is not the trigger, the missing TTY is. In internal/commands/deploy.go:588-596 the if env.WantsJSON() branch returns after writing the creation JSON, which makes the if wait block at :598 unreachable. --timeout is inert for the same reason.
  • A cancelled deployment exits 0. internal/commands/watch.go:108-111 treats cancelled as a clean terminal state and returns nil. Only failed is non-zero (:107). This one also affects dhq deployments watch, and the internal watch calls in dhq launch and dhq init, which all share watchDeployment.

Either path alone is enough for automation to record a green deploy that never deployed.

Reproduce

# TTY: blocks until the deployment finishes
dhq deploy -p my-project --branch main --wait

# non-TTY: returns immediately, exit 0, deployment still running
dhq deploy -p my-project --branch main --wait | cat
echo $?   # 0

Current workaround (what the skill documents)

id=$(dhq deploy -p my-project --branch main --json=identifier | jq -r .identifier)
dhq deployments watch "$id" -p my-project
test "$(dhq deployments show "$id" -p my-project --json=status | jq -r .status)" = completed

The explicit status == "completed" assertion is required — dhq deployments watch alone still exits 0 on cancelled.

Fix options considered
  1. Honour --wait in JSON mode — wait, then emit the final deployment JSON, exiting non-zero on failed and cancelled. Best for callers, but it changes the shape of what dhq deploy --wait --json prints (queued → final), so it is a behaviour change for anyone already parsing it.
  2. Reject the combination outright, the way --dry-run + --wait already is at internal/commands/deploy.go:396-399. Honest and cheap, but it breaks scripts that pass --wait today believing it works — arguably a feature, since those scripts are already broken silently.

cancelled → non-zero is worth doing either way, and is independent of the choice above.

Context: DHQ-695.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions