Show which round a loop step is on, and answer for the round seen

In an execution, a step on its second round or later carries a Round N
badge. Answers to a person's question - decisions, evaluations, evidence,
revealing the reference - send the round of its loop the question was
opened on, fixed when the dialog opened rather than read at submit time,
so an answer to an old round's question is refused by the server instead
of being applied to a draft the person never saw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-25 11:41:13 +02:00
parent fbd27c4344
commit f2378216ee
9 changed files with 110 additions and 29 deletions

View File

@ -173,6 +173,8 @@ export type TaskExecutionStep = {
outputs?: TaskExecutionStepOutput[];
result?: Record<string, unknown>;
status: StepStatus;
/** Which round of its loop the step is on; 1, or absent from older servers, for a step in no loop. */
iteration?: number;
started?: boolean;
skipReason?: string | null;
simulated: boolean;

View File

@ -97,11 +97,16 @@ export abstract class TaskExecutionsCallServiceBase {
inputName: string,
files: File[]
): Observable<TaskExecution>;
/**
* `iteration`, when given, is the round of its loop the answer is for: the server refuses it if
* the node has moved on to another, rather than taking it as the answer to a question not seen.
*/
abstract submitInteractionText(
executionId: string,
nodeId: string,
fieldName: string,
value: string
value: string,
iteration?: number
): Observable<TaskExecution>;
/**
* Submits a human evaluation whole - every criterion and the notes in one call - because the
@ -111,17 +116,20 @@ export abstract class TaskExecutionsCallServiceBase {
executionId: string,
nodeId: string,
verdict: Record<string, string>,
notes: string
notes: string,
iteration?: number
): Observable<TaskExecution>;
abstract attachEvaluationEvidence(
executionId: string,
nodeId: string,
files: File[]
files: File[],
iteration?: number
): Observable<TaskExecution>;
/** Asks to see the reference verdict; the execution records that it was seen before judging. */
abstract revealEvaluationReference(
executionId: string,
nodeId: string
nodeId: string,
iteration?: number
): Observable<TaskExecution>;
abstract provideAuthorization(
executionId: string,

View File

@ -269,11 +269,13 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
executionId: string,
nodeId: string,
fieldName: string,
value: string
value: string,
iteration?: number
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/interaction/${encodeURIComponent(fieldName)}/text`;
return this.http.put<unknown>(url, value, {
headers: { 'Content-Type': 'text/plain' }
headers: { 'Content-Type': 'text/plain' },
params: roundParams(iteration)
}).pipe(map((raw) => this.mapExecution(raw)));
}
@ -281,28 +283,33 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
executionId: string,
nodeId: string,
verdict: Record<string, string>,
notes: string
notes: string,
iteration?: number
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation`;
return this.http.put<unknown>(url, { verdict, notes }).pipe(map((raw) => this.mapExecution(raw)));
return this.http.put<unknown>(url, { verdict, notes }, { params: roundParams(iteration) })
.pipe(map((raw) => this.mapExecution(raw)));
}
override attachEvaluationEvidence(
executionId: string,
nodeId: string,
files: File[]
files: File[],
iteration?: number
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation/evidence`;
const formData = new FormData();
for (const file of files) {
formData.append('files', file);
}
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
return this.http.put<unknown>(url, formData, { params: roundParams(iteration) })
.pipe(map((raw) => this.mapExecution(raw)));
}
override revealEvaluationReference(executionId: string, nodeId: string): Observable<TaskExecution> {
override revealEvaluationReference(executionId: string, nodeId: string, iteration?: number): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation/reveal`;
return this.http.put<unknown>(url, {}).pipe(map((raw) => this.mapExecution(raw)));
return this.http.put<unknown>(url, {}, { params: roundParams(iteration) })
.pipe(map((raw) => this.mapExecution(raw)));
}
override provideAuthorization(
@ -777,3 +784,8 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
return Number.isFinite(number) ? number : fallback;
}
}
/** The round an answer is for, as the endpoint's optional `iteration` parameter. */
function roundParams(iteration?: number): Record<string, string> {
return typeof iteration === 'number' ? { iteration: String(iteration) } : {};
}

View File

