Skip to content

Commit 85fbe6d

Browse files
bmartelcursoragent
andauthored
feat: FIT-1304: Allow strict task overlap enforcement (#9273)
Co-authored-by: bmartel <bmartel@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 208317a commit 85fbe6d

File tree

13 files changed

+288
-31
lines changed

13 files changed

+288
-31
lines changed

label_studio/projects/functions/next_task.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,38 @@ def get_not_solved_tasks_qs(
225225
_, not_solved_tasks = _try_tasks_with_overlap(not_solved_tasks)
226226
queue_info += (' & ' if queue_info else '') + 'Show overlap first'
227227

228+
# Strict task overlap enforcement: filter out tasks where overlap is already reached
229+
# This prevents NEW annotators/reviewers from getting tasks that are already at their annotation limit
230+
# Note: Only applies to annotators and reviewers - managers and admins can access all tasks
231+
# Note: Postponed tasks are NOT filtered here - they are served with overlap_reached flag
232+
# so users can see their work and understand why they can't submit
233+
if flag_set('fflag_feat_all_fit_1304_strict_overlap', user=user):
234+
lse_project = getattr(project, 'lse_project', None)
235+
is_restricted_role = getattr(user, 'is_annotator', False) or getattr(user, 'is_reviewer', False)
236+
if lse_project and getattr(lse_project, 'strict_task_overlap', False) and is_restricted_role:
237+
# Calculate effective overlap limit
238+
# When agreement_threshold is set, allow additional annotators up to max_additional_annotators_assignable
239+
max_additional = 0
240+
if lse_project.agreement_threshold is not None:
241+
max_additional = lse_project.max_additional_annotators_assignable or 0
242+
243+
# Exclude tasks where distinct annotator count >= effective overlap
244+
# Ground truth annotations don't count toward overlap
245+
tasks_at_overlap = (
246+
Task.objects.filter(project=project)
247+
.annotate(
248+
distinct_annotators=Count(
249+
'annotations__completed_by',
250+
filter=Q(annotations__was_cancelled=False, annotations__ground_truth=False),
251+
distinct=True,
252+
)
253+
)
254+
.filter(distinct_annotators__gte=F('overlap') + max_additional)
255+
.values_list('pk', flat=True)
256+
)
257+
258+
not_solved_tasks = not_solved_tasks.exclude(pk__in=tasks_at_overlap)
259+
228260
return not_solved_tasks, user_solved_tasks_array, queue_info, prioritized_on_agreement
229261

230262

web/apps/labelstudio/src/pages/DataManager/DataManager.jsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,12 @@ export const DataManagerPage = ({ ...props }) => {
139139
api.handleError(response);
140140
});
141141

142-
dataManager.on("toast", ({ message, type }) => {
143-
toast.show({ message, type });
142+
dataManager.on("toast", ({ message, type, id, duration }) => {
143+
toast.show({ message, type, id, duration });
144+
});
145+
146+
dataManager.on("toast:dismiss", ({ id } = {}) => {
147+
toast.dismiss(id);
144148
});
145149

146150
dataManager.on("navigate", (route) => {

web/libs/datamanager/src/sdk/dm-sdk.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import { unmountComponentAtNode } from "react-dom";
4545
import camelCase from "lodash/camelCase";
4646
import { instruments } from "../components/DataManager/Toolbar/instruments";
4747
import { APIProxy } from "../utils/api-proxy";
48-
import { FF_LSDV_4620_3_ML, isFF } from "../utils/feature-flags";
4948
import { objectToMap } from "../utils/helpers";
5049
import { serializeJsonForUrl, deserializeJsonFromUrl } from "@humansignal/core";
5150
import { isDefined } from "../utils/utils";
@@ -453,9 +452,7 @@ export class DataManager {
453452
}
454453

455454
destroy(detachCallbacks = true) {
456-
if (isFF(FF_LSDV_4620_3_ML)) {
457-
this.destroyLSF();
458-
}
455+
this.destroyLSF();
459456
unmountComponentAtNode(this.root);
460457

461458
if (this.store) {

web/libs/datamanager/src/sdk/lsf-sdk.js

Lines changed: 164 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { FF_DEV_1752, FF_DEV_2186, FF_DEV_2887, FF_DEV_3034, FF_LSDV_4620_3_ML, isFF } from "../utils/feature-flags";
1+
import { Button } from "@humansignal/ui";
2+
import {
3+
FF_DEV_1752,
4+
FF_DEV_2186,
5+
FF_DEV_2887,
6+
FF_DEV_3034,
7+
FF_LSDV_4620_3_ML,
8+
FF_FIT_1304_STRICT_OVERLAP,
9+
isFF,
10+
} from "../utils/feature-flags";
211
import { isDefined } from "../utils/utils";
312
import { Modal } from "../components/Common/Modal/Modal";
413
import { CommentsSdk } from "./comments-sdk";
@@ -32,13 +41,24 @@ const resolveLabelStudio = () => {
3241
};
3342

3443
// Returns true to suppress (swallow) the error, false to bubble to global handler.
35-
// We allow 403 PAUSED to bubble so the app-level ApiProvider can show the paused modal
36-
const errorHandlerAllowPaused = (result) => {
44+
// We allow certain errors to bubble so the app-level ApiProvider can show modals:
45+
// - 403 PAUSED: User is paused in the project
46+
// - 400 OVERLAP_REACHED: Annotation overlap limit has been reached (only when feature flag is enabled)
47+
const errorHandlerAllowSpecialErrors = (result) => {
3748
const isPaused =
3849
result?.status === 403 &&
3950
typeof result?.response === "object" &&
4051
result?.response?.display_context?.reason === "PAUSED";
41-
return !isPaused;
52+
53+
// Only handle OVERLAP_REACHED when feature flag is enabled
54+
const isOverlapReached =
55+
isFF(FF_FIT_1304_STRICT_OVERLAP) &&
56+
result?.status === 400 &&
57+
typeof result?.response === "object" &&
58+
result?.response?.display_context?.reason === "OVERLAP_REACHED";
59+
60+
// Return false to allow these errors to bubble up to the global handler
61+
return !(isPaused || isOverlapReached);
4262
};
4363

4464
// Support portal URL constants used to construct error reporting links
@@ -47,6 +67,10 @@ const errorHandlerAllowPaused = (result) => {
4767
export const SUPPORT_URL = "https://support.humansignal.com/hc/en-us/requests/new";
4868
export const SUPPORT_URL_REQUEST_ID_PARAM = "tf_37934448633869"; // request_id field ID in ZD
4969

70+
// Toast ID for overlap reached message - used to dismiss this specific toast
71+
// without affecting other toasts like "Annotation Saved"
72+
const OVERLAP_TOAST_ID = "overlap-reached-toast";
73+
5074
export class LSFWrapper {
5175
/** @type {HTMLElement} */
5276
root = null;
@@ -106,6 +130,16 @@ export class LSFWrapper {
106130
this.interfacesModifier = interfacesModifier;
107131
this.isInteractivePreannotations = isInteractivePreannotations ?? false;
108132

133+
// Listen for overlap error modal events (only when feature flag is enabled)
134+
if (isFF(FF_FIT_1304_STRICT_OVERLAP)) {
135+
this.handleOverlapNextTask = () => this.loadTask();
136+
this.handleOverlapCloseTask = () => this.closeTask();
137+
this.handleOverlapExitStream = () => this.exitStream();
138+
window.addEventListener("overlap-error-next-task", this.handleOverlapNextTask);
139+
window.addEventListener("overlap-error-close-task", this.handleOverlapCloseTask);
140+
window.addEventListener("overlap-error-exit-stream", this.handleOverlapExitStream);
141+
}
142+
109143
let interfaces = [...DEFAULT_INTERFACES];
110144

111145
if (this.project.enable_empty_annotation === false) {
@@ -343,6 +377,10 @@ export class LSFWrapper {
343377
setLSFTask(task, annotationID, fromHistory, selectPrediction = false) {
344378
if (!this.lsf) return;
345379

380+
if (isFF(FF_FIT_1304_STRICT_OVERLAP)) {
381+
this.dismissOverlapToast();
382+
}
383+
346384
const hasChangedTasks = this.lsf?.task?.id !== task?.id && task?.id;
347385

348386
this.setLoading(true, hasChangedTasks);
@@ -383,10 +421,74 @@ export class LSFWrapper {
383421
// undefined or true for backward compatibility
384422
this.lsf.toggleInterface("postpone", this.task.allow_postpone !== false);
385423
this.lsf.toggleInterface("topbar:task-counter", true);
424+
425+
if (isFF(FF_FIT_1304_STRICT_OVERLAP)) {
426+
// Handle strict task overlap - disable submission controls when overlap is reached
427+
// Only process when feature flag is enabled
428+
const overlapReached = this.task.overlap_reached === true;
429+
this.overlapReached = overlapReached;
430+
this.overlapReachedMessage =
431+
this.task.overlap_reached_message ||
432+
"Annotation overlap has been reached for this task. Your draft is preserved but cannot be submitted.";
433+
434+
// Set overlap state on LSF store - this will disable buttons with tooltips
435+
this.lsf.setFlags({
436+
overlapReached,
437+
overlapReachedMessage: this.overlapReachedMessage,
438+
});
439+
} else {
440+
this.overlapReached = false;
441+
this.overlapReachedMessage = "";
442+
}
443+
386444
this.lsf.assignTask(task);
387445
this.lsf.initializeStore(lsfTask);
388446
this.setAnnotation(annotationID, fromHistory || isRejectedQueue, selectPrediction);
389447
this.setLoading(false);
448+
449+
if (isFF(FF_FIT_1304_STRICT_OVERLAP) && this.overlapReached) {
450+
// Show informational message if overlap is reached (only when feature flag is enabled)
451+
this.showOverlapReachedMessage();
452+
}
453+
}
454+
455+
/**
456+
* Show informational message when overlap is reached
457+
* @private
458+
*/
459+
showOverlapReachedMessage() {
460+
// Use info toast to communicate the overlap status
461+
// This is informational, not an error, so we use a neutral tone
462+
// Use a specific ID so we can dismiss this toast without affecting others
463+
this.datamanager.invoke("toast", {
464+
id: OVERLAP_TOAST_ID,
465+
message: (
466+
<div className="flex items-center justify-between">
467+
<span>{this.overlapReachedMessage}</span>
468+
<Button
469+
onClick={() => {
470+
this.datamanager.invoke("toast:dismiss", { id: OVERLAP_TOAST_ID });
471+
this.handleOverlapNextTask();
472+
}}
473+
className="ml-4"
474+
size="small"
475+
look="outlined"
476+
>
477+
Next Task
478+
</Button>
479+
</div>
480+
),
481+
type: "info",
482+
duration: -1,
483+
});
484+
}
485+
486+
/**
487+
* Dismiss the overlap reached toast if it's showing
488+
* @private
489+
*/
490+
dismissOverlapToast() {
491+
this.datamanager.invoke("toast:dismiss", { id: OVERLAP_TOAST_ID });
390492
}
391493

392494
/** @private */
@@ -597,6 +699,28 @@ export class LSFWrapper {
597699
if (status === 200 || status === 201) {
598700
this.datamanager.invoke("toast", { message: successMessage, type: "info" });
599701
} else if (status !== undefined) {
702+
// Skip toast for errors that are handled by global modal handlers via display_context
703+
// These errors bubble up to ApiProvider which shows appropriate modals
704+
// Note: display_context is in result.response for API error responses
705+
const displayReason = result?.response?.display_context?.reason;
706+
const isPausedError = displayReason === "PAUSED";
707+
const isOverlapError = isFF(FF_FIT_1304_STRICT_OVERLAP) && displayReason === "OVERLAP_REACHED";
708+
if (isPausedError || isOverlapError) {
709+
// Also update local state for overlap reached (only when feature flag is enabled)
710+
if (isOverlapError) {
711+
this.overlapReached = true;
712+
this.overlapReachedMessage =
713+
result?.response?.detail ||
714+
"Annotation overlap has been reached for this task. Your draft is preserved but cannot be submitted.";
715+
// Set overlap state on LSF store - this will disable buttons with tooltips
716+
this.lsf.setFlags({
717+
overlapReached: true,
718+
overlapReachedMessage: this.overlapReachedMessage,
719+
});
720+
}
721+
return;
722+
}
723+
600724
const requestId = result?.$meta?.headers?.get("x-ls-request-id");
601725
const supportUrl = requestId ? `${SUPPORT_URL}?${SUPPORT_URL_REQUEST_ID_PARAM}=${requestId}` : SUPPORT_URL;
602726

@@ -623,6 +747,12 @@ export class LSFWrapper {
623747

624748
/** @private */
625749
onSubmitAnnotation = async () => {
750+
// Prevent submission if overlap is reached (only when feature flag is enabled)
751+
if (isFF(FF_FIT_1304_STRICT_OVERLAP) && this.overlapReached) {
752+
this.showOverlapReachedMessage();
753+
return;
754+
}
755+
626756
const exitStream = this.shouldExitStream();
627757
const loadNext = exitStream ? false : this.shouldLoadNext();
628758
const result = await this.submitCurrentAnnotation(
@@ -633,7 +763,7 @@ export class LSFWrapper {
633763
{ taskID },
634764
{ body },
635765
// errors are displayed by "toast" event - we don't want to show blocking modal
636-
{ errorHandler: errorHandlerAllowPaused },
766+
{ errorHandler: errorHandlerAllowSpecialErrors },
637767
);
638768
},
639769
false,
@@ -667,7 +797,7 @@ export class LSFWrapper {
667797
body: serializedAnnotation,
668798
},
669799
// errors are displayed by "toast" event - we don't want to show blocking modal
670-
{ errorHandler: errorHandlerAllowPaused },
800+
{ errorHandler: errorHandlerAllowSpecialErrors },
671801
);
672802
});
673803
const status = result?.$meta?.status;
@@ -804,6 +934,12 @@ export class LSFWrapper {
804934
};
805935

806936
onSkipTask = async (_, { comment } = {}) => {
937+
// Prevent skipping if overlap is reached (only when feature flag is enabled)
938+
if (isFF(FF_FIT_1304_STRICT_OVERLAP) && this.overlapReached) {
939+
this.showOverlapReachedMessage();
940+
return;
941+
}
942+
807943
// Manager roles that can force-skip unskippable tasks (OW=Owner, AD=Admin, MA=Manager)
808944
const MANAGER_ROLES = ["OW", "AD", "MA"];
809945
const task = this.task;
@@ -814,7 +950,9 @@ export class LSFWrapper {
814950
const canSkip = !skipDisabled || hasForceSkipPermission;
815951
if (!canSkip) {
816952
console.warn("Task cannot be skipped: allow_skip is false and user lacks manager role");
817-
this.showOperationToast(400, null, "This task cannot be skipped", { error: "Task cannot be skipped" });
953+
this.showOperationToast(400, null, "This task cannot be skipped", {
954+
error: "Task cannot be skipped",
955+
});
818956
return;
819957
}
820958
const result = await this.submitCurrentAnnotation(
@@ -832,7 +970,7 @@ export class LSFWrapper {
832970
id === undefined ? "submitAnnotation" : "updateAnnotation",
833971
params,
834972
options,
835-
{ errorHandler: errorHandlerAllowPaused },
973+
{ errorHandler: errorHandlerAllowSpecialErrors },
836974
);
837975
},
838976
true,
@@ -1082,10 +1220,28 @@ export class LSFWrapper {
10821220
}
10831221

10841222
destroy() {
1223+
// Clean up overlap error event listeners and dismiss toast (only when feature flag is enabled)
1224+
if (isFF(FF_FIT_1304_STRICT_OVERLAP)) {
1225+
window.removeEventListener("overlap-error-next-task", this.handleOverlapNextTask);
1226+
window.removeEventListener("overlap-error-close-task", this.handleOverlapCloseTask);
1227+
window.removeEventListener("overlap-error-exit-stream", this.handleOverlapExitStream);
1228+
// Dismiss the overlap toast if it's showing - this ensures the toast doesn't
1229+
// persist after leaving the labeling interface
1230+
this.dismissOverlapToast();
1231+
}
1232+
10851233
this.lsfInstance?.destroy?.();
10861234
this.lsfInstance = null;
10871235
}
10881236

1237+
/**
1238+
* Close the current task panel (for DataManager context)
1239+
*/
1240+
closeTask() {
1241+
// Invoke the data manager's close task action
1242+
this.datamanager.invoke("closeTask");
1243+
}
1244+
10891245
get taskID() {
10901246
return this.task.id;
10911247
}

web/libs/datamanager/src/stores/Assignee.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,14 @@ export const Assignee = types
6666
};
6767
} else {
6868
const { user_id, annotated, review, reviewed, ...user } = sn;
69+
const id = user_id ?? sn.id;
6970

7071
// When global user fetching is disabled, always create user objects, otherwise use references via user id
7172
// If we only have user_id and no other user properties, just use the user_id as reference
7273
const hasUserProperties = Object.keys(user).length > 0;
7374
result = {
74-
id: user_id,
75-
user: isFF(FF_DISABLE_GLOBAL_USER_FETCHING) && hasUserProperties ? { id: user_id, ...user } : user_id, // Use user_id as reference
75+
id,
76+
user: isFF(FF_DISABLE_GLOBAL_USER_FETCHING) && hasUserProperties ? { id, ...user } : id, // Use user_id as reference
7677
annotated,
7778
review,
7879
reviewed,

web/libs/datamanager/src/stores/DataStores/tasks.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const create = (columns) => {
2929
drafts: types.frozen(),
3030
source: types.maybeNull(types.string),
3131
was_cancelled: false,
32+
overlap_reached: types.maybeNull(types.boolean),
33+
overlap_reached_message: types.maybeNull(types.string),
3234
assigned_task: false,
3335
queue: types.optional(types.maybeNull(types.string), null),
3436
// annotation to select on rejected queue

web/libs/datamanager/src/utils/feature-flags.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ export const FF_DISABLE_GLOBAL_USER_FETCHING =
6262
*/
6363
export const FF_INTERACTIVE_JSON_VIEWER = "fflag_feat_front_interactive_json_viewer_short";
6464

65+
/**
66+
* Strict task overlap enforcement - prevents annotators from submitting
67+
* annotations when task overlap limit has been reached
68+
*/
69+
export const FF_FIT_1304_STRICT_OVERLAP = "fflag_feat_all_fit_1304_strict_overlap";
70+
6571
// Customize flags
6672
const flags = {};
6773

0 commit comments

Comments
 (0)