feat(tests,ci): journey tier + per-PR journey-smoke gate (#2335, #2336) - #2403
Conversation
R1 — tests/journeys/ as a live-stack tier, wired as `run-full.sh --tier journeys`. Reuses the existing harness rather than building a second one: the per-tier verdict, the timeout, the venv pin and the skip audit all come for free, and `run_tier` made the wiring one line. Three rules the tier does not bend: * A journey FAILS, it does not skip. tests/conftest.py::created_agent calls pytest.skip when an agent will not start — reasonable there, and precisely the blind spot #2336 exists to close: on 2026-08-14 five of six stopped agents could not be started and nothing went red. Here that IS the finding. * Preconditions are checked ONCE, loudly. An unreachable stack fails the tier with the URL it tried, not 40 confusing errors. * Nothing is touched that the tier did not create: every agent is pytest-ephemeral-journey-<hex> and torn down by that name, teardown is idempotent, and a crashed run leaves the tier re-runnable. Polling to a deadline is the only synchronisation primitive; `poll_until` raises naming the broken promise and how long it waited ("agent 'x' was created but never reached 'running' — waited 90s"), never a bare assert 200 == 500. R2 — .github/workflows/journey-smoke.yml, on every PR to dev, 30-minute bounded job whose timeout FAILS rather than passes, and which treats "collected nothing" as a failure (pytest exit 5) — the #2029 class this gate is required to prevent. TWO THINGS STATED RATHER THAN GLOSSED: 1. AC #2 wants a real chat turn with real output. That needs a provider key, and every PR-triggered workflow here is deliberately credential-free — pull_request exposes repository secrets to fork PRs while running the PR's own shell (integration-nightly.yml sets out the reasoning in full). So the J03 first-turn journey ships in the tier, runs on a developer's stack and in the nightly, and skips with an ALLOWLISTED reason otherwise. What gates every PR is the lifecycle journey — which is where the 08-14 regression actually was. Resolving AC #2 properly needs a same-repo-only workflow with an environment approval, which is a separate decision. 2. AC #3 (replay ecf1327, watch the gate go red) is NOT done. Local live verification was blocked: agent creation on my dev stack did not return within 200s and wedged the backend. The gate's own PR exercises the workflow for real, which is the honest place to prove it. Related to #2335 Related to #2336 Related to #1958 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2336) The gate failed on its own PR in 8 seconds: `python-version-file:` expects a .python-version / pyproject-style file, so it read the literal string "FROM python:3.13-slim" as a version. Every sibling workflow states '3.13' directly, and #1891's parity is enforced by tests/unit/test_1891_python_version_parity.py — which scans ALL workflows and fails on a stale pin, so the guarantee comes from that test rather than from pointing setup-python at a file it cannot parse. (The REDIS_BACKEND_PASSWORD errors in the same log are downstream noise: the job died before the boot step, so .env never existed when the failure/teardown steps ran `docker compose`.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AC 3) Measured, not assumed: replaying the 08-14 regression (revert 35a24d0, the #2186 fix) left journey-smoke GREEN. #2186's title says why — 'starting a stopped agent no longer 500s WHEN A RECREATE IS NEEDED'. A fresh agent stopped and started immediately has no config drift, so the start takes the plain path and the bug is unreachable. The journey now changes the agent's resource limits while it is stopped, which makes check_resource_limits_match false and forces start_agent_internal onto the recreate path — the exact one that answered 500 for five of six agents. Also worth recording for the issue: it says to replay 'commit ecf1327', but that commit (#2092) is what CAUSED the regression; reverting it removes the bug. The fix to revert is 35a24d0 (#2186). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review sweep —
|
obasilakis
left a comment
There was a problem hiding this comment.
Requesting changes on two mechanical findings. The design is right and the AC #3 replay story is the most valuable thing in the PR — a journey that has never been watched failing is exactly the trap this deck exists to avoid, and finding it twice before it had teeth is the evidence that matters.
Nothing below is a security or data finding. Both blockers are one-line fixes.
Blocking
1. tests/run-full.sh:180 — the api tier does not ignore journeys/, so the tier runs twice
run_tier api 900 . \
--ignore=unit --ignore=integration --ignore=git_sync --ignore=security \
--ignore=scheduler_tests --ignore=agent_server --ignore=manual --ignore=deploy \
--ignore=harness
Every other tier directory is in that list; journeys is not. So run_tier journeys runs the tier, and then run_tier api collects journeys/ again from .. A full local run creates and tears down every journey agent twice, pays the container time twice, and reports a journey failure under two different tier names.
This is the new-producer-not-in-the-consumer-allowlist class — the ignore list is a hand-maintained mirror of the tier list, and adding a tier above without adding the ignore below is the failure mode it has. Fix is --ignore=journeys.
2. .github/workflows/journey-smoke.yml — the exit-5 guard is dead code
run: |
set -uo pipefail
python -m pytest tests/journeys/ -v -rs --tb=short \
--timeout=300 --timeout-method=thread
rc=$?
if [ "$rc" = "5" ]; thenGitHub's default shell for a run: step is bash --noprofile --norc -e -o pipefail {0}. set -uo pipefail adds -u but does not clear the -e that is already active from the shebang args. So a non-zero pytest exit aborts the step at the pytest line: rc=$? never executes, the if never executes, and the ::error title=No journeys collected annotation never appears.
The job still goes red on exit 5 — via -e, not via the code that documents the guarantee. That is the part worth fixing: the #2029 protection this gate is required to provide is currently held by an implicit shell flag, while the code asserting it is unreachable. A future editor who adds continue-on-error or reshapes the step gets no signal.
Fix is either set +euo pipefail-style explicit disabling, or the shape that does not need it:
rc=0
python -m pytest tests/journeys/ ... || rc=$?Worth noting no other workflow in the repo uses rc=$?, so this pattern is landing here first and would be copied.
Non-blocking
3. tests/journeys/test_agent_lifecycle_journey.py:75 — the drift-forcing values are hardcoded against defaults the test never reads
drift = journey_client.put(
f"/api/agents/{name}/resources", json={"memory": "2g", "cpu": "1"},
)This works today only because settings_service.AGENT_DEFAULT_CPU == "2" and AGENT_DEFAULT_MEMORY == "4g", so the PUT genuinely changes something and check_resource_limits_match goes false. Nothing in the test reads either constant, and an operator can move the fleet default through PUT /api/settings/agent-defaults/resources.
Set the fleet default to 2g/1 — or change the constant — and this journey silently loses its teeth again, in exactly the way the PR body describes it losing them the second time. The gate stays green and nobody learns anything.
Cheapest fix that removes the coupling: GET /api/agents/{name}/resources first, then PUT a provably different value, then assert the GET reflects it. That also gives the failure a name ("could not force a recreate") separate from "the restart 500'd".
4. tests/journeys/conftest.py:131 — a partial create leaks an agent
The create's raise AssertionError(...) on a non-2xx sits before the try: whose finally calls delete_agent_idempotent. A create that provisions the ownership row and then fails (a 500 after the row is written) leaves an agent the teardown never reaches, and the tier's own "re-runnable after a crash with no manual cleanup" rule does not hold for that path. Moving the create inside the try costs nothing, since teardown is already idempotent and safe on an agent that never existed.
5. journey-smoke.yml — no paths-ignore
Every PR to dev — including a docs-only or a .claude-only one — boots the full stack and builds the agent base image. That is 5-12 minutes on the measured run, on a check the body intends to make required. A paths-ignore for docs/** and similar would keep the signal and drop the tax.
What I checked and found fine
journey_clientisscope="session"andapi_clientis session-scoped too (tests/conftest.py:415), so there is noScopeMismatch.PUT /api/agents/{name}/resourcesexists (routers/agent_config.py:173) and is owner-gated, so the drift step is a real API call and not a fabricated one.- The workflow is genuinely credential-free,
permissions: contents: read, and the throwaway admin password is generated and::add-mask::ed. A fork PR gets no secret, which is the reason the J03 split exists and the reasoning holds. TRINITY_TEST_CLEANUP_SWEEP: '1'is safe here specifically because the sweep refuses a non-localhost target and the instance dies with the runner.- The
EPHEMERAL_PREFIXteardown-by-name rule is honoured, teardown is idempotent, andpoll_untilis the only synchronisation primitive — the failure messages name the broken promise and the wait, which is the thing that makes these debuggable at 3am. - The skip audit entry is allowlisted rather than silent, so the one skip is visible.
Verdict
Requesting changes on 1 and 2 only. Neither touches the design, and 3 is the one I would most like taken alongside them, because it is the same failure mode the PR body already paid for twice.
Note on merge order: #2407 (the catalog) records J03's harness path as null specifically because this branch is unmerged, and #2408 is approved on top of both. This should land first.
…guard was unreachable (#2335, #2336) ## 1. `run-full.sh` — the api tier ran journeys a second time `run_tier api` collects `.` with a hand-written `--ignore=` per tier, and `journeys` was added above without being added below. So a full local run created and tore down every journey agent twice, paid the container time twice, and reported one journey failure under two tier names. Adding `--ignore=journeys` fixes the instance and leaves the class — the ignore list is a hand-maintained mirror of the tier list, which is the new-producer-not-in-the-consumer-allowlist shape the reviewer named. So the list is now DERIVED from `TIER_DIRS` + `NON_TIER_DIRS`, and checked against the tree: a directory under `tests/` in neither set fails the script by name instead of being swept silently into `api`. Verified both ways — the derived list contains `--ignore=journeys`, and an unwired directory exits 1 with the offending name. ## 2. `journey-smoke.yml` — the exit-5 guard could never run GitHub's default `run:` shell is `bash --noprofile --norc -e -o pipefail {0}`, and `set -uo pipefail` adds `-u` without clearing the `-e` already in force. A non-zero pytest therefore aborted the step on the pytest line: `rc=$?` never executed, the `if` never executed, and the "No journeys collected" annotation could never appear. The job still went red on exit 5 — through `-e`, not through the code that documents the guarantee — so the #2029 protection this gate is required to provide rested on an implicit shell flag while the lines asserting it were dead. A later editor adding `continue-on-error` would have got no signal. `|| rc=$?` over `set +e`: a command in a condition is not subject to `-e`, so the exit code is captured by construction rather than by remembering to disable a flag. Verified under the real flags: exit 5 now prints the annotation and fails, exit 1 fails with no false annotation, exit 0 passes. The old shape, same simulation, exits 5 and prints nothing. ## 3. The drift step no longer depends on constants it never reads `{"memory": "2g", "cpu": "1"}` forced a recreate only because the fleet defaults happened to be `4g`/`2` — values this test never read and an operator can move at runtime through `PUT /api/settings/agent-defaults/resources`. Set the default to 2g/1 and the PUT is a no-op, no drift, plain-start path, and the journey silently loses its teeth exactly as it already did twice. It now GETs the current resources, picks a provably different pair, and asserts the change took — so "could not force a recreate" fails under its own name rather than surfacing later as "the restart 500'd", or as a green run that never exercised the recreate path at all. ## 4. A partial create no longer leaks an agent The create's `raise` sat above the `try:` whose `finally` deletes, so a create that provisioned the ownership row and then failed left an agent teardown never reached — breaking this tier's own re-runnable-after-a-crash rule on the path where cleanup matters most. Moved inside; `delete_agent_idempotent` was already safe on an agent that never existed. ## 5. `paths-ignore` deliberately NOT added, and the reason is now in the file The tax is real, but the obvious fix is the wrong one: a filtered-out PR runs no workflow, so the check is never REPORTED — and for a check this PR intends to make required that is not "skipped", it is a PR that can never merge. That precise failure was diagnosed on #2384 today. The safe shape is `frontend-e2e.yml`'s (unconditional trigger, cheap `changes` job, `if:`-gated heavy job, failing OPEN), which wants its own deliberate change rather than a rushed third copy here — that file says of its sibling that the two "differ ON PURPOSE". Recorded as a comment so the next person does not add the trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both blockers fixed, plus 3 and 4 —
|
| shape | pytest exits | step exit | annotation |
|---|---|---|---|
old (rc=$?) |
5 | 5 | never printed |
new (|| rc=$?) |
5 | 1 | printed |
| new | 1 | 1 | correctly absent |
| new | 0 | 0 | — |
I took || rc=$? over set +e for the reason you implied: a command in a condition is not subject to -e, so the exit code is captured by construction rather than by remembering to disable a flag. The comment records that this is the first rc= pattern in the repo and why it has this shape, since you noted it would be copied.
3. The drift step no longer depends on constants it never reads
Taken alongside, as you asked. It GETs the current resources, picks a provably different pair, and then asserts the change took — so "could not force a recreate" fails under its own name rather than surfacing later as "the restart 500'd", or as a green run that quietly exercised the plain-start path. That was the third time this journey's teeth depended on a coincidence; it no longer does.
4. Partial create no longer leaks
Create moved inside the try. delete_agent_idempotent was already safe on an agent that never existed, so it costs nothing and restores the tier's re-runnable-after-a-crash rule on the path where cleanup matters most.
5. paths-ignore — deliberately not added, and I'd like your view
The tax is real and I'd normally just add it. I did not, because the obvious fix is a trap here: a filtered-out PR runs no workflow, so the check is never reported — and for a check this PR intends to make required, that is not "skipped", it is a PR that can never merge. That exact failure was diagnosed on #2384 earlier today: three required workflows produced no run and the PR sat green-but-unmergeable until someone pushed again.
The safe shape is the one frontend-e2e.yml already uses — unconditional trigger, cheap changes job, if:-gated heavy job, failing open so "cannot determine" means run rather than skip (#1782). That is ~50 lines with subtle semantics, and that file says of its own sibling implementation that the two "differ ON PURPOSE. Don't 'unify' them" — so it wants its own deliberate change, not a rushed third copy appended here.
I've recorded the hazard as a comment on the on: block so the next person doesn't add the trap. Happy to do it properly as a follow-up, or in this PR if you'd rather it land together.
Ready for re-review — the requested changes are pushedFlagging state rather than adding findings: the What landed: both blockers — the api tier's ignore list is now derived and tree-checked (an unwired directory fails by name), and the exit-5 guard is reachable, verified under GitHub's real shell flags. Plus findings 3 and 4. Finding 5 ( The fuller response is in the comment above this one. Nothing further is pending from my side. (Disclosure: I wrote the fix commit on this PR, so this note is a status flag — not a self-review and not an approval.) |
obasilakis
left a comment
There was a problem hiding this comment.
/validate-pr re-review — approving. Clearing my earlier CHANGES_REQUESTED.
I re-derived all four findings against the current branch source rather than reading the commit subjects. All four are genuinely fixed, and two of them are fixed better than I asked for.
1. The api tier collecting journeys/ twice — fixed structurally
I asked for --ignore=journeys. What landed is:
api_ignores=()
for _d in "${TIER_DIRS[@]}" "${NON_TIER_DIRS[@]}"; do api_ignores+=("--ignore=${_d}"); done
run_tier api 900 . "${api_ignores[@]}"That is the better fix. The finding was the new-producer-not-in-the-consumer-allowlist class — a hand-maintained mirror of the tier list — and deriving the ignores from the tier list removes the class rather than the instance. The next tier added cannot reintroduce it.
2. The dead exit-5 guard — fixed, with the reasoning kept
|| rc=$? is there, and the comment explains why it is chosen over set +e (the command becomes part of a condition, which -e does not apply to, so the exit code is captured by construction rather than by remembering to disable a flag). That matters because this pattern is landing in the repo first and will be copied.
3. The hardcoded drift values — fixed, and it now fails with its own name
The journey reads GET /api/agents/{name}/resources, derives a provably different value, PUTs it, and then asserts the change took before starting the agent. The coupling to AGENT_DEFAULT_CPU/AGENT_DEFAULT_MEMORY is gone, so an operator moving the fleet default cannot silently remove this journey's teeth for a third time. The read-back assertion is what I was really after — "could not force a recreate" now fails separately from "the restart 500'd", instead of surfacing as a green run that quietly exercised the plain-start path.
4. The partial-create leak — fixed
The POST /api/agents is inside the try, so the finally reaches an agent whose ownership row was written by a create that then failed or timed out. That was the case where the tier's own re-runnable-after-a-crash rule mattered most.
Checklist: base dev, both referenced issues resolve and carry correct labels, security scan clean, the new os.getenv() vars are all under tests/ so the compose-packaging rule does not apply, no schema or backend-module surface. CI green, including the journey-smoke job running against itself.
Two notes for after merge, neither blocking:
The paths-ignore decision is right and the reasoning should survive. No paths-ignore, so every PR to dev pays a 5–12 minute stack boot including docs-only ones. The comment explains why the obvious fix is the wrong one — a filtered-out workflow never reports, and for a required check "never reported" is a PR that can never merge, which is exactly what happened on #2384 on 2026-08-27. Deferring the changes-job version to its own change rather than appending a rushed third copy is the correct call given frontend-e2e.yml says of its own sibling that the two differ on purpose. Worth doing before this becomes a required check, though, or the docs-only tax lands on everyone at once.
No credentials in the job, deliberately. pull_request exposes repository secrets to fork PRs while running the PR's own start.sh, so the credential-bound J03 first-turn journey correctly stays on a developer's stack and in the nightly. The lifecycle journey is where the 2026-08-14 regression actually was, so the split loses nothing that matters here.
Summary
Rails R1 + R2 of the journey-coverage deck, together: R2 is a gate with nothing to run without R1, and R1 is a tier nothing enforces without R2.
The thing this exists for, concretely: on 2026-08-14 five of six stopped agents could not be started — HTTP 500 on the most fundamental operation the platform has — and it reached users. A human found it by poking around. Nothing automated caught it, because the per-PR gate collects
tests/unit/only and no test in the merge path had ever started a real agent.R1 —
tests/journeys/+run-full.sh --tier journeysReuses the existing harness rather than building a second one, as the issue asks: per-tier verdicts, the timeout, the venv pin and the skip audit all come for free, and
run_tiermade the wiring literally one line.Three rules the tier does not bend:
tests/conftest.py::created_agentcallspytest.skipwhen an agent will not start — reasonable for a fixture whose tests are about something else, and precisely the blind spot here. In this tier, "the agent never reached running" is the finding.run-full.sh's own stated rule 1.pytest-ephemeral-journey-<hex>, torn down by that name; teardown is idempotent, so a crashed run leaves the tier re-runnable with no manual cleanup.poll_untilis the only synchronisation primitive —sleep-as-synchronisation pays the worst case every run and still loses the race on a loaded runner. Failures name the broken promise and how long they waited:never a bare
assert 200 == 500.R2 —
.github/workflows/journey-smoke.ymlEvery PR to
dev. 30-minute bounded job whose timeout fails rather than passes, and which treats "collected nothing" (pytest exit 5) as a failure — the #2029 class a required check exists to prevent.Two things I want visible rather than glossed
1. AC #2 ("a real chat turn with real output") is credential-bound, and this repo deliberately withholds credentials from PR triggers.
pull_requestexposes repository secrets to fork PRs while running arbitrary shell from the PR's own tree —integration-nightly.ymlsets out the full reasoning and keeps itself credential-free for exactly that reason.So the J03 first-turn journey ships in the tier (runs on a developer's stack and in the nightly) and skips with an allowlisted, visible reason otherwise. What gates every PR is the lifecycle journey — create → running → stop → start → running — which is where the 08-14 regression actually was.
Resolving AC #2 properly needs a same-repo-only workflow with an environment approval. That is a security decision, not something to slip into this PR.
2. AC #3 is done, and it earned its keep — it failed twice before it passed.
First attempt: reverted
ecf1327fexactly as the issue says. Gate went green. Correctly so:ecf1327f(#2092) is the commit that caused the regression, so reverting it removes the bug. The fix to revert is35a24d0c(#2186). The issue's replay instruction should be corrected.Second attempt: reverted
35a24d0c. Gate went green again — and this one was my bug. #2186's title states the condition precisely: the 500 happened "when a recreate is needed". A fresh agent stopped and started immediately has no config drift, sostart_agent_internaltakes the plain-start path and the regression is unreachable. The journey had no teeth, and only a real replay could have shown that.Third: the journey now changes resource limits while the agent is stopped, forcing
check_resource_limits_matchfalse and putting the restart on the recreate path. Red, on the right assertion, with the right message.I would not have found either problem from a green run. Worth keeping in mind for the rest of the deck: a journey that has never been watched failing is a journey of unknown value.
Test Plan
pytest tests/journeys/ --collect-only→ 3 collectedrun_tier journeys 900 journeys/wired; marker registered inpyproject.tomljourney-smoke: 2 passed, 1 skipped in 36.99s, whole job 5m07s of a 30m budget. Both lifecycle journeys created and drove a real agent.Related to #2335 · Related to #2336 · Related to #1958 (absorbed)