Skip to content

Commit 4545774

Browse files
Lara Dvorsekclaude
andcommitted
Exercise variants: address the latest review round
A failed websocket publish could destroy a finished variant. Every terminal transition writes the new phase first and publishes afterwards, so when the publish threw, the exception escaped complete() into the pipeline's terminal catch — which deleted the generated exercise and overwrote COMPLETED with FAILED. That catch was introduced in the previous commit; before it existed, the task service's already-terminal check kept the job intact. Fix both ends: publish now swallows and logs a delivery failure, because notifying the client is best effort and must never propagate into state-changing code, and the terminal catch keeps the variant when the job has already reached a terminal phase. Cover both with a test class that stubs the websocket service to throw. Give the wizard's free-form controls accessible names. The domain input, the custom-instructions textarea and the six new-group settings had a styled <p> or <div> beside them instead of a label, so a screen reader announced no name. Each control now has an id and a matching <label for>; the label rules get display:block since labels are inline. The two radio-group headings stay as they are — a <label for> cannot address a group, and each radio already carries its own label. Name the AI variant action. The tooltip key read "Variante erstellen" / "Create Variant", so neither locale mentioned AI; both now say so. The button is icon-only and a tooltip is not an accessible name, so it also gets an aria-label from the same key. Abort the E2E runner when the mock LLM never becomes ready, instead of warning and then pointing Spring AI at a dead port. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1224909 commit 4545774

9 files changed

Lines changed: 144 additions & 13 deletions

File tree

run-e2e-tests-local-fast.sh

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,14 @@ if [ "$SKIP_SERVER" = false ]; then
386386
if [ "$MOCK_READY" = true ]; then
387387
echo -e "${GREEN}Mock LLM is listening (PID $MOCK_LLM_PID).${NC}"
388388
else
389-
echo -e "${RED}WARNING: mock LLM did not become ready; Hyperion tests may skip/fail.${NC}"
389+
# Continuing would enable Hyperion and point Spring AI at a dead port, so the variant suite would
390+
# fail later with connection errors that say nothing about the real cause.
391+
echo -e "${RED}ERROR: mock LLM did not become ready; aborting instead of running Hyperion against a dead endpoint.${NC}"
392+
if kill -0 "$MOCK_LLM_PID" 2>/dev/null; then
393+
kill_tree "$MOCK_LLM_PID"
394+
fi
395+
rm -f "$LOCAL_DIR/hyperion-mock-llm.pid"
396+
exit 1
390397
fi
391398
export ARTEMIS_HYPERION_ENABLED="true"
392399
export SPRING_AI_OPENAI_BASE_URL="http://localhost:${HYPERION_MOCK_LLM_PORT}/v1"

src/main/java/de/tum/cit/aet/artemis/hyperion/service/variants/ExerciseVariantGenerationPipelineService.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,14 @@ public void run(VariantJob job) {
215215
// ExerciseVariantTaskService, which marks the job FAILED but never deletes the exercise — leaving the
216216
// clone, its repositories and its build plans behind as orphans. No failure summary here: that is an
217217
// extra LLM call on a path we already know is broken.
218+
if (jobService.getJob(jobId, job.getInitiatorLogin()).map(current -> current.getPhase().isTerminal()).orElse(true)) {
219+
// The job already reached a terminal phase, so the throw came from AFTER that transition
220+
// (telemetry logging, the websocket publish). The variant is generated and the record says so —
221+
// deleting it here would discard verified work over a failed notification.
222+
log.error("Variant generation job {} raised an error after it had already finished (exercise {}); keeping the variant", jobId, job.getSourceExerciseId(),
223+
unexpected);
224+
return;
225+
}
218226
cleanupProvisionedVariant(variant, jobId);
219227
jobService.fail(jobId, "Unexpected error: " + unexpected.getMessage(), null);
220228
log.error("Variant generation job {} failed unexpectedly (exercise {})", jobId, job.getSourceExerciseId(), unexpected);

src/main/java/de/tum/cit/aet/artemis/hyperion/service/variants/ExerciseVariantJobService.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,18 @@ private VariantJob mutate(String jobId, Consumer<VariantJob> mutation) {
492492
}
493493
}
494494

