myxce/.kiro/specs/resreq-maxtasks-wiring/design.md
Rongsong Shen 799a71ecb1
feat(resreq-maxtasks-wiring): add task-count derivation helper
Adds apply_job_task_count() to overwrite a standalone primary resreq's
minTasks/maxTasks with a job's real requested task count, no-op for
COMPOUND/ALTER nested primaries, with a defensive min>max clamp.
Closes the gap that lets scheduler-allocation's multi-host allocator
ever be reached (bug-1770). Wiring at live ingest call sites follows
in subsequent tasks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYdtrEWWZfKRRGED7raEYg
2026-09-06 03:26:32 +00:00

35 KiB

Overview

This feature closes bug-1770: a job's real requested task count is already correctly threaded from the apiserver all the way to xce_sched_job_t.mintasks/maxtasks, but the parsed resource request the scheduler's allocator dispatch actually reads (xce_primary_resreq_t.minTasks/maxTasks) is unconditionally hardcoded to one task. This makes the scheduler-allocation feature's already-implemented, already-tested, already-soak-validated multi-host allocation logic (parjob_allocator) permanently unreachable from any real job submission today.

Users: cluster users submitting multi-task jobs, and cluster operators relying on existing single-task jobs continuing to behave identically.

Impact: changes the resource request the scheduler dispatches on from an always-1 constant to a real, per-job derived value for standalone primary resource requests, at every point a resreq is parsed from a job's submitted or persisted state. No existing component's ownership, wire format, or public interface changes shape — this is a targeted derivation fix plus the validation needed to trust it.

Goals

  • A standalone primary resource request's minTasks/maxTasks reflect the job's real requested task count, at fresh ingest and at snapshot restore, and remain correct across a subsequent resource-request update (a guarantee this feature relies on an existing structural property for, rather than actively enforcing — see Existing Architecture Analysis).
  • A job requiring more tasks than one host can provide is genuinely evaluated for, and where appropriate allocated across, multiple hosts, through the real scheduler dispatch path.
  • A committed multi-host allocation is proven, end to end, to actually launch and run on real (or virtual) workers.
  • Zero behavioral change for any job with a requested task count of one, and zero behavioral change for compound/alternative resource requests.

Non-Goals

  • Implementing span[ptile=N] resreq-grammar semantics (remains parsed and discarded, unchanged).
  • Wiring task count into primary resource requests nested inside a compound/alternative resource request (bug-1768 already makes their resource checking meaningless regardless of task count).
  • Fixing the snapshot-restore resreq_fp computation gap (bug-1772, tracked separately) or its sibling in make_sched_job() (bug-1774) — the latter is dead code today (confirmed: make_sched_job()'s entire call chain, via sched_update_jobs()/sched_update(), has zero live callers anywhere in this codebase), so this feature does not wire it at all, not merely defer fixing it.
  • Fixing a job's resource-request update never taking effect for scheduling purposes for any field (bug-1773) — this feature's own task-count guarantee across an update holds for that same structural reason, not because this feature actively re-wires anything on update.
  • Modifying the controller's or worker's existing multi-host launch code (mk_launchinfo(), job_start_remote_task(), create_mpi_cmdline()) — this design depends on and validates that code, but does not change it.
  • Fixing the pre-existing per-host live memory-usage reconciliation limitation for multi-host jobs.

Boundary Commitments

This Spec Owns

  • The derivation that sets a standalone primary resource request's minTasks/maxTasks from the job's real requested task count, at every live call site whose parsed resreq actually reaches scheduler dispatch.
  • The defensive invariant that an invalid (minTasks > maxTasks) pair cannot reach allocator dispatch, regardless of upstream validation.
  • A new automated end-to-end test proving a real multi-task job, submitted through the normal submission path, is genuinely allocated across multiple hosts by the scheduler and actually runs to completion.

Out of Boundary

  • span[...] resreq-grammar syntax (parsed, discarded, unchanged).
  • Primary resource requests nested inside a compound/alternative resource request (bug-1768).
  • The snapshot-restore resreq_fp gap (bug-1772).
  • Per-host live memory-usage reconciliation for multi-host jobs (pre-existing, documented limitation).
  • The scheduler's plan-generation/allocation algorithm itself (owned by scheduler-allocation; this spec only makes it reachable).
  • The controller/worker multi-host launch mechanism's internal implementation (pre-existing; this spec validates it, does not modify it).

Allowed Dependencies

  • scheduler-allocation's parjob_allocator/seqjob_allocator/alloc_cache — consumed as-is; this spec must not modify their dispatch logic.
  • The apiserver's existing checkNumProcessors() validation and backend/convert.go's mintasks/maxtasks clamp — relied upon as the primary validation layer; this spec adds a redundant defensive check, not a replacement.
  • The controller/worker's existing multi-host launch code (mk_launchinfo(), job_start_remote_task(), create_mpi_cmdline()) — read-only dependency for the new end-to-end test.

Revalidation Triggers

  • Any change to parse_resreq_primary()'s or parse_resreq()'s signature, or to xce_primary_resreq_t's struct shape.
  • Any change to how a resreq's top-level tag distinguishes a standalone primary from a COMPOUND/ALTER tree.
  • Any change to job_submit_data_t.mintasks/maxtasks or its Go-side validation (checkNumProcessors(), convert.go's clamp).
  • Any change to scheduler-allocation's dispatch gate (sched_make_primary_alloc_()'s maxTasks == 1 branch) or to bug-1768's compound-nested resource-checking gap, since a future fix there would change what "out of boundary" means for compound resource requests.

Architecture

Existing Architecture Analysis

  • parse_resreq_primary(xce_primary_resreq_t*, char*, var, char**) and parse_resreq(xce_resreq_t*, char*, var, char**) (src/lib/resreq.c) take no job-context parameter — they cannot see a job's requested task count. Six existing test call sites invoke the bare production signature directly with no job object in scope, ruling out a signature change as the fix mechanism.
  • Five call sites in total reach this parser in the source tree, but only two are reachable in production today. The scheduler's actual dispatch reads job->schedobj->u.primary->resreq (scheduler.c:315), a pointer set exactly once, by whichever function constructs job->schedobj. Exactly three functions ever do this (job->schedobj = grepped exhaustively): core_build_job() (core_events.c), make_sched_job() (schedobj.c), and the snapshot-restore path (snapshot_load.c). Of these, make_sched_job() is dead code: its only caller, sched_update_jobs(), is only called from inside sched_update()'s switch, and sched_update() (scheduler.c) has zero live callers anywhere in this codebase — confirmed by exhaustive grep and corroborated by the codebase's own pre-existing comments describing it as dead/superseded, and by the identical, already-fixed sibling case in bug-1740. core_build_job() (fresh submission, via the driver-translated XCE_CORE_EVENT_JOBS_PUSH path) and the snapshot_load.c restore path are the two genuinely live schedobj-construction sites, and are this spec's only implementation targets.
  • The remaining two of the five parser call sites are job-update handlers (sched_update_existing_job() in schedobj.c, the JOB_UPDATE handler in core_events.c) that replace job->resreq but never rebuild job->schedobj. sched_update_existing_job() is itself dead code (its sole caller is also inside sched_update()'s dead switch). core_events.c's JOB_UPDATE handler IS live (reached via the driver-translated path, confirmed by tracing main.c → driver.c's event translation → xce_core_push_events()) — but it never affects scheduling regardless, since job->schedobj isn't rebuilt there either, and no live feature in this codebase today ever actually changes an existing job's submit->resreq (grepped the whole controller: nothing mutates it post-submission). Applying this feature's helper at either update call site would be dead code with zero observable effect. Requirement 1.4's guarantee (task count unaffected by an update) already holds today for this structural reason, independent of this feature. The update path never taking effect for scheduling at all, for any field, is a real, separately-tracked, pre-existing defect (bug-1773).
  • "Snapshot restore" (Requirement 1.3/1.4's snapshot_load.c site) is live only via the simulation/replay driver, not live-cluster crash recovery. xce_sched_core_load_snapshot()'s only real caller anywhere in src/ is sim.c's sim_locate_and_load_snapshot(), reachable only through main.c's --sim FILE offline-replay flag; normal-mode main.c has no snapshot-load call or flag at all. A live scheduler restart (including post-failover, e.g. run-ha-soak.sh's own scheduler-restart step) starts with empty in-memory state and rejoins via on_scheduler_join() → the same live core_build_job() path fresh ingest uses — it never touches snapshot_load.c. This means the HA soak (Requirement 5.2) provides zero exercise of this call site; Requirement 5.6 validates it separately. The fix is still worth wiring here — --sim mode drives the identical parjob_allocator/seqjob_allocator dispatch per this project's own driver-equivalence invariant, so a saved snapshot's task count needs to be correct for simulation/tuning fidelity — but this is a genuinely different (and narrower) liveness scope than "post-restart/post-failover" phrasing might suggest, and bug-1772's own description has been corrected accordingly.
  • A single job's resreq parse tree can contain more than one primary when COMPOUND/ALTER syntax is used; parse_resreq_primary() already runs once per primary node in the tree, uniformly. Only the job's own top-level standalone primary (tag XCE_RESREQ_PRIMARY) is in this spec's boundary — a nested primary inside COMPOUND/ALTER must be left exactly as today.
  • job_submit_data_t.mintasks/maxtasks (the job-level source of truth) is already correctly threaded end-to-end from the apiserver's num_processors/maxnum_processors fields, validated twice (checkNumProcessors(), convert.go's clamp) before reaching the C layer, and already correctly round-tripped through the WAL/snapshot wire format. This spec's boundary starts exactly where that correct value stops propagating.

Project Invariant Cross-Check

core_events.c (home of core_build_job(), one of this spec's real call sites) is part of sched_core_isolation_lib — the "one core, two consumers" layer shared between the real driver (driver.c) and the simulation driver (sim_loop.c), per this project's own scheduler-allocation task 5.2 lesson (a fix applied only to the real-driver path there caused a real driver/simulation divergence that corrupted xce_sched_sim_driver_t state). Confirmed, not merely assumed: driver.c and sim_loop.c both build XCE_CORE_EVENT_JOBS_PUSH events and call the same shared xce_core_push_events() (core_events.c), whose JOBS_PUSH case calls core_build_job() directly — no parallel, sim-specific job constructor exists anywhere. Wiring the fix in core_build_job() therefore applies identically to real and simulated job ingest with no divergence risk. test_maxtasks_wiring_ingest.c still includes a regression test exercising this path through sim_loop.c's own entry point, to keep this confirmed property under test rather than resting on a one-time design-time check.

Architecture Pattern & Boundary Map

graph TB
    ApiServer[Apiserver job submission] --> SubmitData[job_submit_data_t mintasks maxtasks]
    SubmitData --> Ingest[Scheduler ingest call sites]
    Ingest --> ParseResreq[parse_resreq existing parser]
    ParseResreq --> Helper[apply_job_task_count new helper]
    Helper --> Primary[xce_primary_resreq_t minTasks maxTasks]
    Primary --> Dispatch[Scheduler dispatch existing]
    Dispatch --> SeqJob[seqjob_allocator existing]
    Dispatch --> ParJob[parjob_allocator existing]
    ParJob --> Commit[Multi host allocation committed]
    Commit --> Controller[Controller mk_launchinfo existing]
    Controller --> Worker[Worker job_start_remote_task existing]
    Worker --> Running[Job reaches RUN then DONE]

Architecture Integration:

  • Selected pattern: a single new helper function, invoked by each of the two ingest call sites whose result actually reaches dispatch (core_build_job() and the snapshot-restore path), immediately after its existing parse_resreq()/parse_resreq_primary() call returns.
  • Domain boundaries: src/lib/resreq.c owns the new helper and its tag-gating/clamp logic; each scheduler-side call site owns invoking it with whichever job-context task count is already in scope there.
  • Existing patterns preserved: this mirrors the codebase's own existing idiom of copying a field from submit-> onto a target struct immediately after that struct is built — already done identically for job->mintasks/maxtasks itself at both of these sites.
  • New component rationale: one shared helper avoids duplicating the COMPOUND/ALTER tag-check twice and gives one independently unit-testable unit for the only real logic this fix introduces.
  • Steering compliance: no new WAL/snapshot field is introduced (see Data Models); resreq.c is a shared C library untouched by the scheduler-core isolation boundary; the fix does not alter scheduler-allocation's dispatch or allocation logic, only what reaches it.

Technology Stack

Layer Choice / Version Role in Feature Notes
Shared C library Existing C (gnu17), src/lib/resreq.c New apply_job_task_count() helper No new dependency
Scheduler (C) Existing C, src/scheduler/src/ Two live ingest call-site wiring core_events.c, snapshot_load.c
Test infrastructure Existing xce_test debug CLI + xcecd_virtual New end-to-end test Extends the pattern already used by xce-test-resources/xce-test-ha

File Structure Plan

Modified Files

  • src/lib/resreq.c — add apply_job_task_count(); parse_resreq_primary()'s existing minTasks = 1; maxTasks = 1; hardcode is left in place as the correct default for a freshly-parsed resreq before the helper is applied, and as the permanent behavior for nested COMPOUND/ALTER primaries the helper deliberately skips.
  • src/include/resreq.h — declare apply_job_task_count().
  • src/scheduler/src/core_events.c — call the helper after core_build_job()'s parse_resreq() call, using j->submit->mintasks/maxtasks. core_events.c's JOB_UPDATE handler is NOT modified — it's live, but job->schedobj (what dispatch actually reads) is never rebuilt by an update, so applying the helper to the freshly-parsed job->resreq there would be dead code (see Existing Architecture Analysis and bug-1773).
  • src/scheduler/src/snapshot_load.c — call the helper after the restore path's parse_resreq() call, using job->mintasks/maxtasks (already restored earlier in the same function, before this call).
  • src/scheduler/src/schedobj.c is NOT modified — make_sched_job() and sched_update_existing_job() are both dead code (confirmed: unreachable via sched_update(), which has zero live callers), so wiring either would have no production effect.
  • src/scheduler/testsuite/test_parjob_e2e_multislot.c — remove the PATCH_MAXTASKS_FOR_BUG_1770 workaround now that real ingest correctly wires maxTasks, converting it to drive the same scenario through unmodified real ingest.

New Files

  • src/lib/testsuite/test_resreq_task_count.c — unit tests for apply_job_task_count() in isolation.
  • src/scheduler/testsuite/test_maxtasks_wiring_ingest.c — white-box tests confirming each of the two wired ingest call sites applies the wiring correctly (including the fresh-ingest-vs-snapshot-restore comparative assertion for Requirement 5.6), plus the structural regression test for Requirement 1.4.
  • src/worker/testsuite/test_worker_launch_type_dispatch.c — white-box test targeting job_start_remote_task()'s launch-type gate (controller_connector.c) first: confirms it correctly no-ops (defers to MPI's own remote-launch mechanism) for an MPI launch type, and correctly fans the job out via XCE's own per-host dispatch for the default launch type — this is the actual decision point Requirement 4.4 depends on, and today has zero test coverage of any kind. Secondarily confirms job_start_local_task()'s branch into create_mpi_cmdline() builds the right host list and command. Neither requires a real MPI runtime installed in the test environment (Requirement 4.4).
  • .claude/skills/xce-test-multihost/ — new skill (SKILL.md + driver script), following xce-test-resources's single-controller-node pattern: deploys one controller/scheduler plus two named xcecd_virtual workers, submits a real multi-task job via jobsub, and verifies genuine multi-host RUN-then-DONE execution on both hosts, plus one array-job variant.

System Flows

sequenceDiagram
    participant User
    participant Api as Apiserver
    participant Sched as Scheduler ingest
    participant Alloc as Scheduler dispatch
    participant Ctrl as Controller
    participant W1 as Worker host A
    participant W2 as Worker host B

    User->>Api: submit job with -n 4,6
    Api->>Api: checkNumProcessors validate
    Api->>Sched: job_submit_data_t mintasks maxtasks
    Sched->>Sched: parse_resreq existing
    Sched->>Sched: apply_job_task_count new
    Sched->>Alloc: standalone primary minTasks maxTasks real value
    Alloc->>Alloc: maxTasks greater than one route to parjob_allocator
    Alloc->>Ctrl: commit multi host allocation
    Ctrl->>W1: launch info host A share
    Ctrl->>W2: launch info host B share
    W1-->>Ctrl: task started
    W2-->>Ctrl: task started
    Ctrl-->>Sched: job running
    Sched-->>User: job reaches DONE

Key decision visible in the diagram: apply_job_task_count() sits strictly between the existing parser and the existing dispatch gate — nothing downstream of it (allocator, controller, worker) changes; nothing upstream of it (apiserver validation, job_submit_data_t wiring) changes.

Requirements Traceability

Requirement Summary Components Interfaces Flows
1.1 Real task count reaches the standalone primary TaskCountWiring, IngestTaskCountWiring apply_job_task_count Sequence diagram, steps 1-6
1.2 Default (one task) behavior unchanged TaskCountWiring apply_job_task_count —
1.3 Jobs restored via the simulation/replay driver's snapshot loading derive the same value as fresh ingest IngestTaskCountWiring (snapshot_load.c site) apply_job_task_count —
1.4 A resource-request update does not change the job's dispatched task count test_maxtasks_wiring_ingest.c (structural regression test; no production change needed — see Existing Architecture Analysis and bug-1773) — —
1.5 Invalid pair cannot reach allocator dispatch TaskCountWiring apply_job_task_count (clamp) —
2.1 Candidate plans evaluated for maxTasks > 1 Existing scheduler-allocation dispatch (unmodified) — Sequence diagram, step 7
2.2 Multi-host allocation committed when satisfiable Existing parjob_allocator (unmodified) — Sequence diagram, step 8
2.3 maxTasks == 1 unaffected Existing seqjob_allocator (unmodified) — —
3.1 Identical single-task results before/after test_maxtasks_wiring_ingest.c — —
3.2 No cluster-wide behavior change while maxTasks always 1 test_maxtasks_wiring_ingest.c — —
3.3 Compound-nested primaries unaffected TaskCountWiring's tag gate apply_job_task_count —
4.1 Controller ensures every host has launch info Existing controller (unmodified), validated by xce-test-multihost — Sequence diagram, steps 8-10
4.2 Worker starts tasks on every allocated host Existing worker (unmodified), validated by xce-test-multihost — Sequence diagram, steps 9-12
4.3 Scheduler reports running via existing mechanism Existing scheduler (unmodified), validated by xce-test-multihost — Sequence diagram, step 13
4.4 Non-default launch type honored WorkerLaunchTypeCoverage — —
5.1 Existing suites pass unmodified Full regression run — —
5.2 HA soak shows no regression (does not exercise the simulation/replay driver's snapshot-loading path — see 5.6) Existing xce-test-ha (unmodified) — —
5.3 Real multi-task job proven end to end xce-test-multihost — Full sequence diagram
5.4 No cache collision across differing task counts, scoped to the ingest path with a correct fingerprint test_resreq_task_count.c / scheduler-allocation's alloc_cache tests (fresh-ingest, core_build_job(), only; snapshot-restore is the documented bug-1772 exception — it never computes resreq_fp, confirmed live and unfixed by this feature; make_sched_job()'s path is dead code and out of consideration entirely) — —
5.5 Multi-task array job proven end to end xce-test-multihost (array-job scenario) — Full sequence diagram, array variant
5.6 Simulation/replay-loaded snapshot derives the same task count as fresh submission test_maxtasks_wiring_ingest.c (snapshot-restore site, comparative assertion against fresh-ingest for equivalent input) apply_job_task_count —

Components and Interfaces

Component Domain/Layer Intent Req Coverage Key Dependencies (P0/P1) Contracts
TaskCountWiring Shared C library (src/lib) Apply a job's real task count onto its resreq's standalone primary 1.1, 1.2, 1.5, 3.3 None (pure function) Service
IngestTaskCountWiring Scheduler (src/scheduler/src) Invoke the helper at every ingest call site whose result actually reaches dispatch 1.1, 1.3 TaskCountWiring (P0) Service
xce-test-multihost Test infrastructure Prove real multi-host allocation and execution end to end 4.1, 4.2, 4.3, 5.3, 5.5 Existing controller/worker/scheduler binaries (P0) Batch
WorkerLaunchTypeCoverage Worker (src/worker/src) Close a pre-existing, zero-coverage gap in the worker's launch-type dispatch decision 4.4 controller_connector.c (P0) Service

Shared C Library (src/lib)

TaskCountWiring

Field Detail
Intent Overwrite minTasks/maxTasks on a resreq's own top-level primary with the job's real requested task count, leaving nested/compound primaries untouched.
Requirements 1.1, 1.2, 1.5, 3.3

Responsibilities & Constraints

  • Sole owner of the tag-gate distinguishing a standalone primary (XCE_RESREQ_PRIMARY) from a COMPOUND/ALTER tree.
  • Sole owner of the defensive minTasks <= maxTasks clamp.
  • Never allocates memory, never mutates select/rusages/rusage_hash/span/same/affinity.

Dependencies

  • Inbound: IngestTaskCountWiring — the two live scheduler-side call sites (P0).

Contracts: Service [x]

Service Interface
/*
 * Overwrite minTasks/maxTasks on resreq's own top-level primary with the
 * job's real requested task count. No-op if resreq is NULL or if its
 * top-level tag is not XCE_RESREQ_PRIMARY -- a COMPOUND or ALTER resreq's
 * nested primaries are left exactly as parse_resreq_primary() set them.
 */
void apply_job_task_count(xce_resreq_t* resreq,
                           uint32_t mintasks,
                           uint32_t maxtasks);
  • Preconditions: resreq has already been successfully returned by parse_resreq()/parse_resreq_primary().
  • Postconditions: if resreq->tag == XCE_RESREQ_PRIMARY, its primary->minTasks/primary->maxTasks equal mintasks/maxtasks, with maxtasks raised to mintasks if mintasks > maxtasks (mirroring convert.go's clamp direction exactly). Otherwise resreq is unchanged.
  • Invariants: idempotent — calling it twice with the same inputs produces the same result.
  • Type note: parameters are uint32_t, matching job_submit_data_t.mintasks/maxtasks and xce_sched_job_t.mintasks/maxtasks (both uint32_t, confirmed in types.h) — the values every call site actually holds. xce_primary_resreq_t.minTasks/maxTasks (the assignment target) are plain int (resreq.h:239-240); this function performs a deliberate, documented narrowing conversion at the write, not a silent one. Safe for realistic task counts (well under INT_MAX), and no existing code path today assigns across this int/uint32_t boundary, so this is a new, one-time, explicitly acknowledged conversion rather than a pattern already relied upon elsewhere.

Implementation Notes

  • Integration: called once, immediately after each of the two live ingest parse_resreq()/parse_resreq_primary() call sites returns successfully.
  • Validation: test_resreq_task_count.c covers the standalone-primary case, the COMPOUND/ALTER no-op case, and the defensive clamp case.
  • Risks: none identified — the "undiscovered sixth call site" risk originally recorded in research.md has since been independently re-verified twice (exhaustive grep, no additional caller found either time).

Scheduler (src/scheduler/src)

IngestTaskCountWiring

Field Detail
Intent Invoke apply_job_task_count() at every ingest call site whose result actually reaches dispatch, using whichever job-context task count is already in scope there.
Requirements 1.1, 1.3

Responsibilities & Constraints

  • Two call sites, each a single new line: core_build_job() (core_events.c); the restore path (snapshot_load.c).
  • schedobj.c is untouched entirely: make_sched_job() and sched_update_existing_job() are both dead code (unreachable via sched_update(), which has zero live callers).
  • core_events.c's JOB_UPDATE handler is deliberately NOT touched either — it's live, but rebuilds job->resreq and never job->schedobj, so the actually-dispatched object never sees the update regardless of whether this helper runs there (bug-1773).

Dependencies

  • Outbound: TaskCountWiring (P0).

Contracts: Service [x]

Service Interface

No new interface — this is two one-line call-site additions to existing functions, not a new function.

Implementation Notes

  • Integration: ordering matters only in that the call must happen after parse_resreq()/parse_resreq_primary() returns and before any downstream code (e.g. core_build_primary_schedobj()'s resreq_fp computation) reads minTasks/maxTasks — confirmed both wired sites already satisfy this ordering naturally.
  • Validation: test_maxtasks_wiring_ingest.c white-box tests each site directly.
  • Risks: none identified; each site's change is independently reviewable and revertable.

Worker (src/worker/src)

WorkerLaunchTypeCoverage

Field Detail
Intent Close a pre-existing, zero-coverage gap in the worker's launch-type dispatch decision, adjacent to (but not caused by) this feature's own fix.
Requirements 4.4

Responsibilities & Constraints

  • Does not modify controller_connector.c — pure test addition against existing, unmodified production code.
  • Primary target: job_start_remote_task()'s launch-type gate (correct no-op for an MPI launch type, deferring to MPI's own remote-launch mechanism; correct fan-out via XCE's per-host dispatch for the default type) — the real decision point Requirement 4.4 depends on, with no existing coverage of any kind.
  • Secondary target: job_start_local_task()'s branch into create_mpi_cmdline(), confirming the built host list and command are correct.

Dependencies

  • External: controller_connector.c's existing, unmodified launch-dispatch functions (P0).

Contracts: Service [x]

Service Interface

No new interface — a white-box test file (#include-ing controller_connector.c directly, the same technique already established by test_controller_connector.c) exercising existing static functions.

Implementation Notes

  • Integration: no production code touched.
  • Validation: test_worker_launch_type_dispatch.c.
  • Risks: none — this is a pure test addition against unmodified code, adjacent-gap discovery rather than a fix this feature's own change requires.

Test Infrastructure

xce-test-multihost

Field Detail
Intent Prove a real multi-task job is genuinely allocated across multiple hosts by the scheduler's own decision, and actually runs to completion.
Requirements 4.1, 4.2, 4.3, 5.3, 5.5

Responsibilities & Constraints

  • Deploys a single controller/scheduler node (no HA needed for this proof) plus two named xcecd_virtual instances, each sized so neither alone can satisfy the test job's requested task count.
  • Drives submission via plain jobsub, never xce_test's jobrun manual-allocation bypass — the scheduler's own decision to span hosts is what this test exists to prove.
  • Follows the pre-flight zombie-process/port check convention already established for xce-test-ha.

Dependencies

  • External: real xce_controller, xce_scheduler, xcecd_virtual binaries (P0, unmodified by this spec).

Contracts: Batch [x]

Batch / Job Contract
  • Trigger: manual invocation (bash .claude/skills/xce-test-multihost/run.sh), same convention as xce-test-ha/xce-test-resources.
  • Input / validation: none beyond the built binaries; the script deploys its own fixture cluster.
  • Output / destination: pass/fail verdict line matching the existing skills' [xce-test-*] PASS/FAIL convention, plus per-host worker log confirmation.
  • Idempotency & recovery: each run deploys a fresh, uniquely-named cluster instance; no shared state across runs.

Implementation Notes

  • Integration: reuses run-ha-soak.sh's submit_worker_job()/verify_worker_rejoin_resync() polling idiom (real job, poll jobinfo for RUN then DONE, grep each worker's own log) rather than inventing a new verification mechanism.
  • Validation: two scenarios — a plain multi-task job, and a multi-task job submitted as part of an array job (Requirement 5.5).
  • Risks: soak-style process/port flakiness, mitigated by the same pre-flight check xce-test-ha already established.

Data Models

Domain Model

No new entities or fields. xce_primary_resreq_t.minTasks/.maxTasks (existing fields) transition from a compile-time constant to a per-job derived value.

Persistence / Save-Restore Completeness

Per persistence.md's "Snapshot Save/Load Field-Completeness" section: minTasks/maxTasks are not independently persisted in the WAL or snapshot wire format. snapshot_wire.c persists job_submit_data_t.mintasks/maxtasks (the job-level source of truth, already correctly round-tripped, confirmed unchanged by this design); snapshot_load.c restores that value and then calls the same parse_resreq() chain fresh — the exact derivation this spec fixes. There is no field being newly persisted, so there is no new round-trip gap to enumerate: fixing the shared derivation fixes fresh-ingest and snapshot-restored jobs identically, by construction.

Error Handling

Error Categories and Responses

Invalid task-count pair (minTasks > maxTasks reaching apply_job_task_count()): defensive-only, since the apiserver already guarantees this invariant twice before any job reaches the C layer. maxtasks is raised to mintasks, mirroring convert.go's clamp direction exactly — no new job-rejecting error path is introduced.

mintasks == 0: not separately validated by this feature, since it's unreachable from either live call site (both convert.go and xce_test's jobsub independently clamp mintasks to at least 1 before a job ever reaches the C layer). A genuinely-zero pair (only reachable via a hand-crafted --sim snapshot bypassing both guards) degrades gracefully through scheduler-allocation's own pre-existing maxTasks <= 1 bail in parjob_allocator.c — the job pends as unsatisfiable rather than reaching an inconsistent dispatch state.

Testing Strategy

  • Unit Tests (test_resreq_task_count.c): standalone primary receives the applied task count; COMPOUND/ALTER-tagged resreq is left untouched; minTasks > maxTasks is defensively clamped.
  • Integration Tests (test_maxtasks_wiring_ingest.c): each of the two live ingest call sites applies the wiring correctly (fresh ingest via core_build_job(), and the simulation/replay driver's snapshot-loading path via snapshot_load.c); the snapshot-loading test asserts the same task count is derived as an equivalent fresh-ingest input would produce, satisfying Requirement 5.6's comparative claim directly; confirms sim_loop.c's job-ingest path reaches the same core_build_job()/core_events.c call site as the real driver, so simulated and real job ingest apply the fix identically with no driver/simulation divergence; a structural regression test drives a real resource-request update (Requirement 1.4) and confirms the job's dispatched task count is unchanged, proving the guarantee holds via job->schedobj never being rebuilt (bug-1773) rather than via any new code this feature adds. test_parjob_e2e_multislot.c is updated to drive its multi-host scenario through unmodified real ingest, closing the loop on the bug it originally characterized.
  • E2E Tests (xce-test-multihost): a real multi-task job submitted via plain jobsub against two real workers is genuinely split across both hosts by the scheduler's own decision and reaches DONE on both (4.1-4.3); a second scenario repeats this for a multi-task array job (5.5).
  • Non-default launch type (test_worker_launch_type_dispatch.c, white-box): confirms job_start_remote_task()'s launch-type gate correctly no-ops for an MPI launch type and correctly fans out via XCE's own per-host dispatch for the default type — the actual decision point Requirement 4.4 depends on, currently with zero coverage of any kind — and confirms job_start_local_task()'s branch into create_mpi_cmdline() builds the correct host list and command (4.4), without requiring a real MPI runtime in the test environment. The E2E harness above only exercises the default launch path, whose availability is guaranteed.
  • Regression: full existing scheduler/controller/worker test suites re-run unmodified (5.1); existing xce-test-ha sequential and chaotic soak re-run unmodified as non-regression evidence (5.2); a cache-key collision test (reusing scheduler-allocation's alloc_cache test conventions) confirms two otherwise-identical jobs with different task counts don't collide in the unsatisfiable-resreq cache, for the fresh-ingest path (core_build_job()) where resreq_fp is correctly computed (5.4) — the snapshot-restore path is explicitly excluded from this test per the documented, pre-existing bug-1772 exception (it never computes resreq_fp, confirmed live and unfixed by this feature), not silently skipped; make_sched_job()'s path is dead code and not a live production concern at all.