Skip to content

Commit 5a4e5dc

Browse files
authored
Merge pull request #171 from BaseIntelligence/fix/prism-prepod-orphan-requeue
fix(prism): requeue pre-pod orphans and unblock infra retry
2 parents 403c869 + 18b74e3 commit 5a4e5dc

6 files changed

Lines changed: 204 additions & 29 deletions

File tree

crates/prism-challenge/src/api.rs

Lines changed: 120 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use submission_gating::{infra_resubmit_allowed, GatingState, GatingStore, Metagr
1919

2020
use prism_recipe::{BASELINE_ARCHITECTURE_PY, BASELINE_TRAINING_PY};
2121

22-
use crate::CHALLENGE_ID;
22+
use crate::{NoScoreReasonCode, CHALLENGE_ID};
2323
use prism_eval_store::{detail_view, eval_json, list_view};
2424
use prism_intake::{
2525
coldkey_of, json_err, map_submission_err, map_zip_err, materialize_arch, metagraph_uid, now_ms,
@@ -182,6 +182,13 @@ async fn gate_one_max(
182182
Ok(None)
183183
}
184184

185+
fn is_challenge_internal(row: &SubmissionState) -> bool {
186+
matches!(
187+
row.final_score,
188+
Some(FinalScore::NoScore(c)) if c == NoScoreReasonCode::ChallengeInternal as u8
189+
)
190+
}
191+
185192
/// [`prism_pipeline::queued_row`] with the coldkey resolved from the live
186193
/// metagraph snapshot (`None` off-chain).
187194
fn queued_row(
@@ -239,11 +246,20 @@ async fn post_submission(
239246
let id = prism_pipeline::submission_id(&req);
240247
let gate_challenge = prism_pipeline::gating_key(req.arch_id.as_deref());
241248

242-
// Idempotent duplicate: identical contract bytes never conflict gating.
243-
let exists = match st.store.get(&id).await {
244-
Ok(r) => r.is_some(),
249+
// Identical bytes never conflict gating. A failed infra row is recovered
250+
// here (same path as `/retry`) so miners are not stuck on `already-queued`
251+
// while the slot stays `failed` + `blocked`.
252+
let existing = match st.store.get(&id).await {
253+
Ok(r) => r,
245254
Err(e) => return json_err(StatusCode::INTERNAL_SERVER_ERROR, "store", &e.to_string()),
246255
};
256+
if existing
257+
.as_ref()
258+
.is_some_and(|r| r.status == Stage::Failed && is_challenge_internal(r))
259+
{
260+
return post_retry(State(st), Path(id), headers).await;
261+
}
262+
let exists = existing.is_some();
247263
let uid = if exists {
248264
None
249265
} else {
@@ -426,17 +442,9 @@ async fn post_retry(
426442
);
427443
}
428444
let gate_key = prism_pipeline::gating_key(row.arch_id.as_deref());
429-
let mut infra = matches!(row.final_score, Some(FinalScore::NoScore(6)));
430-
if infra {
431-
if let Some(g) = &st.gating {
432-
infra = g
433-
.get(&gate_key, &row.miner_hotkey)
434-
.await
435-
.ok()
436-
.flatten()
437-
.is_some_and(|gr| infra_resubmit_allowed(&gr, now_ms()));
438-
}
439-
}
445+
// ChallengeInternal is always miner-retryable (the 30m window only lets a
446+
// *different* ZIP through intake while blocked).
447+
let infra = is_challenge_internal(&row);
440448
if !infra {
441449
if let Err(resp) = verify_bearer(&st.admin_token_hashes, "admin", &headers) {
442450
return resp;
@@ -1288,6 +1296,103 @@ mod tests {
12881296
assert_eq!(s, StatusCode::ACCEPTED, "{v}");
12891297
}
12901298

1299+
#[tokio::test]
1300+
async fn challenge_internal_reposts_and_retries_after_window() {
1301+
let (st, gating) = gated_state(&[[0x11; 32]]);
1302+
let app = submission_router(Arc::clone(&st));
1303+
let body = serde_json::to_vec(&crate::example_valid_request()).unwrap();
1304+
let (s, v) = call(
1305+
app.clone(),
1306+
Request::post("/v1/submissions")
1307+
.header("content-type", "application/json")
1308+
.body(Body::from(body.clone()))
1309+
.unwrap(),
1310+
)
1311+
.await;
1312+
assert_eq!(s, StatusCode::ACCEPTED, "{v}");
1313+
let id = v["submission_id"].as_str().unwrap().to_owned();
1314+
let hk = "11".repeat(32);
1315+
st.store
1316+
.apply(
1317+
&id,
1318+
&StatePatch {
1319+
status: Some(Stage::Failed),
1320+
final_score: Some(FinalScore::NoScore(
1321+
NoScoreReasonCode::ChallengeInternal as u8,
1322+
)),
1323+
..StatePatch::default()
1324+
},
1325+
None,
1326+
)
1327+
.await
1328+
.unwrap();
1329+
gating
1330+
.set_terminal(
1331+
"prism",
1332+
&hk,
1333+
submission_gating::GatingState::Blocked,
1334+
Some("install"),
1335+
)
1336+
.await
1337+
.unwrap();
1338+
assert!(gating.set_updated_at_ms(
1339+
"prism",
1340+
&hk,
1341+
now_ms().saturating_sub(submission_gating::INFRA_RESUBMIT_WINDOW_MS + 1),
1342+
));
1343+
let (s, v) = call(
1344+
app.clone(),
1345+
Request::post(format!("/v1/submissions/{id}/retry"))
1346+
.body(Body::empty())
1347+
.unwrap(),
1348+
)
1349+
.await;
1350+
assert_eq!(s, StatusCode::ACCEPTED, "{v}");
1351+
assert_eq!(v["status"], "queued");
1352+
st.store
1353+
.apply(
1354+
&id,
1355+
&StatePatch {
1356+
status: Some(Stage::Failed),
1357+
final_score: Some(FinalScore::NoScore(
1358+
NoScoreReasonCode::ChallengeInternal as u8,
1359+
)),
1360+
..StatePatch::default()
1361+
},
1362+
None,
1363+
)
1364+
.await
1365+
.unwrap();
1366+
gating
1367+
.set_terminal(
1368+
"prism",
1369+
&hk,
1370+
submission_gating::GatingState::Blocked,
1371+
Some("install"),
1372+
)
1373+
.await
1374+
.unwrap();
1375+
assert!(gating.set_updated_at_ms(
1376+
"prism",
1377+
&hk,
1378+
now_ms().saturating_sub(submission_gating::INFRA_RESUBMIT_WINDOW_MS + 1),
1379+
));
1380+
let (s, v) = call(
1381+
app,
1382+
Request::post("/v1/submissions")
1383+
.header("content-type", "application/json")
1384+
.body(Body::from(body))
1385+
.unwrap(),
1386+
)
1387+
.await;
1388+
assert_eq!(s, StatusCode::ACCEPTED, "{v}");
1389+
assert_eq!(v["status"], "queued");
1390+
assert_eq!(
1391+
st.store.get(&id).await.unwrap().unwrap().status,
1392+
Stage::Queued
1393+
);
1394+
}
1395+
12911396
#[tokio::test]
12921397
async fn precheck_detects_copy_without_queuing() {
12931398
let st = state();

crates/prism-orphan/src/lib.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,36 @@ mod tests {
242242
assert!(matches!(got.final_score, Some(FinalScore::NoScore(_))));
243243
}
244244

245+
#[tokio::test]
246+
async fn boot_reconcile_requeues_pre_pod_review_without_failing() {
247+
let store: Arc<dyn PrismStore> = Arc::new(MemoryPrismStore::default());
248+
let r = row("feedfacefeedface", Stage::LlmReview, None);
249+
store.insert_queued(&r).await.unwrap();
250+
store
251+
.apply(
252+
&r.id,
253+
&prism_store::StatePatch {
254+
status: Some(Stage::LlmReview),
255+
..Default::default()
256+
},
257+
None,
258+
)
259+
.await
260+
.unwrap();
261+
let active = Arc::new(ActiveJobs::new());
262+
let be: Arc<dyn EvalJobBackend> = Arc::new(NoopBackend::dead());
263+
let report = reconcile_once(store.as_ref(), &active, None, be, 0, true, None)
264+
.await
265+
.unwrap();
266+
assert_eq!(report.requeued, 1);
267+
assert_eq!(report.failed, 0);
268+
assert_eq!(report.terminate_attempts, 0);
269+
let got = store.get(&r.id).await.unwrap().unwrap();
270+
assert_eq!(got.status, Stage::Queued);
271+
assert!(got.pod_id.is_none());
272+
assert!(got.final_score.is_none());
273+
}
274+
245275
#[tokio::test]
246276
async fn boot_reconcile_resumes_alive_pod_without_terminate() {
247277
let store: Arc<dyn PrismStore> = Arc::new(MemoryPrismStore::default());

crates/prism-orphan/src/reconcile.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,16 @@ pub async fn reconcile_once(
111111
}
112112
}
113113
}
114-
// Unreattachable mid-pod or pre-measure without pod: fail + best-effort stop.
114+
// Pre-pod (screens / claim / not yet rented): never fail-orphan.
115+
// A restart here has no Lium instance; fail-closed with `pod (none)`
116+
// burned the miner slot and asked them to stop a pod that does not exist.
117+
if row.pod_id.is_none() {
118+
if requeue_pre_pod(store, &row, reason).await {
119+
report.requeued = report.requeued.saturating_add(1);
120+
}
121+
continue;
122+
}
123+
// Unreattachable mid-pod: fail + best-effort stop.
115124
let mut terminated = false;
116125
let key_present = payer.is_some_and(|p| p.vault.get(&row.id).is_some());
117126
if let Some(pod) = row.pod_id.as_deref() {
@@ -141,6 +150,32 @@ pub async fn reconcile_once(
141150
Ok(report)
142151
}
143152

153+
async fn requeue_pre_pod(store: &dyn PrismStore, row: &SubmissionState, reason: &str) -> bool {
154+
let ok = store
155+
.apply(
156+
&row.id,
157+
&StatePatch {
158+
status: Some(Stage::Queued),
159+
error_detail: None,
160+
..StatePatch::default()
161+
},
162+
Some(&StageEvent {
163+
stage: Stage::Queued,
164+
detail: Some(serde_json::json!({
165+
"pre_pod_requeue": true,
166+
"reason": reason,
167+
})),
168+
at_ms: 0,
169+
}),
170+
)
171+
.await
172+
.is_ok();
173+
if ok {
174+
info!(submission_id = %row.id, reason, "pre-pod mid-flight requeued");
175+
}
176+
ok
177+
}
178+
144179
/// Probe pod + vault; on success requeue to `queued` keeping `pod_id`.
145180
async fn try_resume(
146181
store: &dyn PrismStore,

docs/PRISM.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,18 +126,21 @@ special: each miner `X-Lium-Api-Key` has its **own** Lium budget (no shared
126126
process-wide rent serialize queue). The orchestrator **requeues without
127127
burning** `retry_count` / gating attempts. A background tick re-queues
128128
failed 429 rows from the last **6 hours**. After an infra `blocked`, the
129-
miner may **resubmit for up to 30 minutes** (new `POST /v1/submissions` or
130-
`POST /v1/submissions/{id}/retry` for `ChallengeInternal`); after the window
131-
the slot stays blocked until the metagraph watcher reopens it (hotkey left /
132-
replaced).
129+
miner may **`/retry` or re-POST the same bytes** for `ChallengeInternal`
130+
without a time cutoff. A *different* ZIP is only accepted inside the
131+
**30-minute** infra window; after that the slot stays blocked until the
132+
metagraph watcher reopens it (hotkey left / replaced).
133133

134134
**Training-only entries** gate separately under the composite challenge key
135135
`prism:train:<arch_id>`: one accepted entry per `(hotkey, arch_id)`, with
136136
the same auto-retry classes, the same terminal `rejected`/`blocked` states,
137137
and the same watcher resets (reconciliation is prefix-scoped, so `prism`
138138
covers every `prism:train:*` row). Idempotency stays the contract-bytes
139-
`submission_id`: resubmitting identical bytes is an `already-queued` no-op,
140-
never a gate conflict.
139+
`submission_id`: resubmitting identical in-flight / successful bytes is an
140+
`already-queued` no-op (never a gate conflict). A failed `ChallengeInternal`
141+
row is recovered by that same POST or `/retry`. Pre-pod mid-flight rows
142+
(`llm_review` / `similarity` / `provisioning` with no `pod_id`) requeue on
143+
control-plane restart instead of fail-orphan with `pod (none)`.
141144

142145
## Architecture registry + competition
143146

docs/external-miner/prism.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,16 +136,18 @@ versioned descriptor). Trust `/v1/recipe`, not marketing chart labels.
136136
- Infra failures (Lium pod, review/similarity/LLM infra) **auto-retry up to 3
137137
times**; harness `EVAL_FAIL` (miner/model code) is terminal for that attempt
138138
and is **not** auto-retried. Cheat / rejected verdicts are terminal. After an
139-
infra failure (`ChallengeInternal`), you may **recover within 30 minutes**
140-
via `POST /v1/submissions/{id}/retry` with **`X-Lium-Api-Key`** (required on
141-
live when another GPU rent is needed). After 30 minutes the slot stays
142-
blocked until your hotkey leaves the metagraph.
139+
infra failure (`ChallengeInternal` / `control_plane_restart`), recover with
140+
`POST /v1/submissions/{id}/retry` and **`X-Lium-Api-Key`** on live when
141+
another GPU rent is needed — **no 30-minute cutoff** on `/retry` or on
142+
re-POSTing the **same** pin+patch (that used to return `already-queued`
143+
while the row stayed `failed`). A *different* ZIP while the slot is
144+
`blocked` is still only accepted inside the 30-minute infra window.
143145

144146
### Retry vs re-POST
145147

146148
| Action | When | Headers |
147149
|--------|------|---------|
148-
| Re-POST the **same** ZIP | Always safe | Same as submit | Returns `200 already-queued`**no new GPU run**; does not recover a failed row |
150+
| Re-POST the **same** ZIP | Always safe | Same as submit | In-flight / scored → `200 already-queued`. Failed `ChallengeInternal` → same as `/retry` (`202 queued`) |
149151
| `POST /v1/submissions/{id}/retry` | Row status is **`failed`** only | **`X-Lium-Api-Key`** on live (infra recovery); admin Bearer for operator non-infra retries | Requeues measure; wrong/missing Lium key → `400 missing_lium_api_key` |
150152
| `/retry` on non-failed ||| `409 not_failed` — hotkey or Bearer alone does not change that |
151153

docs/external-miner/troubleshoot.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
| `409 schedule` "daily manual run quota exceeded" | Manual anti-spam cap (10/day) — round-loop auto-enqueue does **not** spend it | `GET /v1/quota/{hotkey}``manual.remaining`; wait until next UTC day |
1313
| Active harness but no runs this round | Rare race / restart before auto-enqueue; or eliminated cooldown | Wait for the round tick / ask ops `admin/rounds/current/requeue`; check `eliminated_until_round` |
1414
| `auto_retry` events, class `install` | Dep won't install (bad name/version, heavy source build) | Design: `GET /v1/runs/{id}/logs`; Prism: `GET /v1/submissions/{id}/logs?since=` |
15-
| `control_plane_restart` / `harness_detached` | Restart could not reattach (dead pod or unrecoverable BYOK seal) | Stop the Lium pod if still billing; resubmit with `X-Lium-Api-Key`. Healthy pods are resumed automatically — do not kill them on a routine master redeploy. |
15+
| `control_plane_restart` / `harness_detached` | Restart could not reattach (dead pod or unrecoverable BYOK seal) | If `pod_id` is null, **no Lium pod was rented** — do not hunt a pod. `POST /v1/submissions/{id}/retry` (or re-POST the same ZIP) with `X-Lium-Api-Key`. Healthy pods with a `pod_id` resume automatically. |
1616
| Run `failed` / Score 0 | Missing pages, timeout, crash | `GET /v1/runs/{id}/events`; ensure three required HTML pages |
1717
| External call refused (`403`) | Target is internal-blocklisted (metadata IP, loopback, RFC1918/VPC, control plane) | Call public endpoints only; egress is otherwise open |
1818
| Pages look empty in viewer | Sanitize stripped content | Scripts/`on*` handlers are removed; use static HTML/CSS |
@@ -35,7 +35,7 @@
3535
| `similar: true` on precheck | Would hit intake copy gate | Change the patch vs prior champions; starting from the operator pin is fine |
3636
| `429 precheck_quota_exceeded` | 3 prechecks/coldkey/UTC day used | Wait until next UTC day; rotating hotkeys does not reset |
3737
| `400 missing_lium_api_key` | Live path needs miner-funded Lium | Pass `X-Lium-Api-Key` (your Lium account); see [`prism.md`](prism.md) |
38-
| `409 not_failed` on `/retry` | Row is not `failed` (queued/running/scored) | `/retry` is only for failed rows. Identical ZIP re-POST → `already-queued` (no-op). After infra failure use `/retry` + `X-Lium-Api-Key` |
38+
| `409 not_failed` on `/retry` | Row is not `failed` (queued/running/scored) | `/retry` is only for failed rows. In-flight identical ZIP re-POST → `already-queued`. Failed infra: `/retry` or same-ZIP POST recovers the row |
3939
| `400 missing_lium_api_key` on `/retry` | Failed infra row needs another GPU rent | Send `X-Lium-Api-Key` (hotkey / Bearer alone is not enough) |
4040
| Stuck `Provisioning` | Lium market / underfunded key / no 1×5090 | Check Lium balance; Prism hard-pins **1× RTX 5090** (non-5090 rejected) |
4141
| Idempotent replay | Same `submission_id` (pin id + patch bytes) | Expected — returns prior row |

0 commit comments

Comments
 (0)