fix: protect running validation tasks from queue cleanup
This commit is contained in:
16
README.md
16
README.md
@@ -153,9 +153,12 @@ older-than-seven-days tasks beyond that account's current limit minus 10. Later
|
|||||||
scheduled cleanup uses limit minus 5, retaining a small hysteresis buffer that
|
scheduled cleanup uses limit minus 5, retaining a small hysteresis buffer that
|
||||||
avoids repeatedly over-pruning valid work. For a 100-task account the two
|
avoids repeatedly over-pruning valid work. For a 100-task account the two
|
||||||
boundaries are 90 and 95; for a 500-task account they are 490 and 495. Recent
|
boundaries are 90 and 95; for a 500-task account they are 490 and 495. Recent
|
||||||
overflow tasks are always kept. ModelScope metadata failures fail closed and
|
overflow tasks are always kept. Age-based cleanup applies only to tasks that are
|
||||||
never trigger cancellation. Immediately before mutation, task ownership, active
|
still waiting; running validation tasks are protected even beyond the boundary.
|
||||||
status, and post-OOM queue position are checked again.
|
ModelScope metadata failures fail closed and never trigger cancellation.
|
||||||
|
Immediately before the age-only stop batch, task ownership, active status, and
|
||||||
|
post-OOM queue position are checked again. Deterministic OOM cleanup may still
|
||||||
|
stop a running task because it cannot fit the selected GPU.
|
||||||
|
|
||||||
The verified capacities, safe repository-size boundaries, evidence hierarchy,
|
The verified capacities, safe repository-size boundaries, evidence hierarchy,
|
||||||
and source links are recorded in
|
and source links are recorded in
|
||||||
@@ -247,12 +250,15 @@ after OOM cleanup and a second queue check.
|
|||||||
Version `2026.08.11.4` replaces fixed queue positions with per-account dynamic
|
Version `2026.08.11.4` replaces fixed queue positions with per-account dynamic
|
||||||
boundaries derived from each discovered capacity: limit minus 10 for admission
|
boundaries derived from each discovered capacity: limit minus 10 for admission
|
||||||
and startup cleanup, then limit minus 5 for scheduled dynamic cleanup.
|
and startup cleanup, then limit minus 5 for scheduled dynamic cleanup.
|
||||||
|
Version `2026.08.12.1` protects running tasks from age-based cleanup and records
|
||||||
|
worker-initiated stops as `policy_cancelled`, excluding them from success-rate,
|
||||||
|
failure-cooldown, and circuit-breaker evidence.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Create a tag and submit the repository URL plus tag in "我的适配智能体".
|
Create a tag and submit the repository URL plus tag in "我的适配智能体".
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag agent-v18
|
git tag agent-v19
|
||||||
git push origin agent-v18
|
git push origin agent-v19
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -124,8 +124,10 @@ bash run_poll.sh --dry-run
|
|||||||
positions. The account pool enforces this per-account boundary atomically and updates it when
|
positions. The account pool enforces this per-account boundary atomically and updates it when
|
||||||
capacity probing discovers a higher limit. Cleanup stops OOM tasks first, recalculates the
|
capacity probing discovers a higher limit. Cleanup stops OOM tasks first, recalculates the
|
||||||
surviving queue order, and applies the same limit-minus-10 boundary on startup. Scheduled
|
surviving queue order, and applies the same limit-minus-10 boundary on startup. Scheduled
|
||||||
cleanup relaxes to limit minus 5 to avoid excessive pruning. Recent overflow tasks stay, and
|
cleanup relaxes to limit minus 5 to avoid excessive pruning. Age cleanup stops waiting tasks
|
||||||
unknown ModelScope timestamps never authorize a cancellation.
|
only; running tasks are protected and rechecked after OOM cleanup, immediately before the
|
||||||
|
age-only stop batch. Recent
|
||||||
|
overflow tasks stay, and unknown ModelScope timestamps never authorize a cancellation.
|
||||||
|
|
||||||
## Important Flags
|
## Important Flags
|
||||||
|
|
||||||
@@ -167,6 +169,11 @@ minus 5, while admission continues to reserve the final 10 slots for recent
|
|||||||
models. Override these suffix sizes with `MODELHUB_RECENT_MODEL_RESERVE_SLOTS`
|
models. Override these suffix sizes with `MODELHUB_RECENT_MODEL_RESERVE_SLOTS`
|
||||||
and `MODELHUB_DYNAMIC_OLD_MODEL_CLEANUP_RESERVE_SLOTS`.
|
and `MODELHUB_DYNAMIC_OLD_MODEL_CLEANUP_RESERVE_SLOTS`.
|
||||||
|
|
||||||
|
Every successful worker-initiated stop is persisted as `policy_cancelled` in the
|
||||||
|
outcome store. It is excluded from GPU/framework success rates, local failure
|
||||||
|
cooldowns, and circuit breakers. If a task races to a real success before the
|
||||||
|
stop takes effect, that success remains authoritative.
|
||||||
|
|
||||||
Failure-informed preflight is enabled by default. It rejects deterministic
|
Failure-informed preflight is enabled by default. It rejects deterministic
|
||||||
missing-file and predicted-OOM cases, clamps unsafe context-length arguments,
|
missing-file and predicted-OOM cases, clamps unsafe context-length arguments,
|
||||||
and records its decisions in `candidatePreflight` and each candidate's
|
and records its decisions in `candidatePreflight` and each candidate's
|
||||||
|
|||||||
@@ -88,6 +88,49 @@ class OutcomeTracker:
|
|||||||
self._by_model_gpu[(model_id, target_gpu)].append(record)
|
self._by_model_gpu[(model_id, target_gpu)].append(record)
|
||||||
append_jsonl(self.path, record)
|
append_jsonl(self.path, record)
|
||||||
|
|
||||||
|
def mark_policy_cancellations(self, decisions: list[dict[str, Any]]) -> int:
|
||||||
|
"""Persist intentional task stops so they never become failure evidence."""
|
||||||
|
marked = 0
|
||||||
|
marked_at = _now_iso()
|
||||||
|
for decision in decisions:
|
||||||
|
task_id_value = decision.get("taskId")
|
||||||
|
if task_id_value is None:
|
||||||
|
continue
|
||||||
|
task_id = str(task_id_value)
|
||||||
|
existing = self._by_task_id.get(task_id)
|
||||||
|
if existing is not None and existing.get("outcome") in {"success", "failed"}:
|
||||||
|
continue
|
||||||
|
reasons = list(decision.get("cleanupReasons") or [decision.get("reason")])
|
||||||
|
reasons = [str(reason) for reason in reasons if reason]
|
||||||
|
if existing is None:
|
||||||
|
existing = {
|
||||||
|
"modelId": decision.get("modelId") or "",
|
||||||
|
"targetGpu": decision.get("gpuType") or decision.get("targetGpu") or "",
|
||||||
|
"framework": decision.get("framework") or "",
|
||||||
|
"taskType": decision.get("taskType") or "",
|
||||||
|
"taskId": task_id,
|
||||||
|
"submitTime": decision.get("submitTime") or marked_at,
|
||||||
|
"verifyResult": None,
|
||||||
|
}
|
||||||
|
self._records.append(existing)
|
||||||
|
self._by_task_id[task_id] = existing
|
||||||
|
self._by_model_gpu[(existing["modelId"], existing["targetGpu"])].append(existing)
|
||||||
|
existing.update(
|
||||||
|
{
|
||||||
|
"lastSyncTime": marked_at,
|
||||||
|
"status": "cancellation_requested",
|
||||||
|
"outcome": "policy_cancelled",
|
||||||
|
"failReason": None,
|
||||||
|
"policyCancelled": True,
|
||||||
|
"policyCancellationReasons": reasons,
|
||||||
|
"policyCancelledAt": marked_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
marked += 1
|
||||||
|
if marked:
|
||||||
|
self._rebuild_failed_index()
|
||||||
|
return marked
|
||||||
|
|
||||||
def sync_from_api(self, client: ModelHubClient | ModelHubClientPool) -> int:
|
def sync_from_api(self, client: ModelHubClient | ModelHubClientPool) -> int:
|
||||||
try:
|
try:
|
||||||
begin = self._last_sync_time
|
begin = self._last_sync_time
|
||||||
@@ -109,7 +152,7 @@ class OutcomeTracker:
|
|||||||
|
|
||||||
existing = self._by_task_id.get(task_id)
|
existing = self._by_task_id.get(task_id)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if existing.get("outcome") == "pending":
|
if existing.get("outcome") in {"pending", "policy_cancelled"}:
|
||||||
self._update_record_from_task(existing, task)
|
self._update_record_from_task(existing, task)
|
||||||
if existing.get("outcome") == "failed" and existing.get("logCosUrl"):
|
if existing.get("outcome") == "failed" and existing.get("logCosUrl"):
|
||||||
enrichment_candidates.append(existing)
|
enrichment_candidates.append(existing)
|
||||||
@@ -287,6 +330,9 @@ class OutcomeTracker:
|
|||||||
warnings.append(f"组合 {key} 近期失败集中,建议降低该 GPU+框架的提交优先级。")
|
warnings.append(f"组合 {key} 近期失败集中,建议降低该 GPU+框架的提交优先级。")
|
||||||
|
|
||||||
pending_count = sum(1 for r in self._records if r.get("outcome") == "pending")
|
pending_count = sum(1 for r in self._records if r.get("outcome") == "pending")
|
||||||
|
policy_cancelled_count = sum(
|
||||||
|
1 for record in self._records if record.get("outcome") == "policy_cancelled"
|
||||||
|
)
|
||||||
observed_gpu_memory: dict[str, float] = {}
|
observed_gpu_memory: dict[str, float] = {}
|
||||||
for record in self._records:
|
for record in self._records:
|
||||||
gpu = str(record.get("targetGpu") or "")
|
gpu = str(record.get("targetGpu") or "")
|
||||||
@@ -302,6 +348,7 @@ class OutcomeTracker:
|
|||||||
"generatedAt": now,
|
"generatedAt": now,
|
||||||
"totalRecords": len(self._records),
|
"totalRecords": len(self._records),
|
||||||
"pendingRecords": pending_count,
|
"pendingRecords": pending_count,
|
||||||
|
"policyCancelledRecords": policy_cancelled_count,
|
||||||
"terminalRecords": len(terminal),
|
"terminalRecords": len(terminal),
|
||||||
"gpuSummaries": gpu_summaries,
|
"gpuSummaries": gpu_summaries,
|
||||||
"frameworkSummaries": framework_summaries,
|
"frameworkSummaries": framework_summaries,
|
||||||
@@ -337,9 +384,10 @@ class OutcomeTracker:
|
|||||||
self._failed_model_gpus.clear()
|
self._failed_model_gpus.clear()
|
||||||
latest_by_combo: dict[tuple[str, str], tuple[datetime, dict[str, Any]]] = {}
|
latest_by_combo: dict[tuple[str, str], tuple[datetime, dict[str, Any]]] = {}
|
||||||
for record in self._records:
|
for record in self._records:
|
||||||
# Infrastructure failures neither clear nor create a model/GPU
|
# Infrastructure failures and our own policy cancellations neither
|
||||||
# cooldown. Look through them to the latest attributable outcome.
|
# clear nor create a model/GPU cooldown. Look through them to the
|
||||||
if _is_platform_failure(record):
|
# latest attributable outcome.
|
||||||
|
if _is_platform_failure(record) or _is_policy_cancelled(record):
|
||||||
continue
|
continue
|
||||||
model_id = record.get("modelId") or ""
|
model_id = record.get("modelId") or ""
|
||||||
target_gpu = record.get("targetGpu") or ""
|
target_gpu = record.get("targetGpu") or ""
|
||||||
@@ -362,9 +410,19 @@ class OutcomeTracker:
|
|||||||
record["lastSyncTime"] = _now_iso()
|
record["lastSyncTime"] = _now_iso()
|
||||||
if task.get("logCosUrl"):
|
if task.get("logCosUrl"):
|
||||||
record["logCosUrl"] = task.get("logCosUrl")
|
record["logCosUrl"] = task.get("logCosUrl")
|
||||||
|
status = str(task.get("status") or "").strip().lower()
|
||||||
if is_success(task):
|
if is_success(task):
|
||||||
record["outcome"] = "success"
|
record["outcome"] = "success"
|
||||||
record["failReason"] = None
|
record["failReason"] = None
|
||||||
|
if record.get("policyCancelled"):
|
||||||
|
record["policyCancellationResolvedAsSuccess"] = True
|
||||||
|
record["policyCancelled"] = False
|
||||||
|
elif record.get("policyCancelled"):
|
||||||
|
# A successful stop may be observed as waiting/running briefly before
|
||||||
|
# the platform publishes its terminal cancellation state. None of
|
||||||
|
# those intermediate states should become failure evidence.
|
||||||
|
record["outcome"] = "policy_cancelled"
|
||||||
|
record["failReason"] = None
|
||||||
elif is_failure(task):
|
elif is_failure(task):
|
||||||
record["outcome"] = "failed"
|
record["outcome"] = "failed"
|
||||||
record["failReason"] = classify_failure(task)
|
record["failReason"] = classify_failure(task)
|
||||||
@@ -439,6 +497,10 @@ def _is_platform_failure(record: dict[str, Any]) -> bool:
|
|||||||
return scope == "platform" or category.startswith("platform_")
|
return scope == "platform" or category.startswith("platform_")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_policy_cancelled(record: dict[str, Any]) -> bool:
|
||||||
|
return bool(record.get("policyCancelled")) or record.get("outcome") == "policy_cancelled"
|
||||||
|
|
||||||
|
|
||||||
def _consecutive_attributable_failures(records: list[dict[str, Any]]) -> int:
|
def _consecutive_attributable_failures(records: list[dict[str, Any]]) -> int:
|
||||||
count = 0
|
count = 0
|
||||||
for record in records:
|
for record in records:
|
||||||
@@ -486,7 +548,7 @@ def _outcome_record_key(record: dict[str, Any]) -> str:
|
|||||||
def _outcome_version(record: dict[str, Any]) -> tuple[int, float, float]:
|
def _outcome_version(record: dict[str, Any]) -> tuple[int, float, float]:
|
||||||
last_sync = parse_datetime(record.get("lastSyncTime"))
|
last_sync = parse_datetime(record.get("lastSyncTime"))
|
||||||
submit_time = parse_datetime(record.get("submitTime"))
|
submit_time = parse_datetime(record.get("submitTime"))
|
||||||
outcome_rank = 1 if record.get("outcome") in {"success", "failed"} else 0
|
outcome_rank = 1 if record.get("outcome") in {"success", "failed", "policy_cancelled"} else 0
|
||||||
return (
|
return (
|
||||||
outcome_rank,
|
outcome_rank,
|
||||||
last_sync.timestamp() if last_sync else 0.0,
|
last_sync.timestamp() if last_sync else 0.0,
|
||||||
|
|||||||
@@ -324,6 +324,12 @@ def run_poll_loop(
|
|||||||
age_reserved_slots=age_cleanup_reserve_slots,
|
age_reserved_slots=age_cleanup_reserve_slots,
|
||||||
log=log,
|
log=log,
|
||||||
)
|
)
|
||||||
|
policy_cancelled_recorded = outcome_tracker.mark_policy_cancellations(
|
||||||
|
cleanup_summary["cancelledTasks"]
|
||||||
|
)
|
||||||
|
if policy_cancelled_recorded:
|
||||||
|
outcome_tracker.save()
|
||||||
|
cleanup_summary["policyCancelledRecorded"] = policy_cancelled_recorded
|
||||||
write_json(
|
write_json(
|
||||||
Path(
|
Path(
|
||||||
getattr(
|
getattr(
|
||||||
@@ -344,6 +350,7 @@ def run_poll_loop(
|
|||||||
"certainOomCount": cleanup_summary["certainOomCount"],
|
"certainOomCount": cleanup_summary["certainOomCount"],
|
||||||
"oldOverflowCount": cleanup_summary["oldOverflowCount"],
|
"oldOverflowCount": cleanup_summary["oldOverflowCount"],
|
||||||
"cancelledCount": cleanup_summary["cancelledCount"],
|
"cancelledCount": cleanup_summary["cancelledCount"],
|
||||||
|
"policyCancelledRecorded": policy_cancelled_recorded,
|
||||||
"stopErrorCount": len(cleanup_summary["stopErrors"]),
|
"stopErrorCount": len(cleanup_summary["stopErrors"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ def find_old_overflow_tasks(
|
|||||||
"recentOverflowTasks": 0,
|
"recentOverflowTasks": 0,
|
||||||
"modelAgeUnknown": 0,
|
"modelAgeUnknown": 0,
|
||||||
"accountThresholdUnknown": 0,
|
"accountThresholdUnknown": 0,
|
||||||
|
"runningOverflowProtected": 0,
|
||||||
}
|
}
|
||||||
for account_index, account_tasks in sorted(grouped.items()):
|
for account_index, account_tasks in sorted(grouped.items()):
|
||||||
if account_index in incomplete_accounts:
|
if account_index in incomplete_accounts:
|
||||||
@@ -278,6 +279,9 @@ def find_old_overflow_tasks(
|
|||||||
for position, task in enumerate(ordered, start=1):
|
for position, task in enumerate(ordered, start=1):
|
||||||
if position <= threshold:
|
if position <= threshold:
|
||||||
continue
|
continue
|
||||||
|
if task.status != "waiting":
|
||||||
|
skipped["runningOverflowProtected"] += 1
|
||||||
|
continue
|
||||||
last_modified = model_last_modified.get(task.model_id)
|
last_modified = model_last_modified.get(task.model_id)
|
||||||
if last_modified is None:
|
if last_modified is None:
|
||||||
skipped["modelAgeUnknown"] += 1
|
skipped["modelAgeUnknown"] += 1
|
||||||
@@ -390,6 +394,7 @@ def cleanup_certain_oom_tasks(
|
|||||||
(item for item in age_rank_tasks if item.account_index == account_index),
|
(item for item in age_rank_tasks if item.account_index == account_index),
|
||||||
key=lambda item: item.task_id,
|
key=lambda item: item.task_id,
|
||||||
)[queue_thresholds.get(account_index, len(age_rank_tasks)):]
|
)[queue_thresholds.get(account_index, len(age_rank_tasks)):]
|
||||||
|
if task.status == "waiting"
|
||||||
}
|
}
|
||||||
model_last_modified, age_errors = _load_model_last_modified(
|
model_last_modified, age_errors = _load_model_last_modified(
|
||||||
overflow_model_ids,
|
overflow_model_ids,
|
||||||
@@ -410,7 +415,8 @@ def cleanup_certain_oom_tasks(
|
|||||||
f"thresholds={','.join(str(queue_thresholds[index]) for index in sorted(queue_thresholds))} "
|
f"thresholds={','.join(str(queue_thresholds[index]) for index in sorted(queue_thresholds))} "
|
||||||
f"reserve_recent_slots={reserved_slots} recent_days={recent_model_days} "
|
f"reserve_recent_slots={reserved_slots} recent_days={recent_model_days} "
|
||||||
f"recent_overflow={age_skipped['recentOverflowTasks']} "
|
f"recent_overflow={age_skipped['recentOverflowTasks']} "
|
||||||
f"age_unknown={age_skipped['modelAgeUnknown']}"
|
f"age_unknown={age_skipped['modelAgeUnknown']} "
|
||||||
|
f"running_protected={age_skipped['runningOverflowProtected']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
decisions_by_key: dict[tuple[int, int], dict[str, Any]] = {}
|
decisions_by_key: dict[tuple[int, int], dict[str, Any]] = {}
|
||||||
@@ -444,8 +450,10 @@ def cleanup_certain_oom_tasks(
|
|||||||
refreshed_tasks, refresh_errors = collect_active_tasks(clients, read_concurrency=read_concurrency)
|
refreshed_tasks, refresh_errors = collect_active_tasks(clients, read_concurrency=read_concurrency)
|
||||||
active_ids_by_account: dict[int, set[int]] = {}
|
active_ids_by_account: dict[int, set[int]] = {}
|
||||||
active_positions_by_account: dict[int, dict[int, int]] = {}
|
active_positions_by_account: dict[int, dict[int, int]] = {}
|
||||||
|
active_status_by_account: dict[int, dict[int, str]] = {}
|
||||||
for task in refreshed_tasks:
|
for task in refreshed_tasks:
|
||||||
active_ids_by_account.setdefault(task.account_index, set()).add(task.task_id)
|
active_ids_by_account.setdefault(task.account_index, set()).add(task.task_id)
|
||||||
|
active_status_by_account.setdefault(task.account_index, {})[task.task_id] = task.status
|
||||||
for account_index in range(len(clients)):
|
for account_index in range(len(clients)):
|
||||||
planned_oom_ids = {
|
planned_oom_ids = {
|
||||||
int(decision["taskId"])
|
int(decision["taskId"])
|
||||||
@@ -481,14 +489,25 @@ def cleanup_certain_oom_tasks(
|
|||||||
cleanup_reasons = set(decision.get("cleanupReasons") or [decision.get("reason")])
|
cleanup_reasons = set(decision.get("cleanupReasons") or [decision.get("reason")])
|
||||||
age_only = cleanup_reasons == {"old_model_beyond_account_queue_threshold"}
|
age_only = cleanup_reasons == {"old_model_beyond_account_queue_threshold"}
|
||||||
current_position = active_positions_by_account.get(account_index, {}).get(int(decision["taskId"]))
|
current_position = active_positions_by_account.get(account_index, {}).get(int(decision["taskId"]))
|
||||||
|
current_status = active_status_by_account.get(account_index, {}).get(int(decision["taskId"]))
|
||||||
account_queue_threshold = queue_thresholds.get(account_index)
|
account_queue_threshold = queue_thresholds.get(account_index)
|
||||||
if age_only and (
|
if age_only and (
|
||||||
current_position is None
|
current_position is None
|
||||||
or account_queue_threshold is None
|
or account_queue_threshold is None
|
||||||
or current_position <= account_queue_threshold
|
or current_position <= account_queue_threshold
|
||||||
|
or current_status != "waiting"
|
||||||
):
|
):
|
||||||
policy_no_longer_applies.append(
|
policy_no_longer_applies.append(
|
||||||
{**decision, "recheckedQueuePosition": current_position}
|
{
|
||||||
|
**decision,
|
||||||
|
"recheckedQueuePosition": current_position,
|
||||||
|
"recheckedStatus": current_status,
|
||||||
|
"policyChangeReason": (
|
||||||
|
"task_started_running"
|
||||||
|
if current_status == "running"
|
||||||
|
else "queue_position_or_status_changed"
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if current_position is not None:
|
if current_position is not None:
|
||||||
@@ -501,16 +520,72 @@ def cleanup_certain_oom_tasks(
|
|||||||
if stop_failed:
|
if stop_failed:
|
||||||
break
|
break
|
||||||
decisions_by_id = {int(item["taskId"]): item for item in by_account[account_index]}
|
decisions_by_id = {int(item["taskId"]): item for item in by_account[account_index]}
|
||||||
phase_ids = [
|
for is_oom_phase in (True, False):
|
||||||
sorted(
|
task_ids = sorted(
|
||||||
task_id
|
task_id
|
||||||
for task_id, decision in decisions_by_id.items()
|
for task_id, decision in decisions_by_id.items()
|
||||||
if ("certain_oom_repository_size_exceeds_gpu_capacity" in decision["cleanupReasons"])
|
if ("certain_oom_repository_size_exceeds_gpu_capacity" in decision["cleanupReasons"])
|
||||||
== is_oom_phase
|
== is_oom_phase
|
||||||
)
|
)
|
||||||
for is_oom_phase in (True, False)
|
if not is_oom_phase and task_ids:
|
||||||
]
|
# OOM stops can change actual positions, and a waiting task
|
||||||
for task_ids in phase_ids:
|
# can start running after the account-wide recheck above.
|
||||||
|
# Re-read this account immediately before its age-only stop.
|
||||||
|
try:
|
||||||
|
phase_tasks: dict[int, OwnedTask] = {}
|
||||||
|
for status in ACTIVE_FILTER_STATUSES:
|
||||||
|
for task in _fetch_status_tasks(
|
||||||
|
clients[account_index],
|
||||||
|
account_index=account_index,
|
||||||
|
status=status,
|
||||||
|
):
|
||||||
|
phase_tasks[task.task_id] = task
|
||||||
|
except Exception as exc:
|
||||||
|
stop_errors.append(
|
||||||
|
{
|
||||||
|
"accountIndex": account_index + 1,
|
||||||
|
"taskIds": task_ids,
|
||||||
|
"error": f"age_policy_final_recheck_failed: {type(exc).__name__}: {exc}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stop_failed = True
|
||||||
|
break
|
||||||
|
|
||||||
|
phase_positions = {
|
||||||
|
task_id: position
|
||||||
|
for position, task_id in enumerate(sorted(phase_tasks), start=1)
|
||||||
|
}
|
||||||
|
eligible_task_ids: list[int] = []
|
||||||
|
account_queue_threshold = queue_thresholds.get(account_index)
|
||||||
|
for task_id in task_ids:
|
||||||
|
current_task = phase_tasks.get(task_id)
|
||||||
|
if current_task is None:
|
||||||
|
disappeared.append(decisions_by_id[task_id])
|
||||||
|
continue
|
||||||
|
current_position = phase_positions.get(task_id)
|
||||||
|
if (
|
||||||
|
account_queue_threshold is None
|
||||||
|
or current_position is None
|
||||||
|
or current_position <= account_queue_threshold
|
||||||
|
or current_task.status != "waiting"
|
||||||
|
):
|
||||||
|
policy_no_longer_applies.append(
|
||||||
|
{
|
||||||
|
**decisions_by_id[task_id],
|
||||||
|
"recheckedQueuePosition": current_position,
|
||||||
|
"recheckedStatus": current_task.status,
|
||||||
|
"policyChangeReason": (
|
||||||
|
"task_started_running"
|
||||||
|
if current_task.status == "running"
|
||||||
|
else "queue_position_or_status_changed"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
decisions_by_id[task_id]["recheckedQueuePosition"] = current_position
|
||||||
|
eligible_task_ids.append(task_id)
|
||||||
|
task_ids = eligible_task_ids
|
||||||
|
|
||||||
for batch in _chunks(task_ids, batch_size):
|
for batch in _chunks(task_ids, batch_size):
|
||||||
try:
|
try:
|
||||||
clients[account_index].stop_tasks(batch)
|
clients[account_index].stop_tasks(batch)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
AGENT_VERSION = "2026.08.11.4"
|
AGENT_VERSION = "2026.08.12.1"
|
||||||
|
|||||||
@@ -391,6 +391,78 @@ class CandidatePreflightTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, report["profileCombinationStats"][key]["failureCount"])
|
self.assertEqual(1, report["profileCombinationStats"][key]["failureCount"])
|
||||||
self.assertEqual(1, report["recentProfileCombinationStats"][key]["consecutiveFailures"])
|
self.assertEqual(1, report["recentProfileCombinationStats"][key]["consecutiveFailures"])
|
||||||
|
|
||||||
|
def test_policy_cancellation_is_excluded_from_failure_feedback(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||||
|
path = Path(temporary_dir) / "outcomes.jsonl"
|
||||||
|
tracker = OutcomeTracker(path)
|
||||||
|
tracker.record_submission(
|
||||||
|
"owner/model",
|
||||||
|
"gpu",
|
||||||
|
"vllm",
|
||||||
|
"text-generation",
|
||||||
|
"task-policy",
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
marked = tracker.mark_policy_cancellations(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"taskId": "task-policy",
|
||||||
|
"modelId": "owner/model",
|
||||||
|
"gpuType": "gpu",
|
||||||
|
"cleanupReasons": ["old_model_beyond_account_queue_threshold"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
tracker.save()
|
||||||
|
tracker.sync_from_api(
|
||||||
|
TaskClient(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"taskId": "task-policy",
|
||||||
|
"status": "cancelled",
|
||||||
|
"verifyResult": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
report = tracker.get_stats_report()
|
||||||
|
|
||||||
|
self.assertEqual(1, marked)
|
||||||
|
self.assertEqual(1, report["policyCancelledRecords"])
|
||||||
|
self.assertEqual(0, report["terminalRecords"])
|
||||||
|
self.assertEqual({}, report["combinationStats"])
|
||||||
|
self.assertFalse(tracker.is_model_gpu_failed("owner/model", "gpu"))
|
||||||
|
|
||||||
|
def test_policy_cancellation_does_not_hide_a_racing_success(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||||
|
tracker = OutcomeTracker(Path(temporary_dir) / "outcomes.jsonl")
|
||||||
|
tracker.record_submission(
|
||||||
|
"owner/model",
|
||||||
|
"gpu",
|
||||||
|
"vllm",
|
||||||
|
"text-generation",
|
||||||
|
"task-success",
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
)
|
||||||
|
tracker.mark_policy_cancellations(
|
||||||
|
[{"taskId": "task-success", "modelId": "owner/model", "gpuType": "gpu"}]
|
||||||
|
)
|
||||||
|
tracker.sync_from_api(
|
||||||
|
TaskClient(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"taskId": "task-success",
|
||||||
|
"status": "success",
|
||||||
|
"verifyResult": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
report = tracker.get_stats_report()
|
||||||
|
|
||||||
|
self.assertEqual(0, report["policyCancelledRecords"])
|
||||||
|
self.assertEqual(1, report["totals"]["successCount"])
|
||||||
|
|
||||||
def test_failure_taxonomy_separates_platform_faults_from_model_faults(self) -> None:
|
def test_failure_taxonomy_separates_platform_faults_from_model_faults(self) -> None:
|
||||||
platform = classify_failure_report(
|
platform = classify_failure_report(
|
||||||
"EXECUTE_EMPTY_RESULT",
|
"EXECUTE_EMPTY_RESULT",
|
||||||
|
|||||||
@@ -31,16 +31,22 @@ class FakeQueueClient:
|
|||||||
*,
|
*,
|
||||||
disappear_on_recheck: bool = False,
|
disappear_on_recheck: bool = False,
|
||||||
drop_first_on_recheck: bool = False,
|
drop_first_on_recheck: bool = False,
|
||||||
|
promote_on_waiting_read: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.records = list(records)
|
self.records = list(records)
|
||||||
self.disappear_on_recheck = disappear_on_recheck
|
self.disappear_on_recheck = disappear_on_recheck
|
||||||
self.drop_first_on_recheck = drop_first_on_recheck
|
self.drop_first_on_recheck = drop_first_on_recheck
|
||||||
|
self.promote_on_waiting_read = promote_on_waiting_read
|
||||||
self.waiting_reads = 0
|
self.waiting_reads = 0
|
||||||
self.stopped: list[list[int]] = []
|
self.stopped: list[list[int]] = []
|
||||||
|
|
||||||
def list_tasks_page(self, *, status: str, **_kwargs: Any) -> dict[str, Any]:
|
def list_tasks_page(self, *, status: str, **_kwargs: Any) -> dict[str, Any]:
|
||||||
if status == "waiting":
|
if status == "waiting":
|
||||||
self.waiting_reads += 1
|
self.waiting_reads += 1
|
||||||
|
if self.promote_on_waiting_read == self.waiting_reads:
|
||||||
|
waiting = [record for record in self.records if record["status"] == "waiting"]
|
||||||
|
if waiting:
|
||||||
|
max(waiting, key=lambda item: int(item["taskId"]))["status"] = "running"
|
||||||
if self.disappear_on_recheck and self.waiting_reads >= 2:
|
if self.disappear_on_recheck and self.waiting_reads >= 2:
|
||||||
records: list[dict[str, Any]] = []
|
records: list[dict[str, Any]] = []
|
||||||
else:
|
else:
|
||||||
@@ -108,6 +114,7 @@ class QueueCleanupTests(unittest.TestCase):
|
|||||||
for index in range(1, 192)
|
for index in range(1, 192)
|
||||||
)
|
)
|
||||||
tasks.append(OwnedTask(0, 92, "owner/recent", "Iluvatar_bi-100", "waiting"))
|
tasks.append(OwnedTask(0, 92, "owner/recent", "Iluvatar_bi-100", "waiting"))
|
||||||
|
tasks.append(OwnedTask(0, 93, "owner/old", "Iluvatar_bi-100", "running"))
|
||||||
tasks.append(OwnedTask(1, 1192, "owner/recent", "Iluvatar_bi-100", "waiting"))
|
tasks.append(OwnedTask(1, 1192, "owner/recent", "Iluvatar_bi-100", "waiting"))
|
||||||
|
|
||||||
selected, skipped = find_old_overflow_tasks(
|
selected, skipped = find_old_overflow_tasks(
|
||||||
@@ -124,6 +131,93 @@ class QueueCleanupTests(unittest.TestCase):
|
|||||||
self.assertEqual([91, 1191], [item["taskId"] for item in selected])
|
self.assertEqual([91, 1191], [item["taskId"] for item in selected])
|
||||||
self.assertEqual([91, 191], [item["queuePosition"] for item in selected])
|
self.assertEqual([91, 191], [item["queuePosition"] for item in selected])
|
||||||
self.assertEqual(2, skipped["recentOverflowTasks"])
|
self.assertEqual(2, skipped["recentOverflowTasks"])
|
||||||
|
self.assertEqual(1, skipped["runningOverflowProtected"])
|
||||||
|
|
||||||
|
def test_age_cleanup_never_stops_running_overflow_task(self) -> None:
|
||||||
|
records = [
|
||||||
|
{
|
||||||
|
"taskId": index,
|
||||||
|
"modelId": "owner/old",
|
||||||
|
"gpuType": "Iluvatar_bi-100",
|
||||||
|
"status": "running" if index == 91 else "waiting",
|
||||||
|
}
|
||||||
|
for index in range(1, 92)
|
||||||
|
]
|
||||||
|
client = FakeQueueClient(records)
|
||||||
|
pool = ModelHubClientPool(
|
||||||
|
[client], # type: ignore[list-item]
|
||||||
|
active_task_cap=100,
|
||||||
|
recent_model_reserve_slots=10,
|
||||||
|
)
|
||||||
|
summary = cleanup_certain_oom_tasks(
|
||||||
|
pool,
|
||||||
|
FakeDiscovery(
|
||||||
|
{"owner/old": 1 * GIB},
|
||||||
|
{"owner/old": datetime(2026, 7, 1, tzinfo=timezone.utc)},
|
||||||
|
), # type: ignore[arg-type]
|
||||||
|
reference_time=datetime(2026, 8, 11, tzinfo=timezone.utc),
|
||||||
|
log=lambda _message: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, summary["oldOverflowCount"])
|
||||||
|
self.assertEqual(1, summary["agePolicySkipped"]["runningOverflowProtected"])
|
||||||
|
self.assertEqual([], client.stopped)
|
||||||
|
|
||||||
|
def test_age_cleanup_recheck_releases_task_that_started_running(self) -> None:
|
||||||
|
records = [
|
||||||
|
{
|
||||||
|
"taskId": index,
|
||||||
|
"modelId": "owner/old",
|
||||||
|
"gpuType": "Iluvatar_bi-100",
|
||||||
|
"status": "waiting",
|
||||||
|
}
|
||||||
|
for index in range(1, 92)
|
||||||
|
]
|
||||||
|
# Read 1 is discovery, read 2 is the account-wide mutation recheck,
|
||||||
|
# and read 3 is the final age-only recheck after the OOM phase.
|
||||||
|
client = FakeQueueClient(records, promote_on_waiting_read=3)
|
||||||
|
pool = ModelHubClientPool(
|
||||||
|
[client], # type: ignore[list-item]
|
||||||
|
active_task_cap=100,
|
||||||
|
recent_model_reserve_slots=10,
|
||||||
|
)
|
||||||
|
summary = cleanup_certain_oom_tasks(
|
||||||
|
pool,
|
||||||
|
FakeDiscovery(
|
||||||
|
{"owner/old": 1 * GIB},
|
||||||
|
{"owner/old": datetime(2026, 7, 1, tzinfo=timezone.utc)},
|
||||||
|
), # type: ignore[arg-type]
|
||||||
|
read_concurrency=1,
|
||||||
|
reference_time=datetime(2026, 8, 11, tzinfo=timezone.utc),
|
||||||
|
log=lambda _message: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, summary["oldOverflowCount"])
|
||||||
|
self.assertEqual(0, summary["cancelledCount"])
|
||||||
|
self.assertEqual("task_started_running", summary["policyNoLongerAppliesTasks"][0]["policyChangeReason"])
|
||||||
|
self.assertEqual([], client.stopped)
|
||||||
|
|
||||||
|
def test_certain_oom_cleanup_can_still_stop_running_task(self) -> None:
|
||||||
|
client = FakeQueueClient(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"taskId": 1,
|
||||||
|
"modelId": "owner/large",
|
||||||
|
"gpuType": "Iluvatar_bi-100",
|
||||||
|
"status": "running",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pool = ModelHubClientPool([client], active_task_cap=100) # type: ignore[list-item]
|
||||||
|
summary = cleanup_certain_oom_tasks(
|
||||||
|
pool,
|
||||||
|
FakeDiscovery({"owner/large": 40 * GIB}), # type: ignore[arg-type]
|
||||||
|
log=lambda _message: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, summary["certainOomCount"])
|
||||||
|
self.assertEqual(1, summary["cancelledCount"])
|
||||||
|
self.assertEqual([[1]], client.stopped)
|
||||||
|
|
||||||
def test_old_overflow_task_is_not_stopped_if_it_moves_inside_dynamic_threshold(self) -> None:
|
def test_old_overflow_task_is_not_stopped_if_it_moves_inside_dynamic_threshold(self) -> None:
|
||||||
records = [
|
records = [
|
||||||
|
|||||||
Reference in New Issue
Block a user