Skip to content

Commit 50fa5ed

Browse files
fix(server): run the agent image built from the same commit as the server (#1500)
Derives the agent sandbox image reference from the deployment's own IMAGE_TAG instead of defaulting to the agent-pi:latest release channel, so the sandbox and the application server are built from one commit by construction. A reference naming a moving tag is refused at startup. Also fixes the release workflow asserting node --version against an image that no longer ships Node.
1 parent 644fbad commit 50fa5ed

37 files changed

Lines changed: 1297 additions & 51 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
Practice reviews and mentor conversations now run in the agent sandbox image built from the same
6+
commit as the application server. A deployment that tracked `main` previously fell back to the newest
7+
released sandbox image, which pairs a server with a sandbox nobody built it against — reviews and
8+
mentor turns then failed inside the container with nothing explaining why. The server now reports at
9+
startup when the sandbox image cannot run it, naming both versions.
10+
11+
**Operators:** the sandbox image now follows `IMAGE_TAG`, so a tag that moves between builds refuses
12+
to start — `IMAGE_TAG=latest`, which earlier example configuration shipped, and equally a partial
13+
version such as `0.73`, which every patch release moves. The same goes for setting
14+
`HEPHAESTUS_AGENT_IMAGE_REFERENCE` to one. Set `IMAGE_TAG` to a full release version or a commit SHA,
15+
and remove or digest-pin the reference override, before upgrading. This applies even with the agent
16+
disabled. Release deployments that changed neither, and take the signed digest pin, are unaffected.
17+
See MIGRATION.md.

.github/workflows/cd-staging.yml

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,42 @@ jobs:
3535
outputs:
3636
image-exists: ${{ steps.check.outputs.exists }}
3737
steps:
38-
# Every main push builds :<sha>, except when skip-duplicate-actions dedups an
39-
# identical tree. Skip the deploy (instead of failing red) when that happens.
40-
- name: Look up application-server image for this commit
38+
# The server and the agent image are one runtime contract, so this asks about the PAIR: the
39+
# server stages TypeScript runners and execs `bun` inside agent-pi, and deploying a commit
40+
# whose agent image never published leaves the app resolving an unmatched one (ADR 0031).
41+
#
42+
# A missing image is normal, not a failure — skip rather than deploy half a pair. Both known
43+
# causes are benign: skip-duplicate-actions dedups an identical tree, and a CI/CD run
44+
# cancelled by a later push (merging several PRs in quick succession) leaves the earlier
45+
# commit's images unpublished.
46+
- name: Look up the image pair for this commit
4147
id: check
4248
env:
43-
IMAGE: ghcr.io/ls1intum/hephaestus/application-server
4449
SHA: ${{ github.event.workflow_run.head_sha }}
4550
TOKEN: ${{ secrets.GITHUB_TOKEN }}
4651
run: |
52+
set -euo pipefail
4753
echo "$TOKEN" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
48-
if docker manifest inspect "${IMAGE}:${SHA}" >/dev/null 2>&1; then
49-
echo "exists=true" >> "$GITHUB_OUTPUT"
50-
else
54+
missing=""
55+
for img in application-server agent-pi; do
56+
ref="ghcr.io/ls1intum/hephaestus/${img}:${SHA}"
57+
# Presence is a bare probe on its own exit status, as in release.yml. Reading the digest
58+
# in the condition instead would test `jq`'s status, and `jq` exits 0 on empty input — an
59+
# absent image would then look present and the pair would go unchecked.
60+
if ! docker buildx imagetools inspect "$ref" > /dev/null 2>&1; then
61+
missing="${missing} ${img}"
62+
continue
63+
fi
64+
digest=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' | jq -r '.digest')
65+
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]] || {
66+
echo "::error::${img} digest at ${SHA} is malformed: ${digest:-<empty>}"; exit 1; }
67+
echo "${img}: ${digest}"
68+
done
69+
if [ -n "$missing" ]; then
5170
echo "exists=false" >> "$GITHUB_OUTPUT"
52-
echo "No \`application-server:${SHA}\` image (build deduplicated) — nothing to deploy." >> "$GITHUB_STEP_SUMMARY"
71+
echo "No \`${missing# }\` image at \`${SHA}\` (build deduplicated or its CI run cancelled) — nothing to deploy." >> "$GITHUB_STEP_SUMMARY"
72+
else
73+
echo "exists=true" >> "$GITHUB_OUTPUT"
5374
fi
5475
5576
deploy-staging:

.github/workflows/ci-docker-build.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,13 @@ jobs:
109109
registry: "ghcr.io"
110110
# amd64-only on PRs (main/release build both arches). See reusable-docker-build.yml.
111111
single-arch: ${{ github.event_name == 'pull_request' }}
112+
# The head-SHA tag matters here for the same reason it does on application-server: a preview
113+
# runs at SOURCE_COMMIT and derives its agent image from that tag, so without it no preview can
114+
# ever resolve a matched pair, however the pull request touched the agent tree (ADR 0031).
112115
tags: |
113116
${{ github.ref_name }}
114117
${{ github.sha }}
118+
${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
115119
ci-${{ github.run_number }}
116120
${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || '' }}
117121
labels: |

.github/workflows/ci-quality-gates.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,22 @@ jobs:
370370
echo "::error::Legacy agent image is removed in issue #1076; relocate any new references to MIGRATION.md."
371371
exit 1
372372
fi
373+
# The defect ADR 0031 removed: a reference that floats on the release channel resolves to
374+
# whatever released most recently, which is a pair no release ever built. The reference is
375+
# derived from the deployment's own image tag now, so nothing may name a channel again.
376+
# Needle built at runtime so this guard file doesn't self-match.
377+
- name: Assert no release-channel agent image references
378+
run: |
379+
set -euo pipefail
380+
NEEDLE="agent-pi:$(printf 'lat\x65st')"
381+
if grep -rEnI "$NEEDLE" \
382+
--exclude-dir=node_modules --exclude-dir=target --exclude-dir=.git \
383+
. \
384+
| grep -vE '^\./(MIGRATION\.md|docs/admin/agent-image-digests\.md|docs/decisions/0031-.*\.md|server/src/test/java/de/tum/cit/aet/hephaestus/agent/sandbox/AgentImageReferenceGuardTest\.java):' ; then
385+
echo "::error::A release-channel agent image tag resolves to another release's image (ADR 0031). Leave the reference unset so it follows this deployment's image tag, or pin a digest."
386+
exit 1
387+
fi
388+
373389
- name: Assert no legacy agent-image-pin.env references
374390
run: |
375391
set -euo pipefail