495+
/**
496+
* Notifies the initiator about a job transition. Best effort by design: every caller publishes AFTER it has
497+
* already written the new phase, so letting a delivery failure propagate would let a lost notification undo
498+
* persisted state (the pipeline's terminal catch would treat it as a failed job). The client falls back to
499+
* polling the job endpoints, so a dropped event costs freshness, not correctness.
500+
*/
495501
private void publish(VariantJob job, VariantGenerationEventDTO event) {
496-
websocketService.send(job.getInitiatorLogin(), TOPIC_SUFFIX_PREFIX + job.getJobId(), event);
502+
try {
503+
websocketService.send(job.getInitiatorLogin(), TOPIC_SUFFIX_PREFIX + job.getJobId(), event);
504+
}
505+
catch (RuntimeException e) {
506+
log.warn("Could not publish the {} event of variant job {}", event.type(), job.getJobId(), e);
507+
}
497508
}
498509
}

src/main/webapp/app/course/manage/exercises/create-variant-modal/exercise-variant-ai-modal-wizard.component.html

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@
170170
}
171171
@if (changeDomain()) {
172172
<div class="field-group">
173-
<p class="field-group__label">{{ 'artemisApp.exerciseVariantGeneration.wizard.configure.domain' | artemisTranslate }}</p>
173+
<label class="field-group__label" for="wiz-domain">{{ 'artemisApp.exerciseVariantGeneration.wizard.configure.domain' | artemisTranslate }}</label>
174174
<input
175+
id="wiz-domain"
175176
tumUiInput
176177
class="w-full"
177178
[ngModel]="domainText()"
@@ -206,8 +207,9 @@
206207
}
207208
@if (changeCustom()) {
208209
<div class="field-group">
209-
<p class="field-group__label">{{ 'artemisApp.exerciseVariantGeneration.wizard.configure.custom' | artemisTranslate }}</p>
210+
<label class="field-group__label" for="wiz-custom">{{ 'artemisApp.exerciseVariantGeneration.wizard.configure.custom' | artemisTranslate }}</label>
210211
<textarea
212+
id="wiz-custom"
211213
tumUiTextarea
212214
rows="4"
213215
class="w-full"
@@ -272,9 +274,12 @@
272274
<div class="new-group-settings" (click)="$event.stopPropagation()">
273275
<div class="flex flex-wrap gap-2 items-end mt-2">
274276
<div class="grow" style="min-width: 120px">
275-
<div class="new-group-settings__label">{{ 'artemisApp.exerciseVariantGeneration.wizard.placement.groupTitle' | artemisTranslate }}</div>
277+
<label class="new-group-settings__label" for="wiz-group-title">{{
278+
'artemisApp.exerciseVariantGeneration.wizard.placement.groupTitle' | artemisTranslate
279+
}}</label>
276280
<input
277281
type="text"
282+
id="wiz-group-title"
278283
[ngModel]="newGroupTitle()"
279284
(ngModelChange)="newGroupTitle.set($event)"
280285
tumUiInput
@@ -283,9 +288,12 @@
283288
/>
284289
</div>
285290
<div>
286-
<div class="new-group-settings__label">{{ 'artemisApp.exerciseVariantGeneration.wizard.placement.maxPoints' | artemisTranslate }}</div>
291+
<label class="new-group-settings__label" for="wiz-group-max-points">{{
292+
'artemisApp.exerciseVariantGeneration.wizard.placement.maxPoints' | artemisTranslate
293+
}}</label>
287294
<input
288295
type="number"
296+
id="wiz-group-max-points"
289297
[ngModel]="newGroupMaxPoints()"
290298
(ngModelChange)="newGroupMaxPoints.set($event !== '' ? +$event : undefined)"
291299
tumUiInput
@@ -296,9 +304,12 @@
296304
</div>
297305
<div class="flex flex-wrap gap-2 mt-2">
298306
<div>
299-
<div class="new-group-settings__label">{{ 'artemisApp.exerciseVariantGeneration.wizard.placement.release' | artemisTranslate }}</div>
307+
<label class="new-group-settings__label" for="wiz-group-release">{{
308+
'artemisApp.exerciseVariantGeneration.wizard.placement.release' | artemisTranslate
309+
}}</label>
300310
<input
301311
type="datetime-local"
312+
id="wiz-group-release"
302313
[ngModel]="newGroupReleaseDate()"
303314
(ngModelChange)="newGroupReleaseDate.set($event)"
304315
tumUiInput
@@ -307,9 +318,12 @@
307318
/>
308319
</div>
309320
<div>
310-
<div class="new-group-settings__label">{{ 'artemisApp.exerciseVariantGeneration.wizard.placement.start' | artemisTranslate }}</div>
321+
<label class="new-group-settings__label" for="wiz-group-start">{{
322+
'artemisApp.exerciseVariantGeneration.wizard.placement.start' | artemisTranslate
323+
}}</label>
311324
<input
312325
type="datetime-local"
326+
id="wiz-group-start"
313327
[ngModel]="newGroupStartDate()"
314328
(ngModelChange)="newGroupStartDate.set($event)"
315329
tumUiInput
@@ -318,9 +332,12 @@
318332
/>
319333
</div>
320334
<div>
321-
<div class="new-group-settings__label">{{ 'artemisApp.exerciseVariantGeneration.wizard.placement.due' | artemisTranslate }}</div>
335+
<label class="new-group-settings__label" for="wiz-group-due">{{
336+
'artemisApp.exerciseVariantGeneration.wizard.placement.due' | artemisTranslate
337+
}}</label>
322338
<input
323339
type="datetime-local"
340+
id="wiz-group-due"
324341
[ngModel]="newGroupDueDate()"
325342
(ngModelChange)="newGroupDueDate.set($event)"
326343
tumUiInput
@@ -329,11 +346,12 @@
329346
/>
330347
</div>
331348
<div>
332-
<div class="new-group-settings__label">
349+
<label class="new-group-settings__label" for="wiz-group-assessment-due">
333350
{{ 'artemisApp.exerciseVariantGeneration.wizard.placement.assessmentDue' | artemisTranslate }}
334-
</div>
351+
</label>
335352
<input
336353
type="datetime-local"
354+
id="wiz-group-assessment-due"
337355
[ngModel]="newGroupAssessmentDueDate()"
338356
(ngModelChange)="newGroupAssessmentDueDate.set($event)"
339357
tumUiInput

src/main/webapp/app/course/manage/exercises/create-variant-modal/exercise-variant-ai-modal-wizard.component.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,8 @@
276276

277277
.field-group {
278278
&__label {
279+
// A <label for> rather than a <p>, so the control has an accessible name; labels are inline by default.
280+
display: block;
279281
font-size: 0.8rem;
280282
font-weight: 600;
281283
color: var(--secondary);
@@ -565,6 +567,8 @@
565567

566568
.new-group-settings {
567569
&__label {
570+
// A <label for> rather than a <div>, so the control has an accessible name; labels are inline by default.
571+
display: block;
568572
font-size: 0.7rem;
569573
color: var(--p-text-muted-color);
570574
margin-bottom: 0.2rem;

src/main/webapp/app/course/manage/overview/course-management-exercise-row.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
type="button"
172172
class="btn btn-warning me-1 mb-1"
173173
[ngbTooltip]="'artemisApp.exerciseManagement.action.createVariantWithAi' | artemisTranslate"
174+
[attr.aria-label]="'artemisApp.exerciseManagement.action.createVariantWithAi' | artemisTranslate"
174175
(click)="aiVariantModalVisible.set(true)"
175176
>
176177
<fa-icon [icon]="faRobot" />

src/main/webapp/i18n/de/exerciseManagement.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"action": {
88
"import": "Importieren",
99
"export": "Exportieren",
10-
"createVariantWithAi": "Variante erstellen"
10+
"createVariantWithAi": "Variante mit KI erstellen"
1111
},
1212
"error": {
1313
"onlyIndividualQuiz": "Nur Quizze im Einzelmodus können einer Aufgabengruppe hinzugefügt werden."

src/main/webapp/i18n/en/exerciseManagement.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"action": {
88
"import": "Import",
99
"export": "Export",
10-
"createVariantWithAi": "Create Variant"
10+
"createVariantWithAi": "Create Variant with AI"
1111
},
1212
"error": {
1313
"onlyIndividualQuiz": "Only individual-mode quizzes can be added to an exercise group."
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package de.tum.cit.aet.artemis.hyperion.service.variants;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatCode;
5+
import static org.mockito.ArgumentMatchers.any;
6+
import static org.mockito.ArgumentMatchers.anyString;
7+
import static org.mockito.Mockito.doThrow;
8+
import static org.mockito.Mockito.mock;
9+
import static org.mockito.Mockito.when;
10+
11+
import java.util.List;
12+
13+
import org.junit.jupiter.api.BeforeEach;
14+
import org.junit.jupiter.api.Test;
15+
16+
import de.tum.cit.aet.artemis.account.domain.User;
17+
import de.tum.cit.aet.artemis.core.service.distributed.local.LocalDataProviderService;
18+
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
19+
import de.tum.cit.aet.artemis.exercise.domain.ExerciseType;
20+
import de.tum.cit.aet.artemis.hyperion.dto.VariantGenerationRequestDTO;
21+
import de.tum.cit.aet.artemis.hyperion.service.websocket.HyperionWebsocketService;
22+
23+
/**
24+
* Regression test: a failed websocket publish must not undo a persisted terminal transition.
25+
* <p>
26+
* Every terminal transition writes the new phase first and only then publishes. When the publish threw, the
27+
* exception escaped {@code complete()} into the pipeline's terminal catch, which deleted the freshly generated
28+
* variant exercise and overwrote COMPLETED with FAILED — a lost notification destroying verified work.
29+
* Publication is best effort: the client also polls the job endpoints, so a dropped event costs freshness only.
30+
*/
31+
class ExerciseVariantJobServicePublishFailureTest {
32+
33+
private static final String LOGIN = "instructor1";
34+
35+
private ExerciseVariantJobService jobService;
36+
37+
private VariantJob job;
38+
39+
@BeforeEach
40+
void setUp() {
41+
HyperionWebsocketService websocketService = mock(HyperionWebsocketService.class);
42+
// The broker is unavailable / the payload cannot be converted — an unchecked throw that
43+
// HyperionWebsocketService does not catch itself (it only handles Interrupted/ExecutionException).
44+
doThrow(new IllegalStateException("broker unavailable")).when(websocketService).send(anyString(), anyString(), any());
45+
46+
jobService = new ExerciseVariantJobService(new LocalDataProviderService(), websocketService);
47+
jobService.init();
48+
49+
Exercise exercise = mock(Exercise.class);
50+
when(exercise.getId()).thenReturn(1L);
51+
when(exercise.getTitle()).thenReturn("Test Exercise");
52+
when(exercise.getExerciseType()).thenReturn(ExerciseType.PROGRAMMING);
53+
User user = mock(User.class);
54+
when(user.getLogin()).thenReturn(LOGIN);
55+
56+
job = jobService.startJob(user, exercise, mock(VariantGenerationRequestDTO.class));
57+
}
58+
59+
@Test
60+
void shouldKeepTheJobCompletedWhenTheDoneEventCannotBePublished() {
61+
assertThatCode(() -> jobService.complete(job.getJobId(), 42L, List.of())).doesNotThrowAnyException();
62+
63+
VariantJob stored = jobService.getJob(job.getJobId(), LOGIN).orElseThrow();
64+
assertThat(stored.getPhase()).isEqualTo(VariantJobPhase.COMPLETED);
65+
// The deep link must survive: the variant exercise exists and this id is the only pointer to it.
66+
assertThat(stored.getVariantExerciseId()).isEqualTo(42L);
67+
}
68+
69+
@Test
70+
void shouldStillReachTheTerminalPhaseWhenAFailureEventCannotBePublished() {
71+
assertThatCode(() -> jobService.fail(job.getJobId(), "Failed in VERIFYING")).doesNotThrowAnyException();
72+
73+
assertThat(jobService.getJob(job.getJobId(), LOGIN).orElseThrow().getPhase()).isEqualTo(VariantJobPhase.FAILED);
74+
}
75+
76+
@Test
77+
void shouldStillReachTheTerminalPhaseWhenACancellationEventCannotBePublished() {
78+
assertThatCode(() -> jobService.markCancelled(job.getJobId())).doesNotThrowAnyException();
79+
80+
assertThat(jobService.getJob(job.getJobId(), LOGIN).orElseThrow().getPhase()).isEqualTo(VariantJobPhase.CANCELLED);
81+
}
82+
}

0 commit comments

Comments
 (0)