net/adapter/net/cortex/workflow/step.rs
1//! Capability-bearing steps (plan piece 5 / Phase D) — the one
2//! cross-plan seam to Thunderdome.
3//!
4//! A step that requires an *exclusive* capability must obtain it
5//! through the Thunderdome match→claim pipeline and **must not run**
6//! until an `Active` claim handle is held. The lifecycle layer states
7//! the requirement and reacts to the claim result; it never appends to
8//! a `ReservationFold` and never reads the capability/topology folds
9//! for placement (locked decision 4: `requires_capability` is a
10//! *filter*, not a claim — a hint is never a hold).
11//!
12//! That contract is made **structural** here, not conventional:
13//! [`drive_capability_step`] takes only a [`WorkflowAdapter`] and a
14//! [`ClaimPipeline`] — it has no fold to touch, so a step *cannot*
15//! bypass Thunderdome by construction. The production pipeline
16//! ([`GangClaimPipeline`]) is the only thing wired to the reservation
17//! fold, and it is the Thunderdome flow itself (match → reserve →
18//! quorum-`Active`).
19
20use crate::adapter::net::behavior::fold::{
21 CapabilityFold, Fold, IslandId, IslandTopologyFold, JobId, NodeId, ReservationFold,
22};
23use crate::adapter::net::behavior::gang::{
24 commit_active, match_islands, release_island, single_island_claim, ActiveCommitOutcome,
25 ClaimError, ClaimOutcome, Claimant, Epoch, MatchCriteria, ReplicaCohort, ReplicaSet,
26};
27use crate::adapter::net::current_timestamp_micros;
28use crate::adapter::net::identity::EntityKeypair;
29
30use super::adapter::WorkflowAdapter;
31use super::types::TaskId;
32
33/// A held `Active` claim handle — proof a step may start its
34/// irreversible work on an exclusively-held capability (one island).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct ActiveClaim {
37 /// The island (exclusive resource) held in `Active`.
38 pub island: IslandId,
39}
40
41/// The exclusive-capability requirement of a step. Per locked decision
42/// 4 this is a *match* the pipeline consumes — never a hold. The
43/// lifecycle states it; it never evaluates placement itself.
44pub struct CapabilityRequirement {
45 /// The Thunderdome match (capability query + numeric filter +
46 /// selection policy).
47 pub criteria: MatchCriteria,
48 /// How long the resulting `Reserved` lasts before foreign takeover.
49 pub reserve_ttl_us: u64,
50}
51
52/// Outcome of handing a [`CapabilityRequirement`] to the claim
53/// pipeline.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ClaimResult {
56 /// An exclusive capability is held — the `Active` claim handle. The
57 /// step may run.
58 Active(ActiveClaim),
59 /// No capacity / contention / lost reservation. The step stays
60 /// `Waiting` and re-requests later.
61 Rejected,
62}
63
64/// The one cross-plan seam. The lifecycle hands a
65/// [`CapabilityRequirement`] to this and reacts to the
66/// [`ClaimResult`]. Implementors encapsulate the *entire* contact with
67/// resource arbitration; the lifecycle depends only on this trait.
68///
69/// The seam is **bidirectional**: `claim` acquires, `release` returns
70/// the island to the pool. Every abnormal exit of a step that holds an
71/// `Active` claim (failed, cancelled, deleted, rewound-past) must
72/// `release` it — an un-released claim is a stranded GPU (the audit's
73/// cross-cutting rule). Acquire without release is the one-directional
74/// bug the matching `release` closes.
75pub trait ClaimPipeline {
76 /// Error type for a claim attempt (sign/apply-level failures,
77 /// distinct from a clean [`ClaimResult::Rejected`]).
78 type Error;
79
80 /// Hand `req` to Thunderdome's match→claim pipeline and report
81 /// whether an `Active` handle is now held.
82 fn claim(&mut self, req: &CapabilityRequirement) -> Result<ClaimResult, Self::Error>;
83
84 /// Release a previously-held [`ActiveClaim`], returning its island
85 /// to the pool. The substrate *can* compensate here — unlike an
86 /// external side effect, a held claim is its own to revoke. Should
87 /// be idempotent at the resource layer (releasing an island the
88 /// caller no longer holds is a no-op).
89 fn release(&mut self, claim: &ActiveClaim) -> Result<(), Self::Error>;
90}
91
92/// What [`drive_capability_step`] did with the task.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum StepGate {
95 /// The capability is held; the task is now `Running` and may
96 /// execute its step. Carries the `Active` handle.
97 Running(ActiveClaim),
98 /// The claim was rejected; the task is parked `Waiting` and will
99 /// re-request on a later drive.
100 Waiting,
101}
102
103/// Error from driving a capability-bearing step.
104#[derive(Debug)]
105pub enum StepError<E> {
106 /// The claim pipeline errored.
107 Pipeline(E),
108 /// Writing the resulting task transition to the workflow chain
109 /// failed.
110 Workflow(super::super::error::CortexAdapterError),
111}
112
113/// Drive a capability-bearing step: hand its requirement to `pipeline`
114/// and transition `task` accordingly — `Active` → `Running` (the step
115/// may execute), `Rejected` → `Waiting` (re-request later).
116///
117/// The task **never** touches a reservation fold here: this function
118/// has none to touch. The only path to an exclusive resource is
119/// through `pipeline`, so "a step can't bypass Thunderdome" is a
120/// property of the signature, not a discipline.
121pub fn drive_capability_step<P: ClaimPipeline>(
122 wf: &WorkflowAdapter,
123 pipeline: &mut P,
124 task: TaskId,
125 req: &CapabilityRequirement,
126) -> Result<StepGate, StepError<P::Error>> {
127 match pipeline.claim(req).map_err(StepError::Pipeline)? {
128 ClaimResult::Active(claim) => {
129 wf.start(task).map_err(StepError::Workflow)?;
130 Ok(StepGate::Running(claim))
131 }
132 ClaimResult::Rejected => {
133 wf.wait(task).map_err(StepError::Workflow)?;
134 Ok(StepGate::Waiting)
135 }
136 }
137}
138
139/// The folds + identity a [`GangClaimPipeline`] reads/writes — the
140/// "where I match and reserve, and who I am" context. Bundled so the
141/// pipeline constructor doesn't thread five `&_`/`u64` args (two of
142/// which, `node_id` and `job`, are bare `u64`s).
143pub struct GangClaimContext<'a> {
144 /// Capability fold (step 1 of the match).
145 pub capability: &'a Fold<CapabilityFold>,
146 /// Island-topology fold (step 2 numeric filter).
147 pub topology: &'a Fold<IslandTopologyFold>,
148 /// Reservation fold the reserve + Active commit land on.
149 pub reservations: &'a Fold<ReservationFold>,
150 /// Identity signing the reservation announcements.
151 pub keypair: &'a EntityKeypair,
152 /// This node's id (the claim holder).
153 pub node_id: NodeId,
154}
155
156/// Production [`ClaimPipeline`] backed by the Thunderdome gang
157/// scheduler — the only component here wired to the reservation fold.
158///
159/// `claim` is the Thunderdome flow itself: match (read capability +
160/// topology) → reserve the first available island (AP) → quorum-commit
161/// `Active` (the one CP edge). It uses only the public gang surface
162/// (`match_islands` / `single_island_claim` / `commit_active`), so it
163/// can't reach into the scheduler's internals.
164pub struct GangClaimPipeline<'a> {
165 ctx: GangClaimContext<'a>,
166 /// Single generation owner: every reserve / epoch / release
167 /// announcement this pipeline signs takes the next value, so they
168 /// stay strictly-monotonic. Replaces a duplicate `generation`
169 /// counter plus a throwaway `Claimant` that was rebuilt (and reset
170 /// to 1) on every commit (review #11).
171 claimant: Claimant<'a>,
172 cohort: ReplicaCohort,
173 replica_set: ReplicaSet,
174 reachable: Vec<NodeId>,
175 job: JobId,
176}
177
178impl<'a> GangClaimPipeline<'a> {
179 /// Build a pipeline for `job`, claiming over `ctx`, committing
180 /// `Active` against the island's `replica_set` (with `reachable`
181 /// the subset currently reachable — all of `set` when healthy; a
182 /// strict subset models a partition).
183 pub fn new(
184 ctx: GangClaimContext<'a>,
185 replica_set: ReplicaSet,
186 reachable: Vec<NodeId>,
187 job: JobId,
188 ) -> Self {
189 Self::with_generation(ctx, replica_set, reachable, job, 1)
190 }
191
192 /// Like [`new`](Self::new) but seeds the starting generation/epoch.
193 ///
194 /// **Durability limitation (review #4):** the epoch rides the
195 /// reservation generation (locked decision 3), and the fence lives
196 /// in `cohort`, which [`new`](Self::new) builds fresh per pipeline.
197 /// So with the default seed of 1 the `→ Active` fence is only
198 /// self-consistent *within one pipeline's lifetime*: a restarted or
199 /// successor leader that builds a new pipeline restarts epochs at 1,
200 /// below what a prior leader drove the fence to, and (once the
201 /// cohort is durable/shared) would be fenced out — livelock. The
202 /// live Phase-D wiring must seed `start_generation` from a durable
203 /// per-island counter **and** share the cohort across leaders; this
204 /// constructor is the seam for the former.
205 pub fn with_generation(
206 ctx: GangClaimContext<'a>,
207 replica_set: ReplicaSet,
208 reachable: Vec<NodeId>,
209 job: JobId,
210 start_generation: u64,
211 ) -> Self {
212 let cohort = ReplicaCohort::new(replica_set.members());
213 let claimant =
214 Claimant::with_generation(ctx.reservations, ctx.keypair, ctx.node_id, start_generation);
215 Self {
216 ctx,
217 claimant,
218 cohort,
219 replica_set,
220 reachable,
221 job,
222 }
223 }
224
225 fn next_gen(&mut self) -> u64 {
226 self.claimant.next_gen()
227 }
228}
229
230impl ClaimPipeline for GangClaimPipeline<'_> {
231 type Error = ClaimError;
232
233 fn claim(&mut self, req: &CapabilityRequirement) -> Result<ClaimResult, ClaimError> {
234 // [1] Match — read-only over capability + topology. The
235 // lifecycle stated the requirement; Thunderdome evaluates
236 // placement.
237 // Liveness pruning (MeshOS ↔ Scheduler Projection 4) is fed on the
238 // node claim path via `MeshNode::set_liveness_down`; this seam isn't
239 // wired to a liveness source yet, so it passes an empty down-set.
240 let islands = match_islands(
241 self.ctx.capability,
242 self.ctx.topology,
243 &req.criteria,
244 &std::collections::HashSet::new(),
245 );
246 if islands.is_empty() {
247 return Ok(ClaimResult::Rejected);
248 }
249
250 // [2] Reserve the first available island (AP, optimistic).
251 let until = current_timestamp_micros().saturating_add(req.reserve_ttl_us);
252 let mut reserved = None;
253 for island in islands {
254 let gen = self.next_gen();
255 if single_island_claim(
256 self.ctx.reservations,
257 self.ctx.keypair,
258 self.ctx.node_id,
259 gen,
260 island,
261 until,
262 )? == ClaimOutcome::Won
263 {
264 reserved = Some(island);
265 break;
266 }
267 }
268 let Some(island) = reserved else {
269 return Ok(ClaimResult::Rejected);
270 };
271
272 // [3] Quorum-commit Active (the one CP edge). The epoch rides
273 // the generation: take the next counter value, which is
274 // strictly above the reserve's generation.
275 let epoch: Epoch = self.next_gen();
276 match commit_active(
277 &self.claimant,
278 &mut self.cohort,
279 &self.replica_set,
280 &self.reachable,
281 island,
282 self.job,
283 epoch,
284 )? {
285 ActiveCommitOutcome::Committed => Ok(ClaimResult::Active(ActiveClaim { island })),
286 // No quorum (minority partition) or a takeover stole the
287 // reserve: no Active, so the step is rejected and re-
288 // requests. Release the reserve we still hold now rather
289 // than letting it TTL-expire — otherwise it blocks every
290 // other claimant on this island for the whole reserve_ttl_us
291 // while this step is merely parked Waiting. Best-effort: a
292 // no-op (Lost) if a takeover already stole it, as on
293 // LostReservation (review #14).
294 ActiveCommitOutcome::NoQuorum { .. } | ActiveCommitOutcome::LostReservation => {
295 let gen = self.next_gen();
296 let _ = release_island(
297 self.ctx.reservations,
298 self.ctx.keypair,
299 self.ctx.node_id,
300 gen,
301 island,
302 );
303 Ok(ClaimResult::Rejected)
304 }
305 }
306 }
307
308 fn release(&mut self, claim: &ActiveClaim) -> Result<(), ClaimError> {
309 // CAS the island back to Free — the matching release for the
310 // Active commit. Signed at the next generation so it can't be
311 // reordered behind the claim. A no-op at the fold if we no
312 // longer hold it (idempotent).
313 let gen = self.next_gen();
314 release_island(
315 self.ctx.reservations,
316 self.ctx.keypair,
317 self.ctx.node_id,
318 gen,
319 claim.island,
320 )?;
321 Ok(())
322 }
323}
324
325/// Release a step's held `Active` claim — the matching *release* for
326/// [`drive_capability_step`]'s acquire. The worker MUST call this on
327/// every abnormal exit of a step that holds an island (`Failed`,
328/// cancelled, deleted, rewound past the acquiring step): the island is
329/// the substrate's to revoke, and an un-released claim is a stranded
330/// GPU (the audit's cross-cutting rule). Idempotent at the Thunderdome
331/// layer. Like [`drive_capability_step`] it touches no fold directly —
332/// the only path back to the resource is through `pipeline`.
333pub fn release_step<P: ClaimPipeline>(
334 pipeline: &mut P,
335 claim: &ActiveClaim,
336) -> Result<(), P::Error> {
337 pipeline.release(claim)
338}
339
340/// Advisory classification of a step's side-effect profile (corrections
341/// #3). The substrate can't *verify* side-effect freedom, so this is
342/// convention the worker respects, not enforcement: a `SideEffecting`
343/// step that already completed should not be silently re-run on rewind
344/// without a registered compensating step, whereas `Pure` / `Idempotent`
345/// steps are safe to re-execute. Rewind reconstructs lifecycle metadata
346/// deterministically; it does **not** undo external side effects.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
348pub enum StepKind {
349 /// No external side effects — safe to re-execute freely.
350 #[default]
351 Pure,
352 /// Has side effects but re-execution leaves the world unchanged
353 /// (e.g. an idempotent PUT) — safe to re-execute.
354 Idempotent,
355 /// Produces non-idempotent external effects (an email, a payment, a
356 /// non-idempotent API call). Re-execution is unsafe.
357 SideEffecting,
358}
359
360impl StepKind {
361 /// May this step be safely re-executed (e.g. on a rewind/retry)
362 /// given whether it `already_completed`? `Pure` / `Idempotent` are
363 /// always safe; a completed `SideEffecting` step is not (the worker
364 /// should require a compensating step instead). Advisory.
365 pub fn may_reexecute(self, already_completed: bool) -> bool {
366 match self {
367 StepKind::Pure | StepKind::Idempotent => true,
368 StepKind::SideEffecting => !already_completed,
369 }
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use std::collections::BTreeMap;
376 use std::time::Duration;
377
378 use super::*;
379 use crate::adapter::net::behavior::fold::{
380 CapabilityFilter, CapabilityMembership, CapabilityQuery, EnvelopeMeta, FoldKind,
381 IslandRecord, NodeState, ReservationQuery, ReservationState, SignedAnnouncement, UnitSet,
382 };
383 use crate::adapter::net::behavior::gang::{NumericFilter, SelectionPolicy};
384 use crate::adapter::net::cortex::workflow::TaskStatus;
385 use crate::adapter::net::redex::Redex;
386
387 /// A test double: returns a forced result and records that it was
388 /// consulted. Lets the seam tests prove the driver routes purely
389 /// through the pipeline.
390 struct ForcedPipeline {
391 result: ClaimResult,
392 calls: u32,
393 releases: u32,
394 }
395 impl ClaimPipeline for ForcedPipeline {
396 type Error = std::convert::Infallible;
397 fn claim(&mut self, _req: &CapabilityRequirement) -> Result<ClaimResult, Self::Error> {
398 self.calls += 1;
399 Ok(self.result)
400 }
401 fn release(&mut self, _claim: &ActiveClaim) -> Result<(), Self::Error> {
402 self.releases += 1;
403 Ok(())
404 }
405 }
406
407 fn requirement() -> CapabilityRequirement {
408 CapabilityRequirement {
409 criteria: MatchCriteria {
410 capability: CapabilityQuery::Composite(CapabilityFilter {
411 tags_all: vec!["gpu:h100".into()],
412 ..Default::default()
413 }),
414 numeric: NumericFilter {
415 min_units: 8,
416 ..Default::default()
417 },
418 selection: SelectionPolicy::LeastLoaded,
419 prefer_capability: None,
420 },
421 reserve_ttl_us: 60_000_000,
422 }
423 }
424
425 async fn submitted_task(wf: &WorkflowAdapter, id: TaskId) {
426 let seq = wf.submit(id).unwrap();
427 wf.wait_for_seq(seq).await.unwrap();
428 }
429
430 #[tokio::test]
431 async fn forced_reject_leaves_the_step_waiting_never_running() {
432 let redex = Redex::new();
433 let wf = WorkflowAdapter::open(&redex, 0x0F10_00D1).await.unwrap();
434 submitted_task(&wf, 1).await;
435
436 let mut pipeline = ForcedPipeline {
437 result: ClaimResult::Rejected,
438 calls: 0,
439 releases: 0,
440 };
441 let gate = drive_capability_step(&wf, &mut pipeline, 1, &requirement()).unwrap();
442 let seq = wf.wait(1).unwrap(); // flush + read
443 wf.wait_for_seq(seq).await.unwrap();
444
445 assert_eq!(gate, StepGate::Waiting);
446 assert_eq!(
447 pipeline.calls, 1,
448 "the requirement is handed to the pipeline"
449 );
450 // The step is parked Waiting and never reached Running.
451 assert_eq!(wf.get(1).unwrap().status, TaskStatus::Waiting);
452 }
453
454 #[tokio::test]
455 async fn forced_active_runs_the_step() {
456 let redex = Redex::new();
457 let wf = WorkflowAdapter::open(&redex, 0x0F10_00D2).await.unwrap();
458 submitted_task(&wf, 1).await;
459
460 let mut pipeline = ForcedPipeline {
461 result: ClaimResult::Active(ActiveClaim { island: 0xA0 }),
462 calls: 0,
463 releases: 0,
464 };
465 let gate = drive_capability_step(&wf, &mut pipeline, 1, &requirement()).unwrap();
466 let seq = wf.start(1).unwrap();
467 wf.wait_for_seq(seq).await.unwrap();
468
469 assert_eq!(gate, StepGate::Running(ActiveClaim { island: 0xA0 }));
470 assert_eq!(wf.get(1).unwrap().status, TaskStatus::Running);
471
472 // Abnormal exit: the worker fails the step and MUST release the
473 // claim through the same seam (corrections cross-cutting rule).
474 if let StepGate::Running(claim) = gate {
475 release_step(&mut pipeline, &claim).unwrap();
476 wf.fail(1).unwrap();
477 }
478 assert_eq!(
479 pipeline.releases, 1,
480 "the held claim is released on abnormal exit"
481 );
482 }
483
484 #[test]
485 fn step_kind_reexecute_is_advisory_and_blocks_completed_side_effects() {
486 // Pure / Idempotent: always safe to re-run.
487 assert!(StepKind::Pure.may_reexecute(true));
488 assert!(StepKind::Idempotent.may_reexecute(true));
489 // SideEffecting: safe before completion, unsafe after (needs a
490 // compensating step instead of a silent re-run).
491 assert!(StepKind::SideEffecting.may_reexecute(false));
492 assert!(!StepKind::SideEffecting.may_reexecute(true));
493 assert_eq!(StepKind::default(), StepKind::Pure);
494 }
495
496 // --- production pipeline over real Thunderdome folds ---
497
498 fn new_fold<K: FoldKind>() -> Fold<K> {
499 Fold::with_sweep_interval(Duration::ZERO)
500 }
501
502 fn announce_capability(fold: &Fold<CapabilityFold>, kp: &EntityKeypair, node: u64) {
503 let m = CapabilityMembership {
504 class_hash: 0x67_70_75,
505 tags: vec!["gpu:h100".into()],
506 hardware: None,
507 state: NodeState::Idle,
508 region: None,
509 price_quote: None,
510 reflex_addr: None,
511 allowed_nodes: Vec::new(),
512 allowed_subnets: Vec::new(),
513 allowed_groups: Vec::new(),
514 metadata: BTreeMap::new(),
515 };
516 fold.apply(
517 SignedAnnouncement::sign(
518 kp,
519 CapabilityFold::KIND_ID,
520 m.class_hash,
521 node,
522 1,
523 EnvelopeMeta::default(),
524 m,
525 )
526 .unwrap(),
527 )
528 .unwrap();
529 }
530
531 fn announce_island(fold: &Fold<IslandTopologyFold>, kp: &EntityKeypair, node: u64, id: u64) {
532 let record = IslandRecord {
533 id,
534 units: UnitSet::new((0..8).collect()),
535 host: node,
536 capabilities: vec!["model:a1".into()],
537 load: 0.2,
538 p50_latency_us: 1_000,
539 };
540 fold.apply(
541 SignedAnnouncement::sign(
542 kp,
543 IslandTopologyFold::KIND_ID,
544 0,
545 node,
546 1,
547 EnvelopeMeta::default(),
548 record,
549 )
550 .unwrap(),
551 )
552 .unwrap();
553 }
554
555 #[tokio::test]
556 async fn gang_pipeline_claims_active_and_runs_when_capacity_exists() {
557 let caps = new_fold::<CapabilityFold>();
558 let topo = new_fold::<IslandTopologyFold>();
559 let res = new_fold::<ReservationFold>();
560 let gpu = EntityKeypair::generate();
561 let gn = gpu.entity_id().node_id();
562 announce_capability(&caps, &gpu, gn);
563 announce_island(&topo, &gpu, gn, 0xA0);
564
565 let leader = EntityKeypair::generate();
566 let ln = leader.entity_id().node_id();
567 let mut pipeline = GangClaimPipeline::new(
568 GangClaimContext {
569 capability: &caps,
570 topology: &topo,
571 reservations: &res,
572 keypair: &leader,
573 node_id: ln,
574 },
575 ReplicaSet::new([1, 2, 3]),
576 vec![1, 2, 3], // healthy: full majority reachable
577 42,
578 );
579
580 let redex = Redex::new();
581 let wf = WorkflowAdapter::open(&redex, 0x0F10_00D3).await.unwrap();
582 submitted_task(&wf, 1).await;
583
584 let gate = drive_capability_step(&wf, &mut pipeline, 1, &requirement()).unwrap();
585 let seq = wf.start(1).unwrap();
586 wf.wait_for_seq(seq).await.unwrap();
587
588 assert_eq!(gate, StepGate::Running(ActiveClaim { island: 0xA0 }));
589 assert_eq!(wf.get(1).unwrap().status, TaskStatus::Running);
590 // The island is held in Active by the leader — through
591 // Thunderdome, the only path that touched the reservation fold.
592 assert!(matches!(
593 res.query(ReservationQuery::State(0xA0))[0].1,
594 ReservationState::Active { holder, .. } if holder == ln
595 ));
596 }
597
598 /// Cross-cutting rule, end-to-end over real Thunderdome folds: a
599 /// step acquires an island in `Active`, then on an abnormal exit
600 /// `release_step` returns it to `Free` — the held GPU goes back to
601 /// the pool (no stranded hardware).
602 #[tokio::test]
603 async fn gang_pipeline_release_returns_the_island_to_free() {
604 let caps = new_fold::<CapabilityFold>();
605 let topo = new_fold::<IslandTopologyFold>();
606 let res = new_fold::<ReservationFold>();
607 let gpu = EntityKeypair::generate();
608 let gn = gpu.entity_id().node_id();
609 announce_capability(&caps, &gpu, gn);
610 announce_island(&topo, &gpu, gn, 0xA0);
611
612 let leader = EntityKeypair::generate();
613 let ln = leader.entity_id().node_id();
614 let mut pipeline = GangClaimPipeline::new(
615 GangClaimContext {
616 capability: &caps,
617 topology: &topo,
618 reservations: &res,
619 keypair: &leader,
620 node_id: ln,
621 },
622 ReplicaSet::new([1, 2, 3]),
623 vec![1, 2, 3],
624 42,
625 );
626
627 let redex = Redex::new();
628 let wf = WorkflowAdapter::open(&redex, 0x0F10_00D6).await.unwrap();
629 submitted_task(&wf, 1).await;
630
631 let gate = drive_capability_step(&wf, &mut pipeline, 1, &requirement()).unwrap();
632 let claim = match gate {
633 StepGate::Running(c) => c,
634 StepGate::Waiting => panic!("expected the claim to commit Active"),
635 };
636 // Held in Active.
637 assert!(matches!(
638 res.query(ReservationQuery::State(0xA0))[0].1,
639 ReservationState::Active { .. }
640 ));
641
642 // Abnormal exit → release through the seam → island Free.
643 release_step(&mut pipeline, &claim).unwrap();
644 assert_eq!(
645 res.query(ReservationQuery::State(0xA0))[0].1,
646 ReservationState::Free,
647 "released island returns to the pool",
648 );
649 }
650
651 #[tokio::test]
652 async fn gang_pipeline_rejects_and_waits_with_no_capacity_leaving_nothing_reserved() {
653 let caps = new_fold::<CapabilityFold>();
654 let topo = new_fold::<IslandTopologyFold>();
655 let res = new_fold::<ReservationFold>();
656 // Capability announced but NO island → match is empty.
657 let gpu = EntityKeypair::generate();
658 let gn = gpu.entity_id().node_id();
659 announce_capability(&caps, &gpu, gn);
660
661 let leader = EntityKeypair::generate();
662 let ln = leader.entity_id().node_id();
663 let mut pipeline = GangClaimPipeline::new(
664 GangClaimContext {
665 capability: &caps,
666 topology: &topo,
667 reservations: &res,
668 keypair: &leader,
669 node_id: ln,
670 },
671 ReplicaSet::new([1, 2, 3]),
672 vec![1, 2, 3],
673 42,
674 );
675
676 let redex = Redex::new();
677 let wf = WorkflowAdapter::open(&redex, 0x0F10_00D4).await.unwrap();
678 submitted_task(&wf, 1).await;
679
680 let gate = drive_capability_step(&wf, &mut pipeline, 1, &requirement()).unwrap();
681 let seq = wf.wait(1).unwrap();
682 wf.wait_for_seq(seq).await.unwrap();
683
684 assert_eq!(gate, StepGate::Waiting);
685 assert_eq!(wf.get(1).unwrap().status, TaskStatus::Waiting);
686 // A rejected step leaves NOTHING reserved — no leaked hold.
687 assert!(res.query(ReservationQuery::State(0xA0)).is_empty());
688 }
689
690 /// Minority partition: the leader reaches only 1 of 3 replicas, so
691 /// the `Active` commit is quorum-starved → `Rejected`/`Waiting`,
692 /// and the step never starts compute (the Thunderdome guarantee,
693 /// surfaced at the lifecycle seam).
694 #[tokio::test]
695 async fn gang_pipeline_minority_partition_cannot_run_the_step() {
696 let caps = new_fold::<CapabilityFold>();
697 let topo = new_fold::<IslandTopologyFold>();
698 let res = new_fold::<ReservationFold>();
699 let gpu = EntityKeypair::generate();
700 let gn = gpu.entity_id().node_id();
701 announce_capability(&caps, &gpu, gn);
702 announce_island(&topo, &gpu, gn, 0xA0);
703
704 let leader = EntityKeypair::generate();
705 let ln = leader.entity_id().node_id();
706 let mut pipeline = GangClaimPipeline::new(
707 GangClaimContext {
708 capability: &caps,
709 topology: &topo,
710 reservations: &res,
711 keypair: &leader,
712 node_id: ln,
713 },
714 ReplicaSet::new([1, 2, 3, 4, 5]),
715 vec![1, 2], // minority side of a 3|2 split
716 42,
717 );
718
719 let redex = Redex::new();
720 let wf = WorkflowAdapter::open(&redex, 0x0F10_00D5).await.unwrap();
721 submitted_task(&wf, 1).await;
722
723 let gate = drive_capability_step(&wf, &mut pipeline, 1, &requirement()).unwrap();
724 assert_eq!(
725 gate,
726 StepGate::Waiting,
727 "minority side can't reach Active → step waits"
728 );
729 // Never Active — no compute starts (the Thunderdome guarantee).
730 // And the orphaned reserve is released immediately rather than
731 // left Reserved to TTL-expire, so other claimants aren't blocked
732 // on this island while the step is parked Waiting (review #14).
733 let state = res.query(ReservationQuery::State(0xA0));
734 assert!(
735 state.is_empty() || matches!(state[0].1, ReservationState::Free),
736 "minority reserve released (Free), never left Reserved or leaked Active: {state:?}",
737 );
738 }
739}