.github/workflows/release.yml

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,10 @@ jobs:
159159
id: retag
160160
env:
161161
VERSION: ${{ needs.release.outputs.version }}
162-
CHANNEL: ${{ needs.release.outputs.major }}.${{ needs.release.outputs.minor }}
162+
# The `major.minor` series tag: it moves onto every patch release in the line, exactly as
163+
# `latest` moves onto every release. Both are channel tags; only the spelling differs, and
164+
# naming this one `CHANNEL` made them look like different things. ADR 0031.
165+
SERIES: ${{ needs.release.outputs.major }}.${{ needs.release.outputs.minor }}
163166
SHA: ${{ needs.release.outputs.sha }}
164167
run: |
165168
set -euo pipefail
@@ -195,11 +198,11 @@ jobs:
195198
196199
docker buildx imagetools create \
197200
-t "$FULL_IMAGE:$VERSION" \
198-
-t "$FULL_IMAGE:$CHANNEL" \
201+
-t "$FULL_IMAGE:$SERIES" \
199202
-t "$FULL_IMAGE:latest" \
200203
"$FULL_IMAGE:$SHA"
201204
202-
for tag in "$VERSION" "$CHANNEL" "latest"; do
205+
for tag in "$VERSION" "$SERIES" "latest"; do
203206
DST_DIGEST=$(manifest_digest "$FULL_IMAGE:$tag")
204207
if [[ "$SRC_DIGEST" != "$DST_DIGEST" ]]; then
205208
echo "::error::Retag changed the digest for $img:$tag — refusing to publish."
@@ -214,13 +217,33 @@ jobs:
214217
echo "::endgroup::"
215218
done
216219
220+
- name: Check out the released tree
221+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
222+
with:
223+
ref: ${{ needs.release.outputs.sha }}
224+
fetch-depth: 1
225+
226+
# The pin asset below tells every production deploy to run THIS agent image, so this is the
227+
# last point at which an image the released server cannot drive is still catchable. The
228+
# sandbox is Bun-only (ADR 0030), so `node` must be absent, not present.
217229
- name: Smoke-test agent-pi
218230
env:
219231
IMAGE: ghcr.io/ls1intum/hephaestus/agent-pi@${{ steps.retag.outputs.agent-pi-digest }}
232+
LAYOUT: server/src/main/java/de/tum/cit/aet/hephaestus/agent/runtime/SandboxLayout.java
220233
run: |
221234
set -euo pipefail
222235
docker pull "$IMAGE"
223-
docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'bun --version && node --version'
236+
docker run --rm --entrypoint /bin/sh "$IMAGE" -c \
237+
'bun --version && ! command -v node >/dev/null 2>&1'
238+
239+
expected=$(grep -oE 'RUNTIME_CONTRACT_VERSION = [0-9]+' "$LAYOUT" | grep -oE '[0-9]+$')
240+
[ -n "$expected" ] || { echo "::error::Could not read RUNTIME_CONTRACT_VERSION from $LAYOUT"; exit 1; }
241+
declared=$(docker inspect --format '{{index .Config.Labels "hephaestus.agent.runtime-contract"}}' "$IMAGE")
242+
if [ "$declared" != "$expected" ]; then
243+
echo "::error::agent-pi implements runtime contract '${declared:-<none>}' but the released server stages for v${expected} — refusing to publish a pin for an unmatched pair."
244+
exit 1
245+
fi
246+
echo "agent-pi implements runtime contract v${expected}"
224247
225248
- name: Write release pin asset
226249
id: pin

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# and can be added to the global gitignore or merged into this file. For a more nuclear
77
application-live-local.yml
88
application-local.yml
9+
application-local.yaml
910
**/atlassian-ide-plugin.xml
1011
# auto-import.
1112
# Beads (AI agent issue tracker) - local-only files

MIGRATION.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,64 @@ Entries exist only for releases that need operator action. Everything else is in
6565

6666
### Next release
6767

