feat: compact durable logs and refine framework routing

This commit is contained in:
CoolBoy
2026-09-04 10:30:21 +08:00
parent 5b1ec4d3eb
commit ff73768537
13 changed files with 621 additions and 44 deletions

View File

@@ -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 archive shard is awaiting upload. ModelHub Git traffic is excluded from proxy
routing in the container to avoid deployment-specific Git failures. 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 ## 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 -a agent-v29 -m "ModelHub agent 2026.08.22.1" git tag -a agent-v30 -m "ModelHub agent 2026.09.04.1"
git push origin main agent-v29 git push origin main agent-v30
``` ```

View 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.

View File

@@ -36,7 +36,9 @@ from modelhub_client import (
ModelHubClient, ModelHubClient,
ModelHubClientPool, ModelHubClientPool,
OldModelQueuePolicyError, OldModelQueuePolicyError,
is_capacity_error,
is_duplicate_submission_error, is_duplicate_submission_error,
is_framework_prerequisite_error,
is_model_uniqueness_error, is_model_uniqueness_error,
) )
from models import CandidateModel, HFModelSummary, ModelInspection 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}) skipped.append({"repoId": model.repo_id, "targetGpu": target_gpu, "reason": reason})
continue continue
record = candidate_to_record(best) 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: if market_intelligence is not None:
record.update(market_intelligence.gpu_metadata(target_gpu)) record.update(market_intelligence.gpu_metadata(target_gpu))
record.update(market_intelligence.framework_metadata(best.task_type, target_gpu, best.framework)) record.update(market_intelligence.framework_metadata(best.task_type, target_gpu, best.framework))
@@ -879,6 +885,17 @@ def submit_candidate(
"candidate": candidate, "candidate": candidate,
"reason": "age_policy_skipped", "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): if is_model_uniqueness_error(exc):
print( print(
f"[submit] skipped repo={candidate['repoId']} gpu={candidate['targetGpu']} " f"[submit] skipped repo={candidate['repoId']} gpu={candidate['targetGpu']} "
@@ -901,6 +918,17 @@ def submit_candidate(
"candidate": candidate, "candidate": candidate,
"reason": str(exc), "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( print(
f"[submit] failed repo={candidate['repoId']} gpu={candidate['targetGpu']} " f"[submit] failed repo={candidate['repoId']} gpu={candidate['targetGpu']} "
f"framework={candidate['framework']} reason={exc}", f"framework={candidate['framework']} reason={exc}",
@@ -1052,6 +1080,7 @@ def run_submission(
run_dir = make_run_dir(runs_dir, now) run_dir = make_run_dir(runs_dir, now)
ledger_path.parent.mkdir(parents=True, exist_ok=True) ledger_path.parent.mkdir(parents=True, exist_ok=True)
history_archive_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)) outcome_tracker = outcome_tracker or OutcomeTracker(Path(args.outcomes_path))
# Online decisions are deliberately deterministic. The optional classifier # Online decisions are deliberately deterministic. The optional classifier
@@ -1085,7 +1114,25 @@ def run_submission(
synced_count = 0 synced_count = 0
if not getattr(args, "skip_outcome_sync", False): if not getattr(args, "skip_outcome_sync", False):
try: 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: except Exception:
pass pass
if preflight_advisor is not None: if preflight_advisor is not None:
@@ -1096,7 +1143,6 @@ def run_submission(
updated_after = determine_updated_after(args, now) updated_after = determine_updated_after(args, now)
history_begin = now - timedelta(days=args.stats_window_days) 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) 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) # 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) 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 submit_workers = 1
state_sync = getattr(args, "_state_sync_manager", None) state_sync = getattr(args, "_state_sync_manager", None)
state_sync_paused = False state_sync_paused = False
capacity_saturated = False
if args.dry_run: if args.dry_run:
attempted_candidates = diversified_candidates[:target_submit_count] attempted_candidates = diversified_candidates[:target_submit_count]
@@ -1549,6 +1596,7 @@ def run_submission(
batch_duplicate_candidates: list[dict[str, Any]] = [] batch_duplicate_candidates: list[dict[str, Any]] = []
batch_uniqueness_rejected_candidates: list[dict[str, Any]] = [] batch_uniqueness_rejected_candidates: list[dict[str, Any]] = []
batch_policy_skipped_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]] = [] batch_failed_candidates: list[dict[str, Any]] = []
for index in range(len(batch_candidates)): for index in range(len(batch_candidates)):
result = ordered_results.get(index) result = ordered_results.get(index)
@@ -1593,6 +1641,30 @@ def run_submission(
} }
) )
continue 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"}: if result["outcome"] in {"failed", "precheck_deferred"}:
batch_failed_candidates.append(candidate) batch_failed_candidates.append(candidate)
failed.append( failed.append(
@@ -1646,7 +1718,13 @@ def run_submission(
claim_store.mark_submitted( claim_store.mark_submitted(
[*batch_submitted_candidates, *batch_duplicate_candidates, *batch_uniqueness_rejected_candidates] [*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: if strategy_manager is not None:
strategy_manager.record_accepted(batch_submitted_candidates) strategy_manager.record_accepted(batch_submitted_candidates)
outcome_tracker.save() outcome_tracker.save()
@@ -1663,6 +1741,11 @@ def run_submission(
if hasattr(modelhub_client, "available_submit_slots") and modelhub_client.available_submit_slots() <= 0: if hasattr(modelhub_client, "available_submit_slots") and modelhub_client.available_submit_slots() <= 0:
break 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 / "submitted.jsonl", submitted)
write_jsonl(run_dir / "skipped.jsonl", skipped) write_jsonl(run_dir / "skipped.jsonl", skipped)

View File

@@ -22,7 +22,7 @@ DEFAULT_FRAMEWORK_MIN_SAMPLES = 300
DEFAULT_GPU_MIN_RECENT_TERMINALS = 20 DEFAULT_GPU_MIN_RECENT_TERMINALS = 20
DEFAULT_FRAMEWORK_MIN_WILSON = 0.05 DEFAULT_FRAMEWORK_MIN_WILSON = 0.05
DEFAULT_COMMUNITY_WINDOW_DAYS = 30 DEFAULT_COMMUNITY_WINDOW_DAYS = 30
DEFAULT_COMMUNITY_MAX_MODELS = 2000 DEFAULT_COMMUNITY_MAX_MODELS = 200
DEFAULT_COMMUNITY_HYDRATE_PER_REFRESH = 50 DEFAULT_COMMUNITY_HYDRATE_PER_REFRESH = 50
NEW_FRAMEWORK_PROMOTION_MARGIN = 1.10 NEW_FRAMEWORK_PROMOTION_MARGIN = 1.10
ERROR_RETRY_SECONDS = 300 ERROR_RETRY_SECONDS = 300
@@ -248,6 +248,16 @@ class MarketIntelligenceManager:
else: else:
state = self._base_state(gpus, tasks, now) 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( queue_due = not _fresh(
state.get("queueUpdatedAt"), state.get("queueUpdatedAt"),
now=now, now=now,

View File

@@ -370,6 +370,12 @@ MODEL_UNIQUENESS_ERROR_MARKERS = (
"model already exists", "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") 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) 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: class ModelHubClientPool:
def __init__( def __init__(
self, self,

View File

@@ -10,6 +10,7 @@ from common import parse_datetime, read_json, utc_now, write_json
OFFICIAL_CAPABILITY_VERSION = 1 OFFICIAL_CAPABILITY_VERSION = 1
DEFAULT_OFFICIAL_CAPABILITIES_PATH = Path(".modelhub_state/official_capabilities.json") DEFAULT_OFFICIAL_CAPABILITIES_PATH = Path(".modelhub_state/official_capabilities.json")
DEFAULT_MODEL_GPU_CACHE_LIMIT = 1500
class OfficialCapabilityUnavailable(RuntimeError): class OfficialCapabilityUnavailable(RuntimeError):
@@ -61,6 +62,18 @@ class OfficialCapabilityRegistry:
self.pause_reason: str | None = None self.pause_reason: str | None = None
self._lock = threading.Lock() 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]: def _load(self) -> dict[str, Any]:
try: try:
value = read_json(self.path) 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]: 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() now = now or utc_now()
state = self._load() state = self._load()
self._prune_model_gpu_cache(state)
catalog_usable = _fresh(state.get("catalogUpdatedAt"), now, 1800) catalog_usable = _fresh(state.get("catalogUpdatedAt"), now, 1800)
task_tree_usable = _fresh(state.get("taskTreeUpdatedAt"), now, 7 * 86400) task_tree_usable = _fresh(state.get("taskTreeUpdatedAt"), now, 7 * 86400)
errors: list[str] = [] errors: list[str] = []
@@ -159,6 +173,7 @@ class OfficialCapabilityRegistry:
with self._lock: with self._lock:
cache = self.state.setdefault("modelGpuTaskTypes", {}) cache = self.state.setdefault("modelGpuTaskTypes", {})
cache[key] = {"updatedAt": now.isoformat(), "taskTypes": task_types} cache[key] = {"updatedAt": now.isoformat(), "taskTypes": task_types}
self._prune_model_gpu_cache(self.state)
self.state["generatedAt"] = now.isoformat() self.state["generatedAt"] = now.isoformat()
write_json(self.path, self.state) write_json(self.path, self.state)
return task_types return task_types

View File

@@ -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_RECENT_OUTCOMES_PATH = Path(".modelhub_state/recent_outcomes.jsonl")
DEFAULT_ARCHIVE_PENDING_DIR = Path(".modelhub_state/archive_pending/outcomes") DEFAULT_ARCHIVE_PENDING_DIR = Path(".modelhub_state/archive_pending/outcomes")
OUTCOME_CHECKPOINT_VERSION = 1 OUTCOME_CHECKPOINT_VERSION = 1
DEFAULT_OUTCOME_COMPACT_THRESHOLD = 2000 DEFAULT_OUTCOME_COMPACT_THRESHOLD = 1000
DEFAULT_RECENT_OUTCOME_LIMIT = 1000 DEFAULT_RECENT_OUTCOME_LIMIT = 1000
FAILURE_ENRICHMENT_LIMIT = 40 FAILURE_ENRICHMENT_LIMIT = 40
FAILURE_ENRICHMENT_WORKERS = 4 FAILURE_ENRICHMENT_WORKERS = 4
@@ -215,6 +215,25 @@ class OutcomeTracker:
} }
return contexts 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]]: def get_strategy_history_records(self) -> list[dict[str, Any]]:
"""Expose only successes and evidence-attributable failures for GPU ranking.""" """Expose only successes and evidence-attributable failures for GPU ranking."""
records: list[dict[str, Any]] = [] records: list[dict[str, Any]] = []
@@ -331,7 +350,12 @@ class OutcomeTracker:
self._rebuild_failed_index() self._rebuild_failed_index()
return marked 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: try:
begin = self._last_sync_time begin = self._last_sync_time
end = utc_now() end = utc_now()
@@ -350,13 +374,32 @@ class OutcomeTracker:
if not task_id: if not task_id:
continue continue
task = self._enrich_history_task(task, (task_contexts or {}).get(task_id))
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:
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"}: 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)
updated_count += 1 updated_count += 1
elif metadata_changed:
updated_count += 1
else: else:
status = str(task.get("status") or "").lower() status = str(task.get("status") or "").lower()
if status in TERMINAL_TASK_STATUSES: if status in TERMINAL_TASK_STATUSES:
@@ -393,6 +436,7 @@ class OutcomeTracker:
if updated_count or enrichment_attempts: if updated_count or enrichment_attempts:
self._last_sync_time = end self._last_sync_time = end
self._rebuild_failed_index() self._rebuild_failed_index()
write_jsonl(self.recent_path, self._recent_records)
self.save() self.save()
return updated_count return updated_count
@@ -630,6 +674,18 @@ class OutcomeTracker:
now = now or utc_now() now = now or utc_now()
return failed_at >= now - timedelta(hours=max(0, int(cooldown_hours))) 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]: def get_stats_report(self) -> dict[str, Any]:
now_datetime = utc_now() now_datetime = utc_now()
now = now_datetime.isoformat() now = now_datetime.isoformat()

View File

@@ -560,6 +560,13 @@ def run_poll_loop(
poll_run_dir = make_run_dir(poll_runs_dir, now) poll_run_dir = make_run_dir(poll_runs_dir, now)
outcome_tracker = outcome_tracker or OutcomeTracker(Path(base_args.outcomes_path)) 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 OUTCOME_SYNC_INTERVAL = 3
STATS_PRINT_INTERVAL = 10 STATS_PRINT_INTERVAL = 10
@@ -652,7 +659,13 @@ def run_poll_loop(
and not getattr(base_args, "skip_outcome_sync", False) and not getattr(base_args, "skip_outcome_sync", False)
): ):
try: 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 outcome_synced_this_cycle = True
if synced > 0: if synced > 0:
log(f"[poll] cycle={cycles} outcome_sync_updated={synced}") log(f"[poll] cycle={cycles} outcome_sync_updated={synced}")
@@ -702,7 +715,13 @@ def run_poll_loop(
not outcome_synced_this_cycle not outcome_synced_this_cycle
and not getattr(base_args, "skip_outcome_sync", False) 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: if synced_before_cleanup:
log( log(
f"[queue-cleanup] outcome_sync_updated={synced_before_cleanup}" f"[queue-cleanup] outcome_sync_updated={synced_before_cleanup}"
@@ -920,7 +939,13 @@ def run_poll_loop(
time.sleep(cooldown) time.sleep(cooldown)
try: 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: except Exception:
pass pass
outcome_tracker.save() outcome_tracker.save()

View File

@@ -138,6 +138,20 @@ class SuccessFirstRoutingEngine:
selected: list[dict[str, Any]] = [] selected: list[dict[str, Any]] = []
for routes in by_model.values(): 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) best = max(float(item["routingSuccessLowerBound"]) for item in routes)
close = [item for item in routes if best - float(item["routingSuccessLowerBound"]) <= 0.05] close = [item for item in routes if best - float(item["routingSuccessLowerBound"]) <= 0.05]
close.sort( close.sort(

View File

@@ -26,9 +26,10 @@ STATE_SCHEMA_VERSION = 1
DEFAULT_REMOTE = "https://dev.modelhub.org.cn/CoolBoy/submmit.git" DEFAULT_REMOTE = "https://dev.modelhub.org.cn/CoolBoy/submmit.git"
DEFAULT_BRANCH = "agent-state" DEFAULT_BRANCH = "agent-state"
DEFAULT_ARCHIVE_BRANCH_PREFIX = "agent-archive" DEFAULT_ARCHIVE_BRANCH_PREFIX = "agent-archive"
DEFAULT_BATCH_SIZE = 20 DEFAULT_BATCH_SIZE = 100
DEFAULT_RETENTION_DAYS = 30 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. # Only these runtime files may cross the trust boundary into the state branch.
# Credentials, raw stdout, downloaded archives and run directories are excluded. # Credentials, raw stdout, downloaded archives and run directories are excluded.
@@ -154,6 +155,9 @@ class StateGitSync:
self._mutex = threading.Lock() self._mutex = threading.Lock()
self._workspace: Path | None = None self._workspace: Path | None = None
self._expected_remote_oid: str | 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 @property
def state_dir(self) -> Path: def state_dir(self) -> Path:
@@ -216,8 +220,8 @@ class StateGitSync:
return oid.decode("ascii") if oid else None return oid.decode("ascii") if oid else None
def _sync_archive_pending(self) -> None: def _sync_archive_pending(self) -> None:
pending_root = self.state_dir / "archive_pending" / "outcomes" pending_root = self.state_dir / "archive_pending"
files = sorted(path for path in pending_root.glob("*/*.jsonl.gz") if path.is_file()) files = sorted(path for path in pending_root.glob("*/*/*.jsonl.gz") if path.is_file())
if not files: if not files:
return return
by_month: dict[str, list[Path]] = {} by_month: dict[str, list[Path]] = {}
@@ -251,10 +255,11 @@ class StateGitSync:
manifest = {} manifest = {}
archived = manifest.get("files") if isinstance(manifest.get("files"), dict) else {} archived = manifest.get("files") if isinstance(manifest.get("files"), dict) else {}
for source in month_files: 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) destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination) shutil.copy2(source, destination)
archived[f"outcomes/{source.name}"] = { archived[f"{archive_kind}/{source.name}"] = {
"sha256": _sha256_file(destination), "sha256": _sha256_file(destination),
"bytes": destination.stat().st_size, "bytes": destination.stat().st_size,
} }
@@ -273,7 +278,7 @@ class StateGitSync:
if any(status.staged.get(kind) for kind in ("add", "delete", "modify")): if any(status.staged.get(kind) for kind in ("add", "delete", "modify")):
porcelain.commit( porcelain.commit(
repo, repo,
message=f"archive: outcomes {month}".encode("utf-8"), message=f"archive: durable records {month}".encode("utf-8"),
author=self._author, author=self._author,
committer=self._author, committer=self._author,
) )
@@ -404,6 +409,49 @@ class StateGitSync:
self._expected_remote_oid = None self._expected_remote_oid = None
return self.restore() 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]: def _event_files(self) -> list[Path]:
event_dir = self.state_dir / "events" event_dir = self.state_dir / "events"
if not event_dir.exists(): if not event_dir.exists():
@@ -588,6 +636,7 @@ class StateGitSync:
unresolved += 1 unresolved += 1
retention_cutoff = now - timedelta(days=self.retention_days) retention_cutoff = now - timedelta(days=self.retention_days)
retained: list[dict[str, Any]] = [] retained: list[dict[str, Any]] = []
expired: list[dict[str, Any]] = []
for intent in intents: for intent in intents:
completed_text = intent.get("completedAt") completed_text = intent.get("completedAt")
if not completed_text: if not completed_text:
@@ -602,12 +651,16 @@ class StateGitSync:
completed_at = completed_at.replace(tzinfo=timezone.utc) completed_at = completed_at.replace(tzinfo=timezone.utc)
if completed_at >= retention_cutoff: if completed_at >= retention_cutoff:
retained.append(intent) retained.append(intent)
else:
expired.append(intent)
self._archive_intents(expired)
write_jsonl(self.intents_path, retained) write_jsonl(self.intents_path, retained)
self.record_active_tasks(enriched) self.record_active_tasks(enriched)
return {"active": len(enriched), "reconciled": reconciled, "unresolved": unresolved} return {"active": len(enriched), "reconciled": reconciled, "unresolved": unresolved}
def _copy_snapshot(self) -> dict[str, str]: def _copy_snapshot(self) -> dict[str, str]:
assert self._workspace is not None assert self._workspace is not None
self._compact_intents()
checksums: dict[str, str] = {} checksums: dict[str, str] = {}
for relative in STATE_ALLOWLIST: for relative in STATE_ALLOWLIST:
source = self.project_root / relative source = self.project_root / relative
@@ -634,7 +687,21 @@ class StateGitSync:
if isinstance(sanitized_market, dict): if isinstance(sanitized_market, dict):
# A restored snapshot must refresh official configs before routing. # A restored snapshot must refresh official configs before routing.
sanitized_market["frameworkUpdatedAt"] = None 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) 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"}: elif relative in {"outcomes/submissions.jsonl", ".modelhub_state/recent_outcomes.jsonl"}:
sanitized_outcomes: list[dict[str, Any]] = [] sanitized_outcomes: list[dict[str, Any]] = []
for row in read_jsonl(source): for row in read_jsonl(source):
@@ -666,16 +733,69 @@ class StateGitSync:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
return checksums 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: def sync(self, phase: str) -> bool:
with self._mutex: with self._mutex:
try: try:
if self._workspace is None: if self._workspace is None:
raise StateSyncError("state workspace is not initialized") 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() 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 = { manifest = {
"schemaVersion": STATE_SCHEMA_VERSION, "schemaVersion": STATE_SCHEMA_VERSION,
"generation": self.generation, "generation": next_generation,
"updatedAt": _utc_now().isoformat(), "updatedAt": _utc_now().isoformat(),
"agentVersion": AGENT_VERSION, "agentVersion": AGENT_VERSION,
"writerId": self.writer_id, "writerId": self.writer_id,
@@ -696,7 +816,7 @@ class StateGitSync:
return True return True
porcelain.commit( porcelain.commit(
repo, repo,
message=f"state: generation {self.generation} ({phase})".encode("utf-8"), message=f"state: generation {next_generation} ({phase})".encode("utf-8"),
author=self._author, author=self._author,
committer=self._author, committer=self._author,
) )
@@ -707,32 +827,15 @@ class StateGitSync:
porcelain.add(repo) porcelain.add(repo)
porcelain.commit( porcelain.commit(
repo, repo,
message=f"state: compacted generation {self.generation}".encode("utf-8"), message=f"state: compacted generation {next_generation}".encode("utf-8"),
author=self._author, author=self._author,
committer=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") local_oid = repo.head().decode("ascii")
if self._remote_oid() != local_oid: self._pending_push_oid = local_oid
raise StateSyncError("state branch verification failed after push") self._pending_generation = next_generation
self._expected_remote_oid = local_oid self._pending_manifest = manifest
self.last_sync_at = manifest["updatedAt"] return self._push_pending(phase=phase)
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
except Exception as exc: except Exception as exc:
self.healthy = False self.healthy = False
self.last_error = _safe_text(exc) self.last_error = _safe_text(exc)

View File

@@ -1 +1 @@
AGENT_VERSION = "2026.08.22.1" AGENT_VERSION = "2026.09.04.1"

View File

@@ -319,6 +319,45 @@ class ClientPoolConcurrencyTests(unittest.TestCase):
self.assertEqual("age_policy_skipped", result["reason"]) self.assertEqual("age_policy_skipped", result["reason"])
self.assertEqual([], client.submitted) 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: def test_age_policy_deferred_candidate_is_skipped_not_failed(self) -> None:
class AdmissionRaceClient: class AdmissionRaceClient:
@staticmethod @staticmethod

View File

@@ -193,6 +193,63 @@ class SuperAgentTests(unittest.TestCase):
self.assertEqual("reliable", ordered[0]["targetGpu"]) self.assertEqual("reliable", ordered[0]["targetGpu"])
self.assertGreater(ordered[0]["routingSuccessBand"], ordered[1]["routingSuccessBand"]) 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: def test_modelscope_metadata_and_model_card_lineage_are_structured(self) -> None:
item = { item = {
"id": "owner/model", "id": "owner/model",
@@ -270,6 +327,11 @@ class SuperAgentTests(unittest.TestCase):
] ]
) )
self.assertIsNotNone(batch_id) 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()) self.assertFalse(pending_archive.exists())
archive_refs = porcelain.ls_remote(str(remote)).refs archive_refs = porcelain.ls_remote(str(remote)).refs
self.assertIn(b"refs/heads/agent-archive-2026-08", archive_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) self.assertNotIn("must-not-be-copied", state_text)
restored.close() 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: def test_failed_intent_push_returns_no_batch_id(self) -> None:
with tempfile.TemporaryDirectory() as temporary_dir: with tempfile.TemporaryDirectory() as temporary_dir:
manager = StateGitSync( manager = StateGitSync(