fix: sync PPI scheduling and harden preflight

This commit is contained in:
wtx
2026-08-22 05:09:52 +08:00
parent 052b0c1d5d
commit 311a49110f
6 changed files with 465 additions and 109 deletions
@@ -34,3 +34,89 @@ def test_multilingual_frontend_keeps_critical_ppi_kaks_state_chain_in_sync() ->
for start, end in boundaries:
assert _block(multilingual, start, end) == _block(canonical, start, end)
def test_multilingual_frontend_uses_stage_shell_for_ppi_reference_prompts() -> None:
multilingual = (ROOT / "apps/web/vue3/index_ml.html").read_text(encoding="utf-8")
nearest = _block(
multilingual,
"const confirmNearestPpiReferenceCandidate = async",
"const ppiReferenceDecisionMessage =",
)
preflight = _block(
multilingual,
"const runPpiSpeciesResolutionPreflight = async",
"const ppiKaksDependencyStages =",
)
assert "showStageConfirmDialog" in nearest
assert "ElMessageBox.confirm" not in nearest
assert "id: 'ppi_kaks_reference_status'" in preflight
assert "ppiNeedsNearReferenceDecision(resolution, requestedStages)" in preflight
assert "confirmParams.stage === 'ppi_string_reference_confirmation'" in multilingual
assert "confirmParams.stage === 'ppi_kaks_reference_status'" in multilingual
assert "Confirm top candidate" in multilingual
assert "Reference status acknowledged" in multilingual
def test_multilingual_frontend_keeps_ppi_kaks_owner_pauses_isolated() -> None:
canonical = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
multilingual = (ROOT / "apps/web/vue3/index_ml.html").read_text(encoding="utf-8")
start = "const ppiKaksOwnedOrthologOwners ="
end = "const mergePpiReferenceSpeciesIntoBatch ="
assert _block(multilingual, start, end) == _block(canonical, start, end)
assert "ppiKaksDependencyPauses" in multilingual
assert "ppiKaksDependencyPause:" not in _block(multilingual, start, end)
def test_multilingual_frontend_uses_hidden_ppi_owned_ortholog_producer() -> None:
canonical = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
multilingual = (ROOT / "apps/web/vue3/index_ml.html").read_text(encoding="utf-8")
start = "const ensureOrthologStageForPpiKaks ="
end = "const ensurePpiKaksDependencyOwner ="
producer = _block(multilingual, start, end)
assert producer == _block(canonical, start, end)
assert "rememberPpiKaksInternalOrthologProducer('ppi'" in producer
assert "nextStages.splice" not in producer
assert "attachOrthologDependencyToOwners(requestedStages)" not in producer
def test_multilingual_frontend_does_not_abort_batch_on_ppi_preflight_pause() -> None:
multilingual = (ROOT / "apps/web/vue3/index_ml.html").read_text(encoding="utf-8")
start = multilingual.index("const startBatchExecute = async")
end = multilingual.index("const ppiSettledForKaks =", start)
batch_start = multilingual[start:end]
assert "await ensureOrthologProducerForPpiKaks();" in batch_start
assert "const dependencyPreflightOk = await ensureOrthologProducerForPpiKaks();" not in batch_start
def test_multilingual_background_batch_pauses_ppi_kaks_preflight_blocks() -> None:
multilingual = (ROOT / "apps/web/vue3/index_ml.html").read_text(encoding="utf-8")
helper = _block(
multilingual,
"const isPpiKaksBackgroundPause =",
"const startBackgroundBatchExecution = async",
)
background = _block(
multilingual,
"const startBackgroundBatchExecution = async",
"const extractDataPathsFromText =",
)
assert "stageId === 'ppi' || stageId === 'kaks'" in helper
assert "intentStages.includes('ortholog')" in helper
assert "isPpiKaksBackgroundPause(stageId, stageParams, err, intentStages)" in background
assert "localStatus[stageId] = 'paused'" in background
def test_multilingual_frontend_keeps_transient_fetch_recovery_in_sync() -> None:
canonical = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
multilingual = (ROOT / "apps/web/vue3/index_ml.html").read_text(encoding="utf-8")
start = "const fetchWithTransientRetry = async"
end = "const ppiKaksOwnedOrthologOwners ="
assert _block(multilingual, start, end) == _block(canonical, start, end)
assert "fetchWithTransientRetry('/api/ppi/species-resolution'" in multilingual
+159 -1
View File
@@ -1291,13 +1291,19 @@ const ppiKaksDependencyPauses = () => ({});
def test_submit_preflight_maps_kaks_selected_ortholog_owner_to_dependency_owner() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
retry_helper = _source_block(
html,
"const fetchWithTransientRetry = async",
"const pollReferenceBundleDownloadTask = async",
)
helper = _source_block(
html,
"const runPpiSpeciesResolutionPreflight =",
"const ppiKaksDependencyStages =",
)
result = _run_node(
helper
retry_helper
+ helper
+ """
const jobInfo = {job_id: 'job_1786243816439_04955b'};
const executionPrefix = () => 'Ft.SPL';
@@ -1784,3 +1790,155 @@ def test_ppi_manifest_and_background_status_queries_use_canonical_owner_identity
expected = "canonicalizePpiOwnerBiologicalScope({ ...batchPlanParams, ...batchParams, ...stageParams })"
assert expected in manifest_block
assert expected in background_block
def test_ppi_preflight_retries_one_transient_network_error_before_returning_ready() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
retry_helper = _source_block(
html,
"const fetchWithTransientRetry = async",
"const pollReferenceBundleDownloadTask = async",
)
preflight = _source_block(
html,
"const runPpiSpeciesResolutionPreflight = async",
"const ppiKaksDependencyStages =",
)
result = _run_node(
retry_helper
+ preflight
+ """
const jobInfo = {job_id: 'job_network_retry'};
const executionPrefix = () => 'Ft.SPL';
const canonicalizePpiOwnerBiologicalScope = payload => payload;
const ppiNeedsNearReferenceDecision = () => false;
const formatPpiSpeciesResolutionBlockMessage = () => 'not ready';
const showStageConfirmDialog = async () => true;
const addAgentMessage = () => {};
const ElMessageBox = {alert: async () => {}};
const setTimeout = callback => callback();
let attempts = 0;
const fetch = async () => {
attempts += 1;
if (attempts === 1) {
throw new TypeError('NetworkError when attempting to fetch resource.');
}
return {
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({success: true, status: 'ready'})
};
};
(async () => {
const outcome = await runPpiSpeciesResolutionPreflight({
stage: 'ppi', prefix: 'Ft.SPL', targetSpecies: ['Ft']
}, {requestedStages: ['ppi']});
console.log(JSON.stringify({attempts, allowed: outcome.allowed}));
})();
"""
)
assert result == {"attempts": 2, "allowed": True}
def test_ppi_preflight_does_not_retry_a_valid_business_response() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
retry_helper = _source_block(
html,
"const fetchWithTransientRetry = async",
"const pollReferenceBundleDownloadTask = async",
)
preflight = _source_block(
html,
"const runPpiSpeciesResolutionPreflight = async",
"const ppiKaksDependencyStages =",
)
result = _run_node(
retry_helper
+ preflight
+ """
const jobInfo = {job_id: 'job_business_response'};
const executionPrefix = () => 'Ft.SPL';
const canonicalizePpiOwnerBiologicalScope = payload => payload;
const ppiNeedsNearReferenceDecision = () => false;
const formatPpiSpeciesResolutionBlockMessage = () => 'reference data missing';
const showStageConfirmDialog = async () => true;
const addAgentMessage = () => {};
const ElMessageBox = {alert: async () => {}};
const setTimeout = callback => callback();
let attempts = 0;
const fetch = async () => {
attempts += 1;
return {
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({success: true, status: 'needs_reference_data'})
};
};
(async () => {
const outcome = await runPpiSpeciesResolutionPreflight({
stage: 'ppi', prefix: 'Ft.SPL', targetSpecies: ['Ft']
}, {requestedStages: ['ppi']});
console.log(JSON.stringify({attempts, allowed: outcome.allowed}));
})();
"""
)
assert result == {"attempts": 1, "allowed": False}
def test_reference_bundle_status_poll_recovers_from_one_transient_network_error() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
helper = _source_block(
html,
"const fetchWithTransientRetry = async",
"const ppiKaksOwnedOrthologOwners =",
)
result = _run_node(
helper
+ """
const setTimeout = callback => callback();
const referenceBundleErrorMessage = (_data, fallback) => fallback;
const updateReferenceBundleDownloadProgress = () => {};
let attempts = 0;
const fetch = async () => {
attempts += 1;
if (attempts === 1) throw new TypeError('Failed to fetch');
return {
ok: true,
status: 200,
json: async () => ({status: 'success', result: {status: 'success', species_code: 'string_taxon_3562'}})
};
};
(async () => {
const result = await pollReferenceBundleDownloadTask('task-download');
console.log(JSON.stringify({attempts, status: result.status, species: result.species_code}));
})();
"""
)
assert result == {"attempts": 2, "status": "success", "species": "string_taxon_3562"}
def test_transient_retry_is_not_used_for_download_or_analysis_submission() -> None:
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
download_submit = _source_block(
html,
"const downloadRecommendedPpiReferenceBundle = async",
"const updateReferenceBundleDownloadProgress =",
)
execute_submit = _source_block(
html,
"const submitExecuteRequestWithAutoAnnotation = async",
"const waitForDialogTransition = async",
)
assert "fetch('/api/reference-bundle/download'" in download_submit
assert "fetchWithTransientRetry('/api/reference-bundle/download'" not in download_submit
assert "fetch('/api/execute'" in execute_submit
assert "fetchWithTransientRetry('/api/execute'" not in execute_submit
@@ -0,0 +1,33 @@
from __future__ import annotations
def test_startup_hook_warms_the_string_snapshot_cache(monkeypatch) -> None:
from apps.web import pipeline_backend
from apps.web.vue3 import main
calls = []
monkeypatch.setattr(
pipeline_backend,
"load_string_snapshot",
lambda root: calls.append(root),
)
main.warm_string_species_snapshot_cache()
assert calls == [pipeline_backend.STRING_SPECIES_SNAPSHOT_ROOT]
assert main.warm_string_species_snapshot_cache in main.app.router.on_startup
def test_string_snapshot_warm_failure_does_not_abort_web_startup(monkeypatch, capsys) -> None:
from apps.web import pipeline_backend
from apps.web.vue3 import main
from gfa_core.string_species_registry import StringSnapshotError
def fail_to_warm(_root):
raise StringSnapshotError("snapshot unavailable", code="string_registry_unavailable")
monkeypatch.setattr(pipeline_backend, "load_string_snapshot", fail_to_warm)
main.warm_string_species_snapshot_cache()
assert "STRING snapshot cache warm failed" in capsys.readouterr().out