Skip to content

Commit ea1dfe1

Browse files
gaurav02081claude
andcommitted
fix(wait): stop sharing exit code 2 with Click's usage errors
`run wait` exits to tell a script what happened -- 0 everything passed, 1 something failed, and it used 2 for a timeout. But Click already exits 2 for any usage error: an unknown flag, a missing argument, an out-of-range --interval. All three verified against the built command. So the one thing the feature promises -- "gate on the outcome without parsing anything" -- is the thing that breaks. A script written as sp run wait $ID case $? in 0) merge ;; 1) block ;; 2) sleep 600; retry ;; esac reads a mistyped --timeout as "still running" and retries forever a command that never made a single API call. Nothing crashes and nothing is printed; the script just takes the wrong branch quietly, which is the failure mode exit codes exist to prevent. Timeout is now 9. The README's table stopped at 8 and skipped 2 already, which is why that gap was there; both are now stated explicitly so the next command does not rediscover this. Also: always render a collection. payload = rows[0] if len(rows) == 1 else {'data': rows} made the output shape depend on how many ids were passed, so `sp run wait $IDS | jq '.data[]'` worked until the list happened to hold one run. Every other list in the CLI is {'data': [...]} whether it has one row or a hundred. And a note in the docstring that a `pass` status can come from a run that recorded results for a fraction of its samples -- the API derives it from the rows that exist, not the ones expected -- so a green wait is not proof of coverage. Production run 9360 reports `pass` on 1 of 237. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8fb73cd commit ea1dfe1

5 files changed

Lines changed: 78 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ sp run infra-errors <run> # VM / checkout / build / storage problems
6868
sp run artifacts <run> # binary, coredump, stdout, outputs
6969
sp queue # what is running right now
7070
sp run progress <run> # live status of one run
71+
sp run wait <run> [<run> ...] # block until they finish; 0 all passed,
72+
# 1 something failed, 9 timed out
7173
sp run ls --pr <n> # every run for one pull request
7274
sp run ls --created-after 2026-08-09T00:00:00Z # recent activity
7375
```
@@ -131,7 +133,9 @@ sp run ls --limit 5 | jq -r '.data[] | "\(.run_id) \(.platform) \(.status)
131133
```
132134

133135
Exit codes to branch on: `0` ok, `3` unreachable, `4` not found, `5` validation,
134-
`6` auth, `7` rate limited, `8` conflict.
136+
`6` auth, `7` rate limited, `8` conflict, `9` `run wait` timed out. **`2` means
137+
you got the command wrong** — an unknown flag, a missing argument, a value out
138+
of range — so treat it as a bug in your invocation, never as an outcome.
135139

136140
Progress spinners, retry notices, and colour go to **stderr** and are suppressed
137141
when stdout is not a terminal, so JSON on stdout is always parseable.

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ sp health # API + dependency health
134134
sp queue # queue depth and running jobs
135135
sp run ls # list CI runs
136136
sp run ls --pr 2309 # ... just one pull request's runs
137+
sp run wait 9412 9413 # block until they finish; exit code is the verdict
137138
sp run create --commit <sha> --platform linux --repository owner/repo
138139
sp sample ls / show / details <id> # media samples
139140
sp regression ls / show <id> # regression-test definitions
@@ -275,6 +276,13 @@ Scripts and agents can branch on the exit status:
275276
| 6 | authentication / authorization failure |
276277
| 7 | rate limited |
277278
| 8 | conflict (e.g. deleting a test that has results) |
279+
| 9 | `run wait` gave up before every run finished |
280+
281+
`run wait` additionally exits 1 when a run it waited for failed or was
282+
canceled. Note what is *not* in this table: **2**, which Click returns for any
283+
usage error — an unknown flag, a missing argument, an out-of-range value. Never
284+
give 2 a meaning of your own, or a mistyped flag becomes indistinguishable from
285+
a real outcome.
278286

279287
## Development
280288

sp_cli/commands/run.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
from sp_cli.compare import compare_runs, coverage_warnings
1212
from sp_cli.constants import (ARTIFACT_TYPES, CANCEL_REASON_MIN_LENGTH,
1313
COMMIT_SHA_LENGTH, ERROR_GROUP_BY,
14-
ERROR_SEVERITIES, ERROR_TYPES, INFRA_ERROR_TYPES,
15-
LOG_CONTAINS_MAX_LENGTH, LOG_LEVELS, LOG_SOURCES,
16-
MAX_OFFSET, MAX_PAGE_LIMIT,
17-
MAX_REGRESSION_TEST_IDS, PLATFORMS,
18-
PR_SCAN_DEFAULT, PR_SCAN_MAX,
14+
ERROR_SEVERITIES, ERROR_TYPES, EXIT_TIMEOUT,
15+
INFRA_ERROR_TYPES, LOG_CONTAINS_MAX_LENGTH,
16+
LOG_LEVELS, LOG_SOURCES, MAX_OFFSET,
17+
MAX_PAGE_LIMIT, MAX_REGRESSION_TEST_IDS,
18+
PLATFORMS, PR_SCAN_DEFAULT, PR_SCAN_MAX,
1919
RUN_PENDING_STATUSES, RUN_STATUSES,
2020
RUN_UNSUCCESSFUL_STATUSES, SAMPLE_STATUSES,
2121
WAIT_INTERVAL_DEFAULT, WAIT_INTERVAL_MAX,
@@ -777,8 +777,13 @@ def run_wait(ctx: click.Context, run_ids: Tuple[int, ...], interval: int,
777777
stays pipeable.
778778
779779
Exits 0 only if every run finished successfully; a failed or canceled run
780-
exits 1 and a timeout exits 2, which lets a script gate on the result
780+
exits 1 and a timeout exits 9, which lets a script gate on the result
781781
without parsing the output.
782+
783+
A run whose status is `pass` exits 0 even if it recorded results for only a
784+
fraction of its samples, because the API derives that status from the rows
785+
that exist rather than from the ones expected. Check `sp run summary`'s
786+
skipped_count before treating a green wait as full coverage.
782787
"""
783788
client = ctx.obj['client']
784789
output = ctx.obj['output']
@@ -806,7 +811,7 @@ def run_wait(ctx: click.Context, run_ids: Tuple[int, ...], interval: int,
806811
click.echo(f'timed out after {timeout}s; still pending: '
807812
f'{", ".join(str(r) for r in pending)}', err=True)
808813
_render_wait(ctx, run_ids, finished, output)
809-
raise SystemExit(2)
814+
raise SystemExit(EXIT_TIMEOUT)
810815
if not quiet:
811816
click.echo(f'waiting on {", ".join(str(r) for r in pending)} '
812817
f'({interval}s)', err=True)
@@ -834,5 +839,7 @@ def _render_wait(ctx: click.Context, run_ids: Tuple[int, ...],
834839
:type output: str
835840
"""
836841
rows = [finished[run_id] for run_id in dict.fromkeys(run_ids) if run_id in finished]
837-
payload: Any = rows[0] if len(rows) == 1 else {'data': rows}
838-
render(payload, output, ctx.obj.get('color', False))
842+
# Always a collection, even for one run. The shape must not depend on how
843+
# many ids the caller passed, or `sp run wait $IDS | jq '.data[]'` works
844+
# until the list happens to contain a single run.
845+
render({'data': rows, 'total': len(rows)}, output, ctx.obj.get('color', False))

sp_cli/constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
"""Enumerations accepted by the platform API, mirrored so the CLI can reject bad input locally.
23
34
Each tuple matches a validator in the merged ``mod_api`` blueprint. Keeping them
@@ -29,6 +30,14 @@
2930
#: exits non-zero on these so a script can gate on it.
3031
RUN_UNSUCCESSFUL_STATUSES = ('fail', 'canceled', 'error')
3132

33+
#: ``sp run wait`` exits with this when the deadline passes before every run
34+
#: finishes. Deliberately **not** 2: Click exits 2 on any usage error -- an
35+
#: unknown flag, a missing argument, an out-of-range ``--interval`` -- so a
36+
#: script branching on 2 would read a typo as "still running" and retry a
37+
#: command that never executed. 1 through 8 are taken by ``ApiError.exit_code``
38+
#: and Click, so the first free code is 9.
39+
EXIT_TIMEOUT = 9
40+
3241
#: ``sp run wait`` polling bounds, in seconds.
3342
WAIT_INTERVAL_DEFAULT = 30
3443
WAIT_INTERVAL_MIN = 5

tests/test_cli.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from click.testing import CliRunner
88

99
from sp_cli.client import ApiError
10+
from sp_cli.constants import EXIT_TIMEOUT
1011
from sp_cli.main import cli
1112
from tests import SESSION_SANDBOX # noqa: F401
1213

@@ -1621,15 +1622,52 @@ def test_wait_treats_unknown_status_as_terminal(self, mock_get):
16211622
@mock.patch('sp_cli.commands.run.time.monotonic')
16221623
@mock.patch('sp_cli.client.ApiClient.get')
16231624
def test_wait_times_out(self, mock_get, mock_clock):
1624-
"""Passing the deadline exits 2 and reports what was still pending."""
1625+
"""Passing the deadline exits 9 and reports what was still pending."""
16251626
mock_get.return_value = {'run_id': 9476, 'status': 'queued'}
16261627
# Start, then a reading past the deadline on the first check.
16271628
mock_clock.side_effect = [0, 10_000, 10_000]
16281629
result = self.runner.invoke(cli, ['run', 'wait', '9476', '--timeout', '60'])
16291630

1630-
self.assertEqual(result.exit_code, 2)
1631+
self.assertEqual(result.exit_code, EXIT_TIMEOUT)
16311632
self.assertIn('timed out', result.stderr)
16321633

1634+
@mock.patch('sp_cli.client.ApiClient.get')
1635+
def test_a_timeout_is_distinguishable_from_a_usage_error(self, mock_get):
1636+
"""The reason the timeout code is not 2.
1637+
1638+
Click exits 2 on any usage error. Sharing that code would make a
1639+
mistyped flag indistinguishable from "the runs are still going", so a
1640+
script branching on it would sleep and retry a command that never ran.
1641+
"""
1642+
for argv in (['run', 'wait'], # no run ids
1643+
['run', 'wait', '--nosuchflag'], # unknown option
1644+
['run', 'wait', '9476', '--interval', '1']): # below the minimum
1645+
result = self.runner.invoke(cli, argv)
1646+
self.assertEqual(result.exit_code, 2, argv)
1647+
self.assertNotEqual(result.exit_code, EXIT_TIMEOUT, argv)
1648+
mock_get.assert_not_called()
1649+
1650+
@mock.patch('sp_cli.client.ApiClient.get')
1651+
def test_one_run_is_rendered_as_a_collection_like_every_other_list(self, mock_get):
1652+
"""The payload shape must not depend on how many ids were passed.
1653+
1654+
Returning a bare record for a single run breaks
1655+
`sp run wait $IDS | jq '.data[]'` exactly when the list happens to hold
1656+
one run -- the same command, working or not by coincidence.
1657+
"""
1658+
mock_get.return_value = {'run_id': 9476, 'status': 'pass'}
1659+
single = json.loads(self.runner.invoke(cli, ['run', 'wait', '9476']).stdout)
1660+
1661+
mock_get.side_effect = [{'run_id': 9476, 'status': 'pass'},
1662+
{'run_id': 9477, 'status': 'pass'}]
1663+
several = json.loads(
1664+
self.runner.invoke(cli, ['run', 'wait', '9476', '9477']).stdout)
1665+
1666+
self.assertEqual(single['total'], 1)
1667+
self.assertEqual(several['total'], 2)
1668+
for payload in (single, several):
1669+
self.assertIsInstance(payload['data'], list)
1670+
16331671
@mock.patch('sp_cli.client.ApiClient.get')
16341672
def test_wait_surfaces_api_errors(self, mock_get):
16351673
"""An API failure stops the wait rather than retrying forever."""

0 commit comments

Comments
 (0)