@ -347,14 +347,14 @@ export class TaskExecutionsService {
);
}
submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string) {
submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string, iteration?: number) {
const execution = this._taskExecutions().find((item) => item.id === executionId);
if (execution?.interactionSimulationEnabled === true) {
return throwError(() => new Error('Manual interaction is disabled for simulated executions.'));
}
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value).pipe(
this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value, iteration).pipe(
tap((updatedExecution) => {
if (updatedExecution.executionKind === 'SUBFLOW') {
this.cacheFollowedExecution(updatedExecution);
@ -367,14 +367,14 @@ export class TaskExecutionsService {
);
}
submitEvaluation(executionId: string, nodeId: string, verdict: Record<string, string>, notes: string) {
submitEvaluation(executionId: string, nodeId: string, verdict: Record<string, string>, notes: string, iteration?: number) {
const execution = this._taskExecutions().find((item) => item.id === executionId);
if (execution?.interactionSimulationEnabled === true) {
return throwError(() => new Error('Manual interaction is disabled for simulated executions.'));
}
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.submitEvaluation(executionId, nodeId, verdict, notes).pipe(
this.taskExecutionsCallService.submitEvaluation(executionId, nodeId, verdict, notes, iteration).pipe(
tap((updatedExecution) => {
if (updatedExecution.executionKind === 'SUBFLOW') {
this.cacheFollowedExecution(updatedExecution);
@ -387,9 +387,9 @@ export class TaskExecutionsService {
);
}
attachEvaluationEvidence(executionId: string, nodeId: string, files: File[]) {
attachEvaluationEvidence(executionId: string, nodeId: string, files: File[], iteration?: number) {
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.attachEvaluationEvidence(executionId, nodeId, files).pipe(
this.taskExecutionsCallService.attachEvaluationEvidence(executionId, nodeId, files, iteration).pipe(
tap((execution) => this.replaceExecution(execution))
),
'Attach evaluation evidence failed',
@ -397,9 +397,9 @@ export class TaskExecutionsService {
);
}
revealEvaluationReference(executionId: string, nodeId: string) {
revealEvaluationReference(executionId: string, nodeId: string, iteration?: number) {
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.revealEvaluationReference(executionId, nodeId).pipe(
this.taskExecutionsCallService.revealEvaluationReference(executionId, nodeId, iteration).pipe(
tap((execution) => this.replaceExecution(execution))
),
'Reveal reference verdict failed',

View File

@ -393,6 +393,18 @@ button.llm-node-bias-summary:focus-visible {
font-size: 11px;
}
/* A loop's round sits at the other corner, so it does not cover a container's subflow badge. */
.llm-subflow-badge-wrap:has(.llm-loop-round-badge) {
left: auto;
right: 12px;
}
.llm-loop-round-badge {
border-color: #c4b5fd;
background: #f5f3ff;
color: #5b21b6;
}
.llm-header {
display: flex;
align-items: center;

View File

@ -44,6 +44,15 @@
</span>
</div>
}
@if (loopRound()) {
<div class="llm-subflow-badge-wrap">
<span class="llm-subflow-badge llm-loop-round-badge"
[title]="'Round ' + loopRound() + ' of its loop - earlier rounds are kept in the execution history'">
<i class="bi bi-arrow-repeat"></i>
<span>Round {{ loopRound() }}</span>
</span>
</div>
}
@if (needsAttention() && isHumanNode()) {
<div class="llm-attention-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button

View File

@ -373,6 +373,7 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
__executionNodeId: 'node-1',
__executionStatus: 'WAITING',
__stepStatus: 'WAITING_FOR_INTERACTION',
__stepIteration: 1,
question: 'Should the candidate proceed?',
options: [
{ name: 'approve', label: 'Approve' },
@ -449,11 +450,28 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
});
expect(executions.submitInteractionText.mock.calls).toEqual([
['execution-1', 'node-1', 'rationale', 'Documented evidence'],
['execution-1', 'node-1', 'choice', 'approve']
['execution-1', 'node-1', 'rationale', 'Documented evidence', 1],
['execution-1', 'node-1', 'choice', 'approve', 1]
]);
});
it('answers for the round of the loop the person saw, even if the page has moved on since', () => {
// Polling may refresh the node to the next round while the dialog is still open. The answer
// is to the question that was asked, so it carries that question's round - and the server
// refuses it rather than applying it to a draft the person never saw.
configureInteraction('human-decision');
component.data.data.specificConfiguration = { ...component.data.data.specificConfiguration, __stepIteration: 3 };
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
const executions = TestBed.inject(TaskExecutionsService) as any;
component.openInteractionModal();
const input = dialog.open.mock.calls.at(-1)[0];
component.data.data.specificConfiguration = { ...component.data.data.specificConfiguration, __stepIteration: 4 };
input.onSubmit({ mode: 'decision', choice: 'approve', rationale: 'Looks right' });
expect(executions.submitInteractionText.mock.calls.map((call: unknown[]) => call[4])).toEqual([3, 3]);
});
it('uses the single-response completion field and never confirms the runtime input', () => {
configureInteraction('single-response');
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
@ -467,7 +485,8 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
'execution-1',
'node-1',
'output',
'Human response'
'Human response',
1
);
});

View File

@ -132,6 +132,12 @@ export class TaskStepNodeComponent {
name = 'Step';
mainContentFields: MainContentView[] = [];
interactionSubmitting = false;
/**
* The round of its loop the open question belongs to, fixed when the person opened it. Read at
* submit time instead, a page refreshed by polling would send the new round's number with an
* answer to the old round's question - exactly what the round is sent to catch.
*/
private interactionRound: number | undefined;
/** Per dialog session: the step holds the truth, these only drive what the form shows. */
private evaluationEvidenceCount = 0;
private evaluationReferenceRevealed = false;
@ -610,6 +616,12 @@ export class TaskStepNodeComponent {
return this.stepStatus() === 'SKIPPED';
}
/** The round of its loop this step is on, or null for a step on its first - or in no loop. */
loopRound(): number | null {
const iteration = this.blockConfiguration?.['__stepIteration'];
return typeof iteration === 'number' && iteration > 1 ? iteration : null;
}
stepStatus(): string {
const status = this.blockConfiguration?.['__stepStatus'];
return typeof status === 'string' ? status.toUpperCase() : '';
@ -632,6 +644,8 @@ export class TaskStepNodeComponent {
const executionNodeId = this.executionNodeId();
const contract = this.interactionContract();
if (!executionId || !executionNodeId || !contract) return;
const iteration = this.blockConfiguration?.['__stepIteration'];
this.interactionRound = typeof iteration === 'number' ? iteration : undefined;
this.humanInteractionDialog.open({
...this.buildInteractionDialogState(executionId, executionNodeId, contract),
@ -1072,7 +1086,7 @@ export class TaskStepNodeComponent {
}
private revealEvaluationReference(executionId: string, executionNodeId: string) {
this.taskExecutionsService.revealEvaluationReference(executionId, executionNodeId).subscribe({
this.taskExecutionsService.revealEvaluationReference(executionId, executionNodeId, this.interactionRound).subscribe({
next: () => {
this.evaluationReferenceRevealed = true;
this.humanInteractionDialog.update({
@ -1089,7 +1103,7 @@ export class TaskStepNodeComponent {
}
private attachEvaluationEvidence(executionId: string, executionNodeId: string, files: File[]) {
this.taskExecutionsService.attachEvaluationEvidence(executionId, executionNodeId, files).subscribe({
this.taskExecutionsService.attachEvaluationEvidence(executionId, executionNodeId, files, this.interactionRound).subscribe({
next: () => {
this.evaluationEvidenceCount += files.length;
this.humanInteractionDialog.update({
@ -1142,7 +1156,8 @@ export class TaskStepNodeComponent {
executionId,
executionNodeId,
interactionFieldName,
result.value
result.value,
this.interactionRound
).subscribe({
next: () => {
this.interactionSubmitting = false;
@ -1185,7 +1200,8 @@ export class TaskStepNodeComponent {
executionId,
executionNodeId,
result.verdict,
result.notes
result.notes,
this.interactionRound
).subscribe({
next: () => {
this.interactionSubmitting = false;
@ -1249,7 +1265,8 @@ export class TaskStepNodeComponent {
executionId,
executionNodeId,
choiceField,
result.choice
result.choice,
this.interactionRound
).subscribe({
next: () => {
this.interactionSubmitting = false;
@ -1267,7 +1284,8 @@ export class TaskStepNodeComponent {
executionId,
executionNodeId,
rationaleField,
rationale
rationale,
this.interactionRound
).subscribe({
next: submitChoice,
error: (error) => this.handleDecisionSubmitError(error)

View File

@ -537,6 +537,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__executionStatus: this.execution()?.context.status ?? null,
__interactionSimulationEnabled: this.execution()?.interactionSimulationEnabled === true,
__stepStatus: step.status,
__stepIteration: typeof step.iteration === 'number' ? step.iteration : 1,
// Which iteration a container is on, so a node that is visibly working can say what it
// is working on rather than only that it is.
__containerIterationIndex: typeof step.containerIterationIndex === 'number'