feat: compact durable logs and refine framework routing
This commit is contained in:
21
README.md
21
README.md
@@ -396,11 +396,28 @@ the aggregate checkpoint remains the authoritative recovery input even while an
|
||||
archive shard is awaiting upload. ModelHub Git traffic is excluded from proxy
|
||||
routing in the container to avoid deployment-specific Git failures.
|
||||
|
||||
Version `2026.09.04.1` makes durable state synchronization content-addressed:
|
||||
unchanged cycles do not create commits, and a failed network push retries the
|
||||
same commit/generation. The write-ahead batch size is now 100; resolved intent
|
||||
rows are compacted to 200 recent entries and moved to monthly gzip archive
|
||||
branches. Raw community samples and the official model/GPU capability cache are
|
||||
bounded, so restart no longer needs to restore and rewrite multi-megabyte
|
||||
disposable caches.
|
||||
|
||||
The submission loop now treats account-capacity saturation and framework
|
||||
prerequisite responses as deferred policy outcomes rather than model failures.
|
||||
When a model has an official non-`transformers` route, that immediately
|
||||
executable route is selected before the platform's prerequisite-gated
|
||||
`transformers` fallback; a recorded non-transformers failure unlocks normal
|
||||
success-based transformer selection. Missing framework/task/profile fields in history are
|
||||
recovered from the durable task ledger. The evidence and category breakdown are
|
||||
documented in `docs/failure-analysis-2026-09-04.md`.
|
||||
|
||||
## Deploy
|
||||
|
||||
Create a tag and submit the repository URL plus tag in "我的适配智能体".
|
||||
|
||||
```bash
|
||||
git tag -a agent-v29 -m "ModelHub agent 2026.08.22.1"
|
||||
git push origin main agent-v29
|
||||
git tag -a agent-v30 -m "ModelHub agent 2026.09.04.1"
|
||||
git push origin main agent-v30
|
||||
```
|
||||
|
||||
83
docs/failure-analysis-2026-09-04.md
Normal file
83
docs/failure-analysis-2026-09-04.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Durable log analysis — 2026-09-04
|
||||
|
||||
This report was produced from the live `agent-state` snapshot and the monthly
|
||||
`agent-archive-2026-08` branch. Records were deduplicated by task identity before
|
||||
classification; intent rows and state-sync generations were not counted as
|
||||
independent model submissions.
|
||||
|
||||
## Data quality
|
||||
|
||||
- 15,178 raw outcome rows became 14,178 unique outcomes after deduplication.
|
||||
- 13,318 unique terminal outcomes contained 733 successes and 12,585 failures.
|
||||
- The hot intent WAL contained 9,213 rows, but only 1,299 unique model/GPU
|
||||
routes. Capacity-full responses alone appeared 5,887 times.
|
||||
- The ledger contained 1,028 platform tasks. A single model can legitimately
|
||||
produce several tasks because GPU/framework routes and retries differ.
|
||||
- Raw terminal success rate is biased downward: failures often terminate much
|
||||
faster, while successful tasks can remain waiting/running for a long time.
|
||||
Routing therefore uses attributable outcomes and confidence bounds rather
|
||||
than this raw percentage.
|
||||
|
||||
## Deduplicated failure categories
|
||||
|
||||
| Category | Count | Share |
|
||||
| --- | ---: | ---: |
|
||||
| Ambiguous runtime | 3,067 | 24.37% |
|
||||
| Parameters/template | 2,935 | 23.32% |
|
||||
| Framework/architecture unsupported | 1,428 | 11.35% |
|
||||
| Memory capacity | 1,169 | 9.29% |
|
||||
| Platform infrastructure | 911 | 7.24% |
|
||||
| Repository structure | 725 | 5.76% |
|
||||
| Missing log evidence | 719 | 5.71% |
|
||||
| Tokenizer compatibility | 576 | 4.58% |
|
||||
| Model load | 382 | 3.04% |
|
||||
| Context length | 318 | 2.53% |
|
||||
| Architecture compatibility | 212 | 1.68% |
|
||||
| Runtime memory | 55 | 0.44% |
|
||||
| Backend operator | 44 | 0.35% |
|
||||
| Generic validation failure | 43 | 0.34% |
|
||||
| Attention backend | 1 | 0.01% |
|
||||
|
||||
The most useful structured codes were `MODEL_NOT_SUPPORTED` (1,700),
|
||||
`EXECUTE_EMPTY_RESULT` (1,392), `PREFLIGHT_OOM` (1,166), `TOKENIZER_FAILED`
|
||||
(696), `MODEL_FILE_NOT_FOUND` (590), `MODEL_LOAD_FAILED` (382), and
|
||||
`CONTEXT_LENGTH_ERROR` (268).
|
||||
|
||||
## Representative evidence and algorithm changes
|
||||
|
||||
1. Capacity saturation repeatedly returned the account-limit message. This is
|
||||
platform flow control, not model/framework failure. It is now recorded as
|
||||
`capacity_deferred`, ends the current submission cycle after the pool has
|
||||
tried every account, and does not enter success statistics.
|
||||
2. The platform repeatedly required a failed non-`transformers` verification
|
||||
before accepting `transformers`. Routing now uses a vetted non-transformers
|
||||
route when one exists and treats the prerequisite response as a neutral
|
||||
deferred result. Once durable history contains a non-transformers failure
|
||||
for that model, transformers is unlocked and competes normally by success
|
||||
confidence.
|
||||
3. `PREFLIGHT_OOM` logs explicitly compare repository load size (including the
|
||||
platform's 20% loading allowance) with available device memory. These remain
|
||||
deterministic pre-submit blocks and deterministic queue cleanup evidence.
|
||||
4. Explicit “framework does not support model/architecture” messages continue
|
||||
to create exact GPU + framework + task + architecture blocks. Generic image,
|
||||
driver, storage and connection errors remain platform-neutral and do not
|
||||
poison compatibility scores.
|
||||
5. Platform history often omits `framework`. Outcome sync now joins task IDs
|
||||
with the local durable ledger and restores framework/task/profile metadata,
|
||||
improving per-framework statistics without guessing from model names.
|
||||
|
||||
## Durable-state corrections
|
||||
|
||||
- Hot intent history retains unresolved intents plus 200 recent terminal
|
||||
intents; older terminal attempts are gzip archived by month.
|
||||
- Community raw samples are capped locally at 200 and excluded from the hot Git
|
||||
snapshot. The aggregated GPU/framework statistics remain durable.
|
||||
- Model/GPU official-capability cache is bounded to the 1,500 newest entries.
|
||||
- Outcome compaction starts at 1,000 rows instead of 2,000.
|
||||
- An unchanged snapshot produces no Git commit. A failed push retries the exact
|
||||
same commit and generation instead of creating a new generation every minute.
|
||||
- Submission intent batches default to 100, reducing Git transactions while
|
||||
preserving write-ahead recovery.
|
||||
|
||||
These changes keep full forensic evidence in cold archive branches while making
|
||||
the hot branch small enough for quick restart and reliable server-side unpack.
|
||||
@@ -36,7 +36,9 @@ from modelhub_client import (
|
||||
ModelHubClient,
|
||||
ModelHubClientPool,
|
||||
OldModelQueuePolicyError,
|
||||
is_capacity_error,
|
||||
is_duplicate_submission_error,
|
||||
is_framework_prerequisite_error,
|
||||
is_model_uniqueness_error,
|
||||
)
|
||||
from models import CandidateModel, HFModelSummary, ModelInspection
|
||||
@@ -651,6 +653,10 @@ def process_model_for_candidates(
|
||||
skipped.append({"repoId": model.repo_id, "targetGpu": target_gpu, "reason": reason})
|
||||
continue
|
||||
record = candidate_to_record(best)
|
||||
if outcome_tracker is not None:
|
||||
record["transformersPrerequisiteSatisfied"] = (
|
||||
outcome_tracker.has_non_transformers_failure(model.repo_id)
|
||||
)
|
||||
if market_intelligence is not None:
|
||||
record.update(market_intelligence.gpu_metadata(target_gpu))
|
||||
record.update(market_intelligence.framework_metadata(best.task_type, target_gpu, best.framework))
|
||||
@@ -879,6 +885,17 @@ def submit_candidate(
|
||||
"candidate": candidate,
|
||||
"reason": "age_policy_skipped",
|
||||
}
|
||||
if is_framework_prerequisite_error(exc):
|
||||
print(
|
||||
f"[submit] deferred repo={candidate['repoId']} gpu={candidate['targetGpu']} "
|
||||
f"framework={candidate['framework']} reason=framework_prerequisite",
|
||||
flush=True,
|
||||
)
|
||||
return {
|
||||
"outcome": "framework_prerequisite_deferred",
|
||||
"candidate": candidate,
|
||||
"reason": "framework_prerequisite_deferred",
|
||||
}
|
||||
if is_model_uniqueness_error(exc):
|
||||
print(
|
||||
f"[submit] skipped repo={candidate['repoId']} gpu={candidate['targetGpu']} "
|
||||
@@ -901,6 +918,17 @@ def submit_candidate(
|
||||
"candidate": candidate,
|
||||
"reason": str(exc),
|
||||
}
|
||||
if is_capacity_error(exc):
|
||||
print(
|
||||
f"[submit] deferred repo={candidate['repoId']} gpu={candidate['targetGpu']} "
|
||||
f"framework={candidate['framework']} reason=account_capacity_saturated",
|
||||
flush=True,
|
||||
)
|
||||
return {
|
||||
"outcome": "capacity_deferred",
|
||||
"candidate": candidate,
|
||||
"reason": "account_capacity_saturated",
|
||||
}
|
||||
print(
|
||||
f"[submit] failed repo={candidate['repoId']} gpu={candidate['targetGpu']} "
|
||||
f"framework={candidate['framework']} reason={exc}",
|
||||
@@ -1052,6 +1080,7 @@ def run_submission(
|
||||
run_dir = make_run_dir(runs_dir, now)
|
||||
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
history_archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ledger_entries = load_ledger(ledger_path)
|
||||
|
||||
outcome_tracker = outcome_tracker or OutcomeTracker(Path(args.outcomes_path))
|
||||
# Online decisions are deliberately deterministic. The optional classifier
|
||||
@@ -1085,7 +1114,25 @@ def run_submission(
|
||||
synced_count = 0
|
||||
if not getattr(args, "skip_outcome_sync", False):
|
||||
try:
|
||||
synced_count = outcome_tracker.sync_from_api(modelhub_client)
|
||||
task_contexts = outcome_tracker.get_task_compatibility_contexts()
|
||||
for entry in ledger_entries:
|
||||
task_id = str(entry.get("taskId") or "")
|
||||
if not task_id:
|
||||
continue
|
||||
context = task_contexts.setdefault(task_id, {})
|
||||
for field, source in (
|
||||
("modelId", "modelId"),
|
||||
("targetGpu", "targetGpu"),
|
||||
("framework", "framework"),
|
||||
("taskType", "taskType"),
|
||||
("submitTime", "submitTime"),
|
||||
):
|
||||
if not context.get(field) and entry.get(source):
|
||||
context[field] = entry.get(source)
|
||||
synced_count = outcome_tracker.sync_from_api(
|
||||
modelhub_client,
|
||||
task_contexts=task_contexts,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if preflight_advisor is not None:
|
||||
@@ -1096,7 +1143,6 @@ def run_submission(
|
||||
|
||||
updated_after = determine_updated_after(args, now)
|
||||
history_begin = now - timedelta(days=args.stats_window_days)
|
||||
ledger_entries = load_ledger(ledger_path)
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# Count today's submissions from the local ledger (avoids expensive paginated API call)
|
||||
daily_snapshot = count_submissions_for_day(tasks=[], ledger_entries=ledger_entries, day_start=day_start, day_end=now)
|
||||
@@ -1473,6 +1519,7 @@ def run_submission(
|
||||
submit_workers = 1
|
||||
state_sync = getattr(args, "_state_sync_manager", None)
|
||||
state_sync_paused = False
|
||||
capacity_saturated = False
|
||||
|
||||
if args.dry_run:
|
||||
attempted_candidates = diversified_candidates[:target_submit_count]
|
||||
@@ -1549,6 +1596,7 @@ def run_submission(
|
||||
batch_duplicate_candidates: list[dict[str, Any]] = []
|
||||
batch_uniqueness_rejected_candidates: list[dict[str, Any]] = []
|
||||
batch_policy_skipped_candidates: list[dict[str, Any]] = []
|
||||
batch_capacity_deferred_candidates: list[dict[str, Any]] = []
|
||||
batch_failed_candidates: list[dict[str, Any]] = []
|
||||
for index in range(len(batch_candidates)):
|
||||
result = ordered_results.get(index)
|
||||
@@ -1593,6 +1641,30 @@ def run_submission(
|
||||
}
|
||||
)
|
||||
continue
|
||||
if result["outcome"] == "capacity_deferred":
|
||||
capacity_saturated = True
|
||||
batch_capacity_deferred_candidates.append(candidate)
|
||||
skipped.append(
|
||||
{
|
||||
"repoId": candidate["repoId"],
|
||||
"targetGpu": candidate["targetGpu"],
|
||||
"reason": "account_capacity_saturated",
|
||||
"deferred": True,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if result["outcome"] == "framework_prerequisite_deferred":
|
||||
batch_policy_skipped_candidates.append(candidate)
|
||||
skipped.append(
|
||||
{
|
||||
"repoId": candidate["repoId"],
|
||||
"targetGpu": candidate["targetGpu"],
|
||||
"framework": candidate["framework"],
|
||||
"reason": "framework_prerequisite_deferred",
|
||||
"deferred": True,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if result["outcome"] in {"failed", "precheck_deferred"}:
|
||||
batch_failed_candidates.append(candidate)
|
||||
failed.append(
|
||||
@@ -1646,7 +1718,13 @@ def run_submission(
|
||||
claim_store.mark_submitted(
|
||||
[*batch_submitted_candidates, *batch_duplicate_candidates, *batch_uniqueness_rejected_candidates]
|
||||
)
|
||||
claim_store.release([*batch_failed_candidates, *batch_policy_skipped_candidates])
|
||||
claim_store.release(
|
||||
[
|
||||
*batch_failed_candidates,
|
||||
*batch_policy_skipped_candidates,
|
||||
*batch_capacity_deferred_candidates,
|
||||
]
|
||||
)
|
||||
if strategy_manager is not None:
|
||||
strategy_manager.record_accepted(batch_submitted_candidates)
|
||||
outcome_tracker.save()
|
||||
@@ -1663,6 +1741,11 @@ def run_submission(
|
||||
|
||||
if hasattr(modelhub_client, "available_submit_slots") and modelhub_client.available_submit_slots() <= 0:
|
||||
break
|
||||
if capacity_saturated:
|
||||
# Every account was tried by the pool. More attempts in this
|
||||
# cycle only duplicate the same capacity response and pollute
|
||||
# durable logs; refresh capacity on the next poll instead.
|
||||
break
|
||||
|
||||
write_jsonl(run_dir / "submitted.jsonl", submitted)
|
||||
write_jsonl(run_dir / "skipped.jsonl", skipped)
|
||||
|
||||
@@ -22,7 +22,7 @@ DEFAULT_FRAMEWORK_MIN_SAMPLES = 300
|
||||
DEFAULT_GPU_MIN_RECENT_TERMINALS = 20
|
||||
DEFAULT_FRAMEWORK_MIN_WILSON = 0.05
|
||||
DEFAULT_COMMUNITY_WINDOW_DAYS = 30
|
||||
DEFAULT_COMMUNITY_MAX_MODELS = 2000
|
||||
DEFAULT_COMMUNITY_MAX_MODELS = 200
|
||||
DEFAULT_COMMUNITY_HYDRATE_PER_REFRESH = 50
|
||||
NEW_FRAMEWORK_PROMOTION_MARGIN = 1.10
|
||||
ERROR_RETRY_SECONDS = 300
|
||||
@@ -248,6 +248,16 @@ class MarketIntelligenceManager:
|
||||
else:
|
||||
state = self._base_state(gpus, tasks, now)
|
||||
|
||||
community_sample = state.get("communitySample") or {}
|
||||
if isinstance(community_sample, dict) and len(community_sample) > DEFAULT_COMMUNITY_MAX_MODELS:
|
||||
cutoff = now - timedelta(days=DEFAULT_COMMUNITY_WINDOW_DAYS)
|
||||
ordered = sorted(
|
||||
community_sample.items(),
|
||||
key=lambda item: parse_datetime((item[1] or {}).get("updateTime")) or cutoff,
|
||||
reverse=True,
|
||||
)
|
||||
state["communitySample"] = dict(ordered[:DEFAULT_COMMUNITY_MAX_MODELS])
|
||||
|
||||
queue_due = not _fresh(
|
||||
state.get("queueUpdatedAt"),
|
||||
now=now,
|
||||
|
||||
@@ -370,6 +370,12 @@ MODEL_UNIQUENESS_ERROR_MARKERS = (
|
||||
"model already exists",
|
||||
)
|
||||
|
||||
FRAMEWORK_PREREQUISITE_ERROR_MARKERS = (
|
||||
"必须在非transformers框架验证失败后",
|
||||
"must fail on a non-transformers framework",
|
||||
"non-transformers framework verification failed",
|
||||
)
|
||||
|
||||
DEFAULT_CAPACITY_STATE_PATH = Path(".modelhub_state/account_capacity.json")
|
||||
|
||||
|
||||
@@ -396,6 +402,13 @@ def is_model_uniqueness_error(error: ModelHubAPIError) -> bool:
|
||||
return any(marker in message for marker in MODEL_UNIQUENESS_ERROR_MARKERS)
|
||||
|
||||
|
||||
def is_framework_prerequisite_error(error: ModelHubAPIError) -> bool:
|
||||
message = str(error).strip().lower()
|
||||
if isinstance(error.payload, dict):
|
||||
message = f"{message} {error.payload.get('message') or ''}".lower()
|
||||
return any(marker in message for marker in FRAMEWORK_PREREQUISITE_ERROR_MARKERS)
|
||||
|
||||
|
||||
class ModelHubClientPool:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -10,6 +10,7 @@ from common import parse_datetime, read_json, utc_now, write_json
|
||||
|
||||
OFFICIAL_CAPABILITY_VERSION = 1
|
||||
DEFAULT_OFFICIAL_CAPABILITIES_PATH = Path(".modelhub_state/official_capabilities.json")
|
||||
DEFAULT_MODEL_GPU_CACHE_LIMIT = 1500
|
||||
|
||||
|
||||
class OfficialCapabilityUnavailable(RuntimeError):
|
||||
@@ -61,6 +62,18 @@ class OfficialCapabilityRegistry:
|
||||
self.pause_reason: str | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _prune_model_gpu_cache(state: dict[str, Any]) -> None:
|
||||
cache = state.get("modelGpuTaskTypes")
|
||||
if not isinstance(cache, dict) or len(cache) <= DEFAULT_MODEL_GPU_CACHE_LIMIT:
|
||||
return
|
||||
ordered = sorted(
|
||||
cache.items(),
|
||||
key=lambda pair: parse_datetime((pair[1] or {}).get("updatedAt")) or datetime.min.replace(tzinfo=utc_now().tzinfo),
|
||||
reverse=True,
|
||||
)
|
||||
state["modelGpuTaskTypes"] = dict(ordered[:DEFAULT_MODEL_GPU_CACHE_LIMIT])
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
try:
|
||||
value = read_json(self.path)
|
||||
@@ -73,6 +86,7 @@ class OfficialCapabilityRegistry:
|
||||
def prepare(self, client: Any, *, fallback_gpus: list[str], task_types: list[str], now: datetime | None = None) -> dict[str, Any]:
|
||||
now = now or utc_now()
|
||||
state = self._load()
|
||||
self._prune_model_gpu_cache(state)
|
||||
catalog_usable = _fresh(state.get("catalogUpdatedAt"), now, 1800)
|
||||
task_tree_usable = _fresh(state.get("taskTreeUpdatedAt"), now, 7 * 86400)
|
||||
errors: list[str] = []
|
||||
@@ -159,6 +173,7 @@ class OfficialCapabilityRegistry:
|
||||
with self._lock:
|
||||
cache = self.state.setdefault("modelGpuTaskTypes", {})
|
||||
cache[key] = {"updatedAt": now.isoformat(), "taskTypes": task_types}
|
||||
self._prune_model_gpu_cache(self.state)
|
||||
self.state["generatedAt"] = now.isoformat()
|
||||
write_json(self.path, self.state)
|
||||
return task_types
|
||||
|
||||
@@ -31,7 +31,7 @@ DEFAULT_OUTCOME_CHECKPOINT_PATH = Path(".modelhub_state/outcome_checkpoint.json"
|
||||
DEFAULT_RECENT_OUTCOMES_PATH = Path(".modelhub_state/recent_outcomes.jsonl")
|
||||
DEFAULT_ARCHIVE_PENDING_DIR = Path(".modelhub_state/archive_pending/outcomes")
|
||||
OUTCOME_CHECKPOINT_VERSION = 1
|
||||
DEFAULT_OUTCOME_COMPACT_THRESHOLD = 2000
|
||||
DEFAULT_OUTCOME_COMPACT_THRESHOLD = 1000
|
||||
DEFAULT_RECENT_OUTCOME_LIMIT = 1000
|
||||
FAILURE_ENRICHMENT_LIMIT = 40
|
||||
FAILURE_ENRICHMENT_WORKERS = 4
|
||||
@@ -215,6 +215,25 @@ class OutcomeTracker:
|
||||
}
|
||||
return contexts
|
||||
|
||||
def merge_task_contexts(self, contexts: dict[str, dict[str, Any]] | None) -> int:
|
||||
"""Repair framework/profile fields omitted by the platform history API."""
|
||||
if not isinstance(contexts, dict):
|
||||
return 0
|
||||
changed = 0
|
||||
for task_id, context in contexts.items():
|
||||
record = self._by_task_id.get(str(task_id))
|
||||
if record is None or not isinstance(context, dict):
|
||||
continue
|
||||
before = json.dumps(record, ensure_ascii=False, sort_keys=True)
|
||||
self._merge_record_metadata(record, self._enrich_history_task({}, context))
|
||||
if json.dumps(record, ensure_ascii=False, sort_keys=True) != before:
|
||||
changed += 1
|
||||
if changed:
|
||||
write_jsonl(self.recent_path, self._recent_records)
|
||||
self._rebuild_indexes()
|
||||
self.save()
|
||||
return changed
|
||||
|
||||
def get_strategy_history_records(self) -> list[dict[str, Any]]:
|
||||
"""Expose only successes and evidence-attributable failures for GPU ranking."""
|
||||
records: list[dict[str, Any]] = []
|
||||
@@ -331,7 +350,12 @@ class OutcomeTracker:
|
||||
self._rebuild_failed_index()
|
||||
return marked
|
||||
|
||||
def sync_from_api(self, client: ModelHubClient | ModelHubClientPool) -> int:
|
||||
def sync_from_api(
|
||||
self,
|
||||
client: ModelHubClient | ModelHubClientPool,
|
||||
*,
|
||||
task_contexts: dict[str, dict[str, Any]] | None = None,
|
||||
) -> int:
|
||||
try:
|
||||
begin = self._last_sync_time
|
||||
end = utc_now()
|
||||
@@ -350,13 +374,32 @@ class OutcomeTracker:
|
||||
if not task_id:
|
||||
continue
|
||||
|
||||
task = self._enrich_history_task(task, (task_contexts or {}).get(task_id))
|
||||
|
||||
existing = self._by_task_id.get(task_id)
|
||||
if existing is not None:
|
||||
metadata_before = (
|
||||
existing.get("modelId"),
|
||||
existing.get("targetGpu"),
|
||||
existing.get("framework"),
|
||||
existing.get("taskType"),
|
||||
existing.get("modelProfile"),
|
||||
)
|
||||
self._merge_record_metadata(existing, task)
|
||||
metadata_changed = metadata_before != (
|
||||
existing.get("modelId"),
|
||||
existing.get("targetGpu"),
|
||||
existing.get("framework"),
|
||||
existing.get("taskType"),
|
||||
existing.get("modelProfile"),
|
||||
)
|
||||
if existing.get("outcome") in {"pending", "policy_cancelled"}:
|
||||
self._update_record_from_task(existing, task)
|
||||
if existing.get("outcome") == "failed" and existing.get("logCosUrl"):
|
||||
enrichment_candidates.append(existing)
|
||||
updated_count += 1
|
||||
elif metadata_changed:
|
||||
updated_count += 1
|
||||
else:
|
||||
status = str(task.get("status") or "").lower()
|
||||
if status in TERMINAL_TASK_STATUSES:
|
||||
@@ -393,6 +436,7 @@ class OutcomeTracker:
|
||||
if updated_count or enrichment_attempts:
|
||||
self._last_sync_time = end
|
||||
self._rebuild_failed_index()
|
||||
write_jsonl(self.recent_path, self._recent_records)
|
||||
self.save()
|
||||
|
||||
return updated_count
|
||||
@@ -630,6 +674,18 @@ class OutcomeTracker:
|
||||
now = now or utc_now()
|
||||
return failed_at >= now - timedelta(hours=max(0, int(cooldown_hours)))
|
||||
|
||||
def has_non_transformers_failure(self, model_id: str) -> bool:
|
||||
"""Return platform evidence that unlocks prerequisite-gated transformers."""
|
||||
for record in [*self._recent_records, *self._records]:
|
||||
if str(record.get("modelId") or "") != str(model_id):
|
||||
continue
|
||||
if record.get("outcome") != "failed":
|
||||
continue
|
||||
framework = str(record.get("framework") or "").strip().casefold()
|
||||
if framework and framework != "transformers":
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_stats_report(self) -> dict[str, Any]:
|
||||
now_datetime = utc_now()
|
||||
now = now_datetime.isoformat()
|
||||
|
||||
@@ -560,6 +560,13 @@ def run_poll_loop(
|
||||
poll_run_dir = make_run_dir(poll_runs_dir, now)
|
||||
|
||||
outcome_tracker = outcome_tracker or OutcomeTracker(Path(base_args.outcomes_path))
|
||||
recovered_contexts = _load_task_compatibility_contexts(
|
||||
outcome_tracker,
|
||||
ledger_path=Path(base_args.ledger_path),
|
||||
)
|
||||
repaired_contexts = outcome_tracker.merge_task_contexts(recovered_contexts)
|
||||
if repaired_contexts:
|
||||
log(f"[outcome-recovery] metadata_repaired={repaired_contexts}")
|
||||
OUTCOME_SYNC_INTERVAL = 3
|
||||
STATS_PRINT_INTERVAL = 10
|
||||
|
||||
@@ -652,7 +659,13 @@ def run_poll_loop(
|
||||
and not getattr(base_args, "skip_outcome_sync", False)
|
||||
):
|
||||
try:
|
||||
synced = outcome_tracker.sync_from_api(modelhub_client)
|
||||
synced = outcome_tracker.sync_from_api(
|
||||
modelhub_client,
|
||||
task_contexts=_load_task_compatibility_contexts(
|
||||
outcome_tracker,
|
||||
ledger_path=Path(base_args.ledger_path),
|
||||
),
|
||||
)
|
||||
outcome_synced_this_cycle = True
|
||||
if synced > 0:
|
||||
log(f"[poll] cycle={cycles} outcome_sync_updated={synced}")
|
||||
@@ -702,7 +715,13 @@ def run_poll_loop(
|
||||
not outcome_synced_this_cycle
|
||||
and not getattr(base_args, "skip_outcome_sync", False)
|
||||
):
|
||||
synced_before_cleanup = outcome_tracker.sync_from_api(modelhub_client)
|
||||
synced_before_cleanup = outcome_tracker.sync_from_api(
|
||||
modelhub_client,
|
||||
task_contexts=_load_task_compatibility_contexts(
|
||||
outcome_tracker,
|
||||
ledger_path=Path(base_args.ledger_path),
|
||||
),
|
||||
)
|
||||
if synced_before_cleanup:
|
||||
log(
|
||||
f"[queue-cleanup] outcome_sync_updated={synced_before_cleanup}"
|
||||
@@ -920,7 +939,13 @@ def run_poll_loop(
|
||||
time.sleep(cooldown)
|
||||
|
||||
try:
|
||||
outcome_tracker.sync_from_api(modelhub_client)
|
||||
outcome_tracker.sync_from_api(
|
||||
modelhub_client,
|
||||
task_contexts=_load_task_compatibility_contexts(
|
||||
outcome_tracker,
|
||||
ledger_path=Path(base_args.ledger_path),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
outcome_tracker.save()
|
||||
|
||||
@@ -138,6 +138,20 @@ class SuccessFirstRoutingEngine:
|
||||
|
||||
selected: list[dict[str, Any]] = []
|
||||
for routes in by_model.values():
|
||||
# The platform permits some transformers submissions only after a
|
||||
# non-transformers attempt has failed. Prefer an immediately
|
||||
# executable route whenever one exists; transformers remains a
|
||||
# fallback for tasks/models with no other official route.
|
||||
non_transformers = [
|
||||
item for item in routes if str(item.get("framework") or "").casefold() != "transformers"
|
||||
]
|
||||
transformers_unlocked = any(
|
||||
bool(item.get("transformersPrerequisiteSatisfied"))
|
||||
for item in routes
|
||||
if str(item.get("framework") or "").casefold() == "transformers"
|
||||
)
|
||||
if non_transformers and not transformers_unlocked:
|
||||
routes = non_transformers
|
||||
best = max(float(item["routingSuccessLowerBound"]) for item in routes)
|
||||
close = [item for item in routes if best - float(item["routingSuccessLowerBound"]) <= 0.05]
|
||||
close.sort(
|
||||
|
||||
@@ -26,9 +26,10 @@ STATE_SCHEMA_VERSION = 1
|
||||
DEFAULT_REMOTE = "https://dev.modelhub.org.cn/CoolBoy/submmit.git"
|
||||
DEFAULT_BRANCH = "agent-state"
|
||||
DEFAULT_ARCHIVE_BRANCH_PREFIX = "agent-archive"
|
||||
DEFAULT_BATCH_SIZE = 20
|
||||
DEFAULT_BATCH_SIZE = 100
|
||||
DEFAULT_RETENTION_DAYS = 30
|
||||
DEFAULT_HISTORY_DEPTH = 20
|
||||
DEFAULT_HISTORY_DEPTH = 200
|
||||
DEFAULT_RECENT_TERMINAL_INTENTS = 200
|
||||
|
||||
# Only these runtime files may cross the trust boundary into the state branch.
|
||||
# Credentials, raw stdout, downloaded archives and run directories are excluded.
|
||||
@@ -154,6 +155,9 @@ class StateGitSync:
|
||||
self._mutex = threading.Lock()
|
||||
self._workspace: Path | None = None
|
||||
self._expected_remote_oid: str | None = None
|
||||
self._pending_push_oid: str | None = None
|
||||
self._pending_generation: int | None = None
|
||||
self._pending_manifest: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def state_dir(self) -> Path:
|
||||
@@ -216,8 +220,8 @@ class StateGitSync:
|
||||
return oid.decode("ascii") if oid else None
|
||||
|
||||
def _sync_archive_pending(self) -> None:
|
||||
pending_root = self.state_dir / "archive_pending" / "outcomes"
|
||||
files = sorted(path for path in pending_root.glob("*/*.jsonl.gz") if path.is_file())
|
||||
pending_root = self.state_dir / "archive_pending"
|
||||
files = sorted(path for path in pending_root.glob("*/*/*.jsonl.gz") if path.is_file())
|
||||
if not files:
|
||||
return
|
||||
by_month: dict[str, list[Path]] = {}
|
||||
@@ -251,10 +255,11 @@ class StateGitSync:
|
||||
manifest = {}
|
||||
archived = manifest.get("files") if isinstance(manifest.get("files"), dict) else {}
|
||||
for source in month_files:
|
||||
destination = workspace / "outcomes" / source.name
|
||||
archive_kind = source.parent.parent.name
|
||||
destination = workspace / archive_kind / source.name
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, destination)
|
||||
archived[f"outcomes/{source.name}"] = {
|
||||
archived[f"{archive_kind}/{source.name}"] = {
|
||||
"sha256": _sha256_file(destination),
|
||||
"bytes": destination.stat().st_size,
|
||||
}
|
||||
@@ -273,7 +278,7 @@ class StateGitSync:
|
||||
if any(status.staged.get(kind) for kind in ("add", "delete", "modify")):
|
||||
porcelain.commit(
|
||||
repo,
|
||||
message=f"archive: outcomes {month}".encode("utf-8"),
|
||||
message=f"archive: durable records {month}".encode("utf-8"),
|
||||
author=self._author,
|
||||
committer=self._author,
|
||||
)
|
||||
@@ -404,6 +409,49 @@ class StateGitSync:
|
||||
self._expected_remote_oid = None
|
||||
return self.restore()
|
||||
|
||||
def _archive_intents(self, records: list[dict[str, Any]]) -> str | None:
|
||||
if not records:
|
||||
return None
|
||||
now = _utc_now()
|
||||
month = now.strftime("%Y-%m")
|
||||
name = f"{now.strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:10]}.jsonl.gz"
|
||||
path = self.state_dir / "archive_pending" / "attempts" / month / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
import gzip
|
||||
|
||||
with gzip.open(path, "wt", encoding="utf-8", compresslevel=6) as handle:
|
||||
for record in records:
|
||||
safe = {
|
||||
key: value
|
||||
for key, value in record.items()
|
||||
if not any(marker in key.casefold() for marker in ("token", "password", "authorization"))
|
||||
}
|
||||
handle.write(json.dumps(safe, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
return f"{month}/{name}"
|
||||
|
||||
def _compact_intents(self) -> int:
|
||||
records = read_jsonl(self.intents_path)
|
||||
if len(records) <= DEFAULT_RECENT_TERMINAL_INTENTS:
|
||||
return 0
|
||||
active_statuses = {"pending", "submitted", "recovered_active"}
|
||||
active = [row for row in records if str(row.get("status") or "") in active_statuses]
|
||||
terminal = [row for row in records if str(row.get("status") or "") not in active_statuses]
|
||||
terminal.sort(
|
||||
key=lambda row: str(row.get("completedAt") or row.get("createdAt") or ""),
|
||||
reverse=True,
|
||||
)
|
||||
retained_terminal = terminal[:DEFAULT_RECENT_TERMINAL_INTENTS]
|
||||
archived = terminal[DEFAULT_RECENT_TERMINAL_INTENTS:]
|
||||
if not archived:
|
||||
return 0
|
||||
self._archive_intents(archived)
|
||||
write_jsonl(self.intents_path, [*active, *retained_terminal])
|
||||
self.log(
|
||||
f"[state-compact] intents_archived={len(archived)} "
|
||||
f"active={len(active)} recent_terminal={len(retained_terminal)}"
|
||||
)
|
||||
return len(archived)
|
||||
|
||||
def _event_files(self) -> list[Path]:
|
||||
event_dir = self.state_dir / "events"
|
||||
if not event_dir.exists():
|
||||
@@ -588,6 +636,7 @@ class StateGitSync:
|
||||
unresolved += 1
|
||||
retention_cutoff = now - timedelta(days=self.retention_days)
|
||||
retained: list[dict[str, Any]] = []
|
||||
expired: list[dict[str, Any]] = []
|
||||
for intent in intents:
|
||||
completed_text = intent.get("completedAt")
|
||||
if not completed_text:
|
||||
@@ -602,12 +651,16 @@ class StateGitSync:
|
||||
completed_at = completed_at.replace(tzinfo=timezone.utc)
|
||||
if completed_at >= retention_cutoff:
|
||||
retained.append(intent)
|
||||
else:
|
||||
expired.append(intent)
|
||||
self._archive_intents(expired)
|
||||
write_jsonl(self.intents_path, retained)
|
||||
self.record_active_tasks(enriched)
|
||||
return {"active": len(enriched), "reconciled": reconciled, "unresolved": unresolved}
|
||||
|
||||
def _copy_snapshot(self) -> dict[str, str]:
|
||||
assert self._workspace is not None
|
||||
self._compact_intents()
|
||||
checksums: dict[str, str] = {}
|
||||
for relative in STATE_ALLOWLIST:
|
||||
source = self.project_root / relative
|
||||
@@ -634,7 +687,21 @@ class StateGitSync:
|
||||
if isinstance(sanitized_market, dict):
|
||||
# A restored snapshot must refresh official configs before routing.
|
||||
sanitized_market["frameworkUpdatedAt"] = None
|
||||
# Raw community rows are a disposable refresh cache. The
|
||||
# aggregated framework/GPU statistics are the durable input.
|
||||
sanitized_market["communitySample"] = {}
|
||||
write_json(destination, sanitized_market)
|
||||
elif relative == ".modelhub_state/official_capabilities.json":
|
||||
payload = read_json(source)
|
||||
cache = payload.get("modelGpuTaskTypes") if isinstance(payload, dict) else None
|
||||
if isinstance(cache, dict) and len(cache) > 1500:
|
||||
ordered = sorted(
|
||||
cache.items(),
|
||||
key=lambda pair: str((pair[1] or {}).get("updatedAt") or ""),
|
||||
reverse=True,
|
||||
)
|
||||
payload["modelGpuTaskTypes"] = dict(ordered[:1500])
|
||||
write_json(destination, payload)
|
||||
elif relative in {"outcomes/submissions.jsonl", ".modelhub_state/recent_outcomes.jsonl"}:
|
||||
sanitized_outcomes: list[dict[str, Any]] = []
|
||||
for row in read_jsonl(source):
|
||||
@@ -666,16 +733,69 @@ class StateGitSync:
|
||||
path.unlink(missing_ok=True)
|
||||
return checksums
|
||||
|
||||
def _push_pending(self, *, phase: str) -> bool:
|
||||
if self._pending_push_oid is None:
|
||||
return True
|
||||
assert self._workspace is not None
|
||||
repo = Repo(str(self._workspace))
|
||||
remote_oid = self._remote_oid()
|
||||
if remote_oid == self._pending_push_oid:
|
||||
pushed = True
|
||||
elif remote_oid != self._expected_remote_oid:
|
||||
raise StateSyncError("state branch changed remotely; refusing to overwrite a newer snapshot")
|
||||
else:
|
||||
porcelain.push(
|
||||
repo,
|
||||
self.remote,
|
||||
refspecs=f"HEAD:refs/heads/{self.branch}",
|
||||
force=True,
|
||||
outstream=io.BytesIO(),
|
||||
errstream=io.BytesIO(),
|
||||
**self._auth_kwargs(),
|
||||
)
|
||||
pushed = self._remote_oid() == self._pending_push_oid
|
||||
if not pushed:
|
||||
raise StateSyncError("state branch verification failed after push")
|
||||
self._expected_remote_oid = self._pending_push_oid
|
||||
self.generation = int(self._pending_generation or self.generation)
|
||||
manifest = self._pending_manifest or {}
|
||||
self.last_sync_at = manifest.get("updatedAt")
|
||||
self._pending_push_oid = None
|
||||
self._pending_generation = None
|
||||
self._pending_manifest = None
|
||||
self.last_error = None
|
||||
self.healthy = True
|
||||
self.log(f"[state-sync] generation={self.generation} phase={phase} status=ok")
|
||||
self._sync_archive_pending_safely()
|
||||
return True
|
||||
|
||||
def sync(self, phase: str) -> bool:
|
||||
with self._mutex:
|
||||
try:
|
||||
if self._workspace is None:
|
||||
raise StateSyncError("state workspace is not initialized")
|
||||
# A failed network push is retried byte-for-byte. Do not create
|
||||
# another commit or generation for the same state transition.
|
||||
if self._pending_push_oid is not None:
|
||||
return self._push_pending(phase=phase)
|
||||
previous_manifest: dict[str, Any] = {}
|
||||
try:
|
||||
previous_manifest = read_json(self._workspace / "manifest.json")
|
||||
except (FileNotFoundError, ValueError, TypeError):
|
||||
pass
|
||||
checksums = self._copy_snapshot()
|
||||
self.generation += 1
|
||||
if (
|
||||
previous_manifest.get("checksums") == checksums
|
||||
and previous_manifest.get("agentVersion") == AGENT_VERSION
|
||||
):
|
||||
self.healthy = True
|
||||
self.last_error = None
|
||||
self._sync_archive_pending_safely()
|
||||
return True
|
||||
next_generation = self.generation + 1
|
||||
manifest = {
|
||||
"schemaVersion": STATE_SCHEMA_VERSION,
|
||||
"generation": self.generation,
|
||||
"generation": next_generation,
|
||||
"updatedAt": _utc_now().isoformat(),
|
||||
"agentVersion": AGENT_VERSION,
|
||||
"writerId": self.writer_id,
|
||||
@@ -696,7 +816,7 @@ class StateGitSync:
|
||||
return True
|
||||
porcelain.commit(
|
||||
repo,
|
||||
message=f"state: generation {self.generation} ({phase})".encode("utf-8"),
|
||||
message=f"state: generation {next_generation} ({phase})".encode("utf-8"),
|
||||
author=self._author,
|
||||
committer=self._author,
|
||||
)
|
||||
@@ -707,32 +827,15 @@ class StateGitSync:
|
||||
porcelain.add(repo)
|
||||
porcelain.commit(
|
||||
repo,
|
||||
message=f"state: compacted generation {self.generation}".encode("utf-8"),
|
||||
message=f"state: compacted generation {next_generation}".encode("utf-8"),
|
||||
author=self._author,
|
||||
committer=self._author,
|
||||
)
|
||||
current_remote_oid = self._remote_oid()
|
||||
if current_remote_oid != self._expected_remote_oid:
|
||||
raise StateSyncError("state branch changed remotely; refusing to overwrite a newer snapshot")
|
||||
porcelain.push(
|
||||
repo,
|
||||
self.remote,
|
||||
refspecs=f"HEAD:refs/heads/{self.branch}",
|
||||
force=True,
|
||||
outstream=io.BytesIO(),
|
||||
errstream=io.BytesIO(),
|
||||
**self._auth_kwargs(),
|
||||
)
|
||||
local_oid = repo.head().decode("ascii")
|
||||
if self._remote_oid() != local_oid:
|
||||
raise StateSyncError("state branch verification failed after push")
|
||||
self._expected_remote_oid = local_oid
|
||||
self.last_sync_at = manifest["updatedAt"]
|
||||
self.last_error = None
|
||||
self.healthy = True
|
||||
self.log(f"[state-sync] generation={self.generation} phase={phase} status=ok")
|
||||
self._sync_archive_pending_safely()
|
||||
return True
|
||||
self._pending_push_oid = local_oid
|
||||
self._pending_generation = next_generation
|
||||
self._pending_manifest = manifest
|
||||
return self._push_pending(phase=phase)
|
||||
except Exception as exc:
|
||||
self.healthy = False
|
||||
self.last_error = _safe_text(exc)
|
||||
|
||||
@@ -1 +1 @@
|
||||
AGENT_VERSION = "2026.08.22.1"
|
||||
AGENT_VERSION = "2026.09.04.1"
|
||||
|
||||
@@ -319,6 +319,45 @@ class ClientPoolConcurrencyTests(unittest.TestCase):
|
||||
self.assertEqual("age_policy_skipped", result["reason"])
|
||||
self.assertEqual([], client.submitted)
|
||||
|
||||
def test_submit_capacity_exhaustion_is_deferred_not_failed(self) -> None:
|
||||
class CapacityClient:
|
||||
@staticmethod
|
||||
def add_task(_payload): # noqa: ANN001, ANN205
|
||||
raise ModelHubAPIError("当前等待中或运行中的异步模型验证任务数量已达上限(100)")
|
||||
|
||||
result = submit_candidate(
|
||||
{
|
||||
"repoId": "owner/model",
|
||||
"modelAddress": "https://modelscope.cn/models/owner/model",
|
||||
"targetGpu": "gpu-a",
|
||||
"framework": "vllm",
|
||||
"taskType": "text-generation",
|
||||
"configParams": "safe",
|
||||
},
|
||||
CapacityClient(), # type: ignore[arg-type]
|
||||
)
|
||||
self.assertEqual("capacity_deferred", result["outcome"])
|
||||
self.assertEqual("account_capacity_saturated", result["reason"])
|
||||
|
||||
def test_transformers_platform_prerequisite_is_deferred_not_failed(self) -> None:
|
||||
class PrerequisiteClient:
|
||||
@staticmethod
|
||||
def add_task(_payload): # noqa: ANN001, ANN205
|
||||
raise ModelHubAPIError("该模型必须在非transformers框架验证失败后才可以开启transformers框架验证任务")
|
||||
|
||||
result = submit_candidate(
|
||||
{
|
||||
"repoId": "owner/model",
|
||||
"modelAddress": "https://modelscope.cn/models/owner/model",
|
||||
"targetGpu": "gpu-a",
|
||||
"framework": "transformers",
|
||||
"taskType": "text-generation",
|
||||
"configParams": "safe",
|
||||
},
|
||||
PrerequisiteClient(), # type: ignore[arg-type]
|
||||
)
|
||||
self.assertEqual("framework_prerequisite_deferred", result["outcome"])
|
||||
|
||||
def test_age_policy_deferred_candidate_is_skipped_not_failed(self) -> None:
|
||||
class AdmissionRaceClient:
|
||||
@staticmethod
|
||||
|
||||
@@ -193,6 +193,63 @@ class SuperAgentTests(unittest.TestCase):
|
||||
self.assertEqual("reliable", ordered[0]["targetGpu"])
|
||||
self.assertGreater(ordered[0]["routingSuccessBand"], ordered[1]["routingSuccessBand"])
|
||||
|
||||
def test_transformers_is_fallback_when_an_executable_framework_exists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
engine = SuccessFirstRoutingEngine(Path(temporary_dir) / "routing.json", log_fn=lambda _: None)
|
||||
candidates = [
|
||||
{
|
||||
"repoId": "owner/model",
|
||||
"targetGpu": "gpu-a",
|
||||
"framework": "transformers",
|
||||
"taskType": "text-generation",
|
||||
"frameworkMarketSamples": 1000,
|
||||
"frameworkMarketSuccessRate": 0.95,
|
||||
},
|
||||
{
|
||||
"repoId": "owner/model",
|
||||
"targetGpu": "gpu-b",
|
||||
"framework": "vllm",
|
||||
"taskType": "text-generation",
|
||||
"frameworkMarketSamples": 1000,
|
||||
"frameworkMarketSuccessRate": 0.40,
|
||||
},
|
||||
]
|
||||
ordered = engine.order_candidates(candidates)
|
||||
self.assertEqual(1, len(ordered))
|
||||
self.assertEqual("vllm", ordered[0]["framework"])
|
||||
|
||||
candidates[0]["transformersPrerequisiteSatisfied"] = True
|
||||
unlocked = engine.order_candidates(candidates)
|
||||
self.assertEqual("transformers", unlocked[0]["framework"])
|
||||
|
||||
def test_outcome_context_repairs_framework_missing_from_history_api(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
root = Path(temporary_dir)
|
||||
tracker = OutcomeTracker(root / "outcomes.jsonl")
|
||||
tracker._records = [
|
||||
{
|
||||
"taskId": "42",
|
||||
"modelId": "owner/model",
|
||||
"targetGpu": "gpu-a",
|
||||
"framework": "",
|
||||
"taskType": "",
|
||||
"outcome": "failed",
|
||||
}
|
||||
]
|
||||
tracker._rebuild_indexes()
|
||||
repaired = tracker.merge_task_contexts(
|
||||
{
|
||||
"42": {
|
||||
"framework": "vllm",
|
||||
"taskType": "text-generation",
|
||||
"modelProfile": {"modelType": "Qwen2ForCausalLM"},
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertEqual(1, repaired)
|
||||
self.assertEqual("vllm", tracker._by_task_id["42"]["framework"])
|
||||
self.assertEqual("Qwen2ForCausalLM", tracker._by_task_id["42"]["modelProfile"]["modelType"])
|
||||
|
||||
def test_modelscope_metadata_and_model_card_lineage_are_structured(self) -> None:
|
||||
item = {
|
||||
"id": "owner/model",
|
||||
@@ -270,6 +327,11 @@ class SuperAgentTests(unittest.TestCase):
|
||||
]
|
||||
)
|
||||
self.assertIsNotNone(batch_id)
|
||||
generation = manager.generation
|
||||
remote_head = manager._remote_oid()
|
||||
self.assertTrue(manager.sync("unchanged_cycle"))
|
||||
self.assertEqual(generation, manager.generation)
|
||||
self.assertEqual(remote_head, manager._remote_oid())
|
||||
self.assertFalse(pending_archive.exists())
|
||||
archive_refs = porcelain.ls_remote(str(remote)).refs
|
||||
self.assertIn(b"refs/heads/agent-archive-2026-08", archive_refs)
|
||||
@@ -296,6 +358,63 @@ class SuperAgentTests(unittest.TestCase):
|
||||
self.assertNotIn("must-not-be-copied", state_text)
|
||||
restored.close()
|
||||
|
||||
def test_failed_state_push_retries_the_same_commit_and_generation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
root = Path(temporary_dir)
|
||||
remote = root / "remote.git"
|
||||
project = root / "project"
|
||||
project.mkdir()
|
||||
porcelain.init(remote, bare=True)
|
||||
manager = StateGitSync(
|
||||
project_root=project,
|
||||
credentials={"username": "u", "email": "e@example.com", "password": "p"},
|
||||
remote=str(remote),
|
||||
log_fn=lambda _: None,
|
||||
)
|
||||
manager.acquire_process_lock()
|
||||
self.assertTrue(manager.restore())
|
||||
write_json(project / ".modelhub_state" / "account_capacity.json", {"version": 1})
|
||||
with patch("state_sync.porcelain.push", side_effect=RuntimeError("temporary failure")):
|
||||
self.assertFalse(manager.sync("cycle"))
|
||||
pending_oid = manager._pending_push_oid
|
||||
self.assertIsNotNone(pending_oid)
|
||||
self.assertEqual(0, manager.generation)
|
||||
self.assertTrue(manager.sync("retry"))
|
||||
self.assertEqual(1, manager.generation)
|
||||
self.assertEqual(pending_oid, manager._remote_oid())
|
||||
manager.close()
|
||||
|
||||
def test_terminal_intents_are_bounded_and_archived(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
root = Path(temporary_dir)
|
||||
intent_path = root / ".modelhub_state" / "recovery_intents.jsonl"
|
||||
write_jsonl(
|
||||
intent_path,
|
||||
[
|
||||
{
|
||||
"intentId": str(index),
|
||||
"status": "failed",
|
||||
"createdAt": f"2026-08-01T00:{index % 60:02d}:00+00:00",
|
||||
"completedAt": f"2026-08-02T00:{index % 60:02d}:00+00:00",
|
||||
}
|
||||
for index in range(250)
|
||||
]
|
||||
+ [{"intentId": "pending", "status": "pending"}],
|
||||
)
|
||||
manager = StateGitSync(
|
||||
project_root=root,
|
||||
credentials={"username": "u", "email": "e@example.com", "password": "p"},
|
||||
remote="unused",
|
||||
log_fn=lambda _: None,
|
||||
)
|
||||
self.assertEqual(50, manager._compact_intents())
|
||||
retained = read_jsonl(intent_path)
|
||||
self.assertEqual(201, len(retained))
|
||||
self.assertEqual(1, sum(row.get("status") == "pending" for row in retained))
|
||||
shard = next((root / ".modelhub_state" / "archive_pending" / "attempts").rglob("*.jsonl.gz"))
|
||||
with gzip.open(shard, "rt", encoding="utf-8") as handle:
|
||||
self.assertEqual(50, len(handle.readlines()))
|
||||
|
||||
def test_failed_intent_push_returns_no_batch_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
manager = StateGitSync(
|
||||
|
||||
Reference in New Issue
Block a user