68+
#### 🔴 An agent image reference naming a channel tag is now refused
69+
70+
**Affected**, and either one is enough:
71+
72+
- `HEPHAESTUS_AGENT_IMAGE_REFERENCE` set to a channel tag — `:latest`, `:stable`, `:edge`, `:main`,
73+
or a partial version such as `:0.73`, which we retag onto every patch release in that line — or to
74+
a reference with no tag at all.
75+
- **`IMAGE_TAG=latest`** — which earlier versions of `docker/.env.example` shipped as the default —
76+
or **`IMAGE_TAG=0.73`**. The reference now derives from `IMAGE_TAG`, so such a deployment resolves
77+
`agent-pi:latest` or `agent-pi:0.73` without ever naming it, and the refusal applies just the same.
78+
79+
Check both with `grep -E 'IMAGE_TAG|HEPHAESTUS_AGENT_IMAGE_REFERENCE'` over your deployment
80+
configuration before you upgrade. `AGENT_ENABLED=false` does **not** exempt you: the check runs at
81+
startup, whether or not the sandbox is ever used. A release deploy that leaves both alone takes the
82+
signed digest pin and is unaffected.
83+
84+
The boot fails with one of:
85+
86+
```
87+
hephaestus.agent.image.reference must not be a channel tag
88+
hephaestus.agent.image.reference names a version series rather than one release
89+
```
90+
91+
**Before**: the agent sandbox image fell back to `ghcr.io/ls1intum/hephaestus/agent-pi:latest` when
92+
nothing else supplied a reference. `latest` tracks the newest **release**, so a deployment tracking
93+
`main` ran its application server against an agent image built from a different commit. Nothing
94+
reported it: practice reviews and mentor sessions simply failed inside the container.
95+
96+
**After**: the reference follows your deployment's own `IMAGE_TAG`, so the sandbox image is the one
97+
built from the same commit as the application server. A channel tag is refused at startup with a
98+
message naming the fix, because it can only ever name a pairing no release produced.
99+
100+
**Action**: set `IMAGE_TAG` to a full release version (`0.74.0`) or to a full commit SHA — never
101+
`latest`, and never the `0.74` series. Then, if you also set the reference override, remove it or
102+
replace it with a digest:
103+
104+
```bash
105+
# either: remove the HEPHAESTUS_AGENT_IMAGE_REFERENCE line entirely (recommended) — do not
106+
# leave it present and empty, which binds an empty reference and fails the boot for a second reason
107+
#
108+
# or: pin the exact image you mean
109+
HEPHAESTUS_AGENT_IMAGE_REFERENCE=ghcr.io/ls1intum/hephaestus/agent-pi@sha256:<digest>
110+
```
111+
112+
A deployment tracking `main` keeps `HEPHAESTUS_RELEASE_PIN_SKIP=true` and
113+
`HEPHAESTUS_AGENT_IMAGE_REQUIRE_DIGEST=false`; the derived reference is a matched tag, not a digest.
114+
See [Agent image digests](https://ls1intum.github.io/Hephaestus/admin/agent-image-digests).
115+
116+
#### 🟡 Preview deployments name their own agent image
117+
118+
**Affected**: preview stacks (`docker/preview/`) that run practice reviews or the mentor from a pull
119+
request which does not touch `docker/agents/**`.
120+
121+
A preview now derives its agent image from its own commit, and CI publishes one at that commit only
122+
when the pull request changed the agent tree or a workflow. Previously such a preview silently used
123+
the last release's image. Set `HEPHAESTUS_AGENT_IMAGE_REFERENCE` in the preview's `.env` to the agent
124+
image you want it to exercise — `docker/preview/.env.example` shows the line.
125+
68126
#### 🟡 Reviewer-side practices keep the old wording until you update them
69127

70128
**Affected**: workspaces created before this release that use the shipped practices

docker/.env.example

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
# Hostname for the application (e.g., hephaestus.cit.tum.de)
1717
APP_HOSTNAME=
1818

19-
# Docker image tag to deploy (e.g., latest, v1.0.0, develop)
20-
IMAGE_TAG=latest
19+
# Release to deploy, or the full commit SHA for a deploy that tracks main. Every image in the stack
20+
# is pulled at this tag, and the agent sandbox image is resolved from it too, so the whole
21+
# deployment is one matched build. Never a tag that moves between builds — `latest`, or a partial
22+
# version like `0.74` that every patch release retags — because it pairs a server with an agent image
23+
# nobody built it against, and the boot is refused. See ADR 0031.
24+
IMAGE_TAG=0.74.0
2125

2226
# Tomcat RemoteIpValve internal-proxies REGEX (NOT CIDR) matching ONLY the ingress (Traefik) address.
2327
# REQUIRED in prod: forward-headers-strategy=native is active and ProxyTrustGuard fails the boot if
@@ -231,7 +235,17 @@ GITLAB_WORKSPACE_CREATION=false
231235
# AGENT SANDBOX (Practice Review)
232236
# -----------------------------------------------------------------------------
233237

234-
# Agent image override (dev only): see docs/admin/agent-image-digests.md.
238+
# Agent sandbox image. Left unset it follows IMAGE_TAG above on a deploy tracking main, and the
239+
# release's cosign-verified digest on a release deploy — so setting it is an override of both, for
240+
# when neither names the image you mean. A digest is immutable and therefore reproducible; never a
241+
# release channel, which names whatever released most recently. See docs/admin/agent-image-digests.md.
242+
# HEPHAESTUS_AGENT_IMAGE_REFERENCE=ghcr.io/ls1intum/hephaestus/agent-pi@sha256:<digest>
243+
244+
# A deploy that tracks main rather than a release sets BOTH of these: there is no signed pin asset
245+
# for a main commit, so the fetcher must be told to skip it and the digest requirement must come off.
246+
# The derived reference is still a matched pair, just a tag rather than a digest.
247+
# HEPHAESTUS_RELEASE_PIN_SKIP=true
248+
# HEPHAESTUS_AGENT_IMAGE_REQUIRE_DIGEST=false
235249

236250
# The sandbox is activated by the worker role (hephaestus.runtime.worker.enabled, default true
237251
# in the monolith), not a toggle. Practice review additionally needs AGENT_ENABLED and

docker/agents/pi/Dockerfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,14 @@ RUN set -eu; \
105105
fi; \
106106
echo "OK: no node/npm in image; runtime is $(bun --version) at $(command -v bun)"
107107

108+
# The build above proves the runtime contract — Bun resolves and imports the SDK exactly as
109+
# PiRuntimeFactory arranges it, and no Node survives. These labels are the only part of that proof a
110+
# server can read before it commits a job to a container: it compares the contract version against
111+
# SandboxLayout.RUNTIME_CONTRACT_VERSION, which AgentImageContractSyncTest pins to the line below.
112+
# Bump both together when an older image could no longer run the staged runners. ADR 0031.
113+
LABEL hephaestus.agent.runtime-contract=1
114+
LABEL hephaestus.agent.bun-version=${BUN_VERSION}
115+
LABEL hephaestus.agent.pi-version=${PI_VERSION}
116+
108117
USER 1000:1000
109118
WORKDIR /workspace

docker/compose.app.yaml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,19 @@ services:
258258
GITLAB_DEFAULT_SERVER_URL: ${GITLAB_DEFAULT_SERVER_URL:-${GITLAB_OAUTH_BASE_URL:-https://gitlab.com}}
259259
# Agent sandbox (practice review + Pi mentor chat). Activated by the worker role
260260
# (hephaestus.runtime.worker.enabled), then gated per-workspace via WorkspaceFeatures.
261-
# AgentImagePinGuard requires a digest-pinned agent image in prod; the release-pin-fetcher
262-
# supplies it for release tags. Set false for non-release (e.g. main/latest) deploys that skip
263-
# the pin, otherwise the boot fails on agent-pi:<tag>. Keep true for release deploys.
261+
# AgentImagePinGuard requires a digest-pinned agent image in prod, and the release-pin-fetcher
262+
# supplies that digest for release tags. A non-release deploy has no signed pin, so the server
263+
# falls back to agent-pi:${IMAGE_TAG} — the agent image CI built from the very commit this
264+
# server image came from. That is a matched pair, but a tag rather than a digest, so a deploy
265+
# tracking main sets this false. Keep true for release deploys, where the pin is authoritative
266+
# and stronger. Never point this at a release channel instead — see ADR 0031.
264267
HEPHAESTUS_AGENT_IMAGE_REQUIRE_DIGEST: ${HEPHAESTUS_AGENT_IMAGE_REQUIRE_DIGEST:-true}
268+
# The operator's override, and the only lever when neither the signed pin nor the derivation
269+
# names the right image. Valueless on purpose: Compose then sets it only when the deployment
270+
# actually supplies one, from the stack `.env` or the host. `${VAR:-}` would set it to the
271+
# empty string instead, and Spring reads present-but-empty as a value rather than as absent —
272+
# the reference would bind to "" and every deployment that does not override would fail to boot.
273+
HEPHAESTUS_AGENT_IMAGE_REFERENCE:
265274
SANDBOX_DOCKER_HOST: ${SANDBOX_DOCKER_HOST:-unix:///var/run/docker.sock}
266275
SANDBOX_CONTAINER_RUNTIME: ${SANDBOX_CONTAINER_RUNTIME:-}
267276
SANDBOX_MAX_CONCURRENT: ${SANDBOX_MAX_CONCURRENT:-5}
@@ -376,6 +385,8 @@ services:
376385
<<: [*practice-review-env, *scm-access-env]
377386
HEPHAESTUS_TRUSTED_PROXIES: ${HEPHAESTUS_TRUSTED_PROXIES:-}
378387
HEPHAESTUS_AGENT_IMAGE_REQUIRE_DIGEST: ${HEPHAESTUS_AGENT_IMAGE_REQUIRE_DIGEST:-true}
388+
# Valueless, as on application-server above; this is the pod that actually opens sandboxes.
389+
HEPHAESTUS_AGENT_IMAGE_REFERENCE:
379390
HEPHAESTUS_HUB_URL: ws://application-server:8080/api/workers/connect
380391
HEPHAESTUS_WORKER_REGISTRATION_TOKEN: ${HEPHAESTUS_WORKER_REGISTRATION_TOKEN:-}
381392
# Agent job queue (practice review). The queue runs on PostgreSQL — this pod needs no NATS at

0 commit comments

Comments
 (0)