Skip to main content

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}