Feeds a reconciled job's decision (task 2.3) through the exact same recording/print/REPL-check calls the existing decision-drain loop uses for a freshly-drained decision, via make_dispatch_decision() -- the same DISPATCH-construction idiom the live allocator path already uses. Dedup (Requirement 3.3) needs no new mechanism: the job->accounts gate from tasks 2.2/2.3 already prevents double-reporting, now proven at the reporting layer too (zero recorded decisions / no console output for an already-accounted job). Required extending sim_reconcile_unaccounted_jobs()'s signature with a third parameter, a nullable recording-context handle -- design.md's Service Interface updated to match, with the confirmed reasoning: the handle only exists as a stack-local inside the round loop, and task 3's still-unwired call site sits in that same scope, so this is necessary plumbing, not scope creep. 46/47 scheduler tests pass; the 1 failure is pre-existing bug-1560 (unrelated). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
32 KiB
Design Document
Overview
Purpose: Restore the scheduler's documented normal-mode/simulation-mode driver equivalence invariant (.kiro/steering/scheduler-driver-equivalence.md) for jobs already resolved before a given point in a replay — both their resource-accounting state and their decision visibility.
Users: XCE scheduler engineers and operators debugging via xce_scheduler -i -s <replay> or batch --sim, and any future scheduler spec (scheduler-allocation, scheduler-preemption) relying on simulation mode reflecting real scheduling behavior.
Impact: Changes sim_loop.c's round-boundary handling (replacing the monolithic xce_core_go() with the split calls driver.c already uses, plus a new reconciliation step that also compacts decoded_session->decisions as it consumes entries — see Recorded-Decision Reconciliation's Implementation Notes), and the signature of five accounting functions currently in resource.c (relocated to operate on xce_sched_core_t*). sim_verify.c's comparison predicates (sim_decision_eq() and its helpers) need no change — confirmed during design validation that sim_loop.c's own array bookkeeping is sufficient to keep the existing positional comparison correct. No change to core.c/core_events.c's event-application logic, to scheduling/allocation policy, or to REPL output formatting.
Goals
- Resource-in-use accounting (
resInUse/sharedInUse) reflects every currently-running job in simulation mode, matching normal mode (Requirement 1). - Simulation-mode capacity checks under contention produce the same outcome normal mode would (Requirement 2) — a direct consequence of Goal 1, since the capacity-check code path is unchanged and already reads this data correctly.
- Every job's decision (including ones resolved before this replay run started tracking them live) is visible through the existing decision-reporting channel (Requirement 3).
- Missing recorded data degrades gracefully, never fabricates or crashes (Requirement 4).
- Zero regression to REPL-vs-batch-sim equivalence,
--sim-verify, or normal-mode behavior (Requirement 5).
Non-Goals
- Any change to REPL console output formatting (
scheduler-sim-repl-verbosity's boundary). - Any new inspectable data or command (
scheduler-sim-diagnostics's boundary). - Any change to scheduling/allocation policy or picker plugin behavior.
- Reopening
scheduler-core.
Boundary Commitments
This Spec Owns
- The signatures of
sched_account_jobs()/sched_charge_account()/sched_uncharge_account()/sched_update_account()/sched_update_res_accounts()/sched_commit_job_alloc()(currentlyresource.c,xce_scheduler_t*-coupled) and their relocation toxce_sched_core_t*. sim_loop.c's round-boundary sequence (thexce_core_go()replacement and the new reconciliation step).- The logic that reconstructs
job->allocs/job->accountsfrom a recorded decision for a RUN-state job that has neither, and feeds that reconstructed decision through the existing decision-reporting channel.
Out of Boundary
driver.c's normal-mode round logic, beyond updating its one call site to the relocated accounting function signature.core.c/core_events.c's event-application logic (JOB_PUSH/JOB_UPDATE/JOB_STATE_CHANGE handling) — unchanged.repl_break.c/repl.c/repl_info.c— unchanged; they consume whatever the decision-reporting channel produces, without needing to know whether a given decision was freshly computed or reconstructed.--sim-verify's actual pass/fail semantics — this spec must not introduce a false mismatch, but does not redesign verification.
Allowed Dependencies
sim_verify.c's existingsim_verify_job_alloc_eq()/sim_verify_host_alloc_eq()/sim_verify_alloc_eq()field shapes as the reference for what a reconstructedxce_job_alloc_tmust contain — not necessarily calling these functions, but matching the payload shape they already validate.decoded_session->decisions, already decoded every round by existingsim_loop.ccode — no new decode path.- The
sched_resolve_job_picker()relocation (task 4.3) as the precedent for the accounting-function signature change.
Revalidation Triggers
- Any change to
xce_job_alloc_t/xce_host_alloc_t/xce_alloc_t's wire shape (controller.capnp) must be re-checked against the reconstruction logic here. - Any change to how
--sim-verifycompares decisions must be re-checked against this spec's reconciliation step, and vice versa. - If
scheduler-sim-diagnosticslater adds a "why did this job dispatch" trace, it can build on the now-correctjob->accountsthis spec establishes rather than re-deriving it. - When
scheduler-preemptionlands (currently requirements-generated, not yet designed —driver.h:96presently states "no live allocator path produces this decision type"): Requirement 3.1 here covers "dispatched, pended, or preempted" jobs, but Recorded-Decision Reconciliation's trigger condition (RUN-state +job->accounts == NULL) only ever matches a recorded DISPATCH entry — a preempted job leaves RUN-state at the moment of preemption, so it cannot trigger this same mechanism, and PEND doesn't need reconciliation because the live picker already re-derives it fresh every round. This is not a gap today (no code path emitsXCE_DECISION_PREEMPTyet, so no recorded replay can contain one), but oncescheduler-preemptionstarts producing real PREEMPT decisions, revisit whether this spec's mechanism needs a second trigger condition for "job left RUN-state via preemption within this round, no reconciled visibility yet" or whetherscheduler-preemption's own design should own that instead.
Architecture
Existing Architecture Analysis
driver.c'sxce_sched_driver_run_normal_mode_round()is the reference implementation for the exact sequence this spec ports tosim_loop.c:xce_core_begin_session()→ driver-specific interleaved bookkeeping (plugin_begin_session(),sched_account_jobs()) →xce_core_signal_go()→ decision-drain loop (cbuf_get(&core->decisions), recording each viasched_record_session_add_decision()when recording, thencore->on_decision()).sim_loop.ccurrently calls the older monolithicxce_core_go()(itself now justxce_core_begin_session()+xce_core_signal_go()back-to-back, percore_session.c) with no interleaving room, and decodesdecoded_session->decisionsevery round but only consumes it undersim->verify.- The accounting functions being relocated —
sched_account_jobs()(scheduler.c) and its calleessched_charge_account()/sched_uncharge_account()/sched_update_account()/sched_update_res_accounts()/sched_commit_job_alloc()(resource.c) — already only touchsched->corein their bodies despite theirxce_scheduler_t*signature. Confirmed by reading every function in this call chain, and independently re-confirmed withgrep "sched->" resource.c | grep -v "sched->core"returning zero matches across that whole file (covers the fiveresource.cfunctions;sched_account_jobs()itself was separately read in full).
Architecture Pattern & Boundary Map
flowchart TD
BeginSession[xce_core_begin_session] --> Reconcile[Reconcile RUN jobs with no accounts]
Reconcile --> Lookup{Recorded decision found in decoded_session}
Lookup -->|Yes| Rebuild[Rebuild job allocs and accounts from recorded decision]
Lookup -->|No| Unavailable[Report usage unavailable, continue]
Rebuild --> Compact[Free consumed entry, compact decoded_session.decisions]
Compact --> Report[Feed decision through existing reporting channel]
Report --> AccountJobs[sched_account_jobs on core]
Unavailable --> AccountJobs
AccountJobs --> SignalGo[xce_core_signal_go]
SignalGo --> Picker[Live picker and allocator run for still-pending jobs]
Picker --> DrainLoop[Decision drain loop -- same reporting channel for fresh decisions]
Architecture Integration:
- Selected pattern: extend the existing round-boundary interleaving slot (the same one
driver.calready uses for its own bookkeeping) with one additional step, rather than a new hook or a second round-processing pass. - Domain/feature boundaries: reconciliation logic lives in
sim_loop.conly; the relocated accounting functions live inresource.c/core.hand are shared by both drivers;driver.cgets a one-line call-site update. - Existing patterns preserved: the
job->accounts == NULLchecksched_account_jobs()already performs is reused as the reconciliation trigger and the double-application guard — no new state. - New components rationale: reconciliation must run before
sched_account_jobs()(so the same round's re-charge picks up newly-reconstructed jobs) and beforexce_core_signal_go()(so the live picker/allocator's capacity checks for still-pending jobs see correct usage this round, satisfying Requirement 2). - Steering compliance:
sim_loop.cremains at its documented interleaving-slot discipline (no new call sites added to the core's own API surface);resource.c's functions gain no new behavior, only a new valid signature.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Scheduler daemon (C, gnu17) | existing src/scheduler/ toolchain |
All logic in existing driver/core/verify translation units | No new dependency; matches tech.md |
File Structure Plan
Modified Files
src/scheduler/src/resource.c— relocatesched_charge_account(),sched_uncharge_account(),sched_update_account(),sched_update_res_accounts(),sched_commit_job_alloc()fromxce_scheduler_t*toxce_sched_core_t*parameters (body unchanged — every reference is alreadysched->core.*).src/scheduler/include/scheduler.h— remove/update the now-relocated declarations.src/scheduler/include/core.h— add the relocated declarations, mirroringsched_resolve_job_picker()'s existing placement and doc-comment precedent.src/scheduler/src/scheduler.c— relocatesched_account_jobs()itself the same way (it is defined here, not inresource.c; body likewise unchanged — every reference is alreadysched->core.*); update this file's own two call sites for the new signatures:sched_commit_decision()'s call tosched_commit_job_alloc()(pass&(sched->core)) andsched_begin_session()'s call tosched_account_jobs()likewise.src/scheduler/src/driver.c— updatexce_sched_driver_run_normal_mode_round()'ssched_account_jobs(sched)call tosched_account_jobs(&(sched->core)).src/scheduler/src/sim_loop.c— replace thexce_core_go(&(sim->core))call withxce_core_begin_session()→ reconciliation step (new static function(s)) →sched_account_jobs(&(sim->core))→xce_core_signal_go(); reconciled decisions feed through the same decision-reporting call the existing drain loop uses, and each consumed recorded decision is removed/compacted fromdecoded_session->decisionsin the same pass so the existing--sim-verifypositional walk (diagainstcbuf_get(&(sim->core.decisions), ...), unchanged) stays correctly aligned.src/scheduler/src/sim_verify.c— no change (confirmed during design validation; seesim_loop.centry above and the Recorded-Decision Reconciliation component's Implementation Notes).
New/Modified Test Files
src/scheduler/testsuite/test_driver_session_ordering.c(or a newtest_sim_session_ordering.cmirroring it) — provessim_loop.cnow upholds the same "picker never observes zeroed-but-not-yet-resummed accounting" propertydriver.c's own test already proves.- A new resource-contention fixture (multiple jobs competing for insufficient capacity) added to the scheduler's simulation testsuite, per
scheduler-driver-equivalence.md's testing discipline — required because existing fixtures evidently don't exercise this path. src/scheduler/testsuite/test_sim_equivalence.c(existing, decision-identity coverage) — extended with a case where a job is already resolved before the replay's live picker runs, verifying decision AND accounting-state identity, not just decision identity.
System Flows
See the Architecture Pattern & Boundary Map diagram above — it is the single non-trivial flow this feature introduces (the round-boundary reconciliation decision tree). No sequence diagram is needed beyond it; the decision-drain loop's own flow is unchanged.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1, 1.2, 1.3, 1.4 | Accurate resource-usage reporting | Core-Level Resource Accounting, Sim Driver Round-Boundary Accounting | relocated sched_account_jobs |
Round-boundary flow |
| 2.1, 2.2 | Correct capacity awareness under contention | Core-Level Resource Accounting (consequence of 1.x being correct) | sched_check_host_satisfy_/sched_check_shared_satisfy_ (unchanged, already reads this data) |
Round-boundary flow |
| 3.1, 3.2, 3.3 | Decision visibility for pre-resolved jobs | Recorded-Decision Reconciliation | decision-reporting channel | Round-boundary flow |
| 4.1, 4.2 | Graceful degradation | Recorded-Decision Reconciliation | n/a (error path) | Round-boundary flow |
| 5.1, 5.2, 5.3 | No regression | all components | n/a | n/a |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| Core-Level Resource Accounting | Scheduler core / resource.c, core.h | Re-derive resource-in-use state from currently-running jobs' known allocations, callable by either driver | 1.1-1.4, 2.1-2.2 | xce_sched_core_t (P0) |
Service |
| Sim Driver Round-Boundary Accounting | Sim driver / sim_loop.c | Call the core-level accounting function at the correct point in the round boundary, mirroring the normal-mode driver | 1.3, 1.4, 5.3 | Core-Level Resource Accounting (P0) | State |
| Recorded-Decision Reconciliation | Sim driver / sim_loop.c | Reconstruct allocation/accounting and decision visibility for a RUN-state job with no accounts, from this round's recorded decisions | 3.1-3.3, 4.1-4.2 | decoded_session->decisions (P0), decision-reporting channel (P0) |
Service, State |
Scheduler Core (resource.c / core.h)
Core-Level Resource Accounting
| Field | Detail |
|---|---|
| Intent | Re-derive resInUse/sharedInUse from every currently-running job's known resource allocation, usable identically by both drivers |
| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 2.2 |
Responsibilities & Constraints
- Body unchanged from today's
sched_account_jobs()/callees — this is a signature relocation (xce_scheduler_t*→xce_sched_core_t*), not a logic change, since every reference in the existing bodies is alreadysched->core.*. - Both drivers call it at the identical relative position: after
xce_core_begin_session()'s resval reset, beforexce_core_signal_go()unblocks the picker/allocator. - Requirement 2 (capacity awareness) is satisfied as a direct consequence —
sched_check_host_satisfy_()/sched_check_shared_satisfy_()already readresInUse/sharedInUseunconditionally; once those fields are correctly populated in simulation mode, the existing (unmodified) capacity-check code path produces the same outcome normal mode would.
Contracts: Service [x] / State [ ]
Service Interface
/*
* Re-charges every currently-running job's known resource allocation
* into core->sharedInUse and each worker's resInUse. Skips a job whose
* allocation is not yet known (job->accounts == NULL) -- reconciling
* that is the Recorded-Decision Reconciliation component's job, which
* must run first in the same round-boundary window for this call to
* see a fully up-to-date job set.
*/
int sched_account_jobs(xce_sched_core_t* core);
- Preconditions:
corenon-NULL; called strictly afterxce_core_begin_session()'s resval reset for the current round. - Postconditions: every RUN-state job with known accounts is reflected in
resInUse/sharedInUse; a RUN-state job with unknown accounts is silently skipped (not an error — see Recorded-Decision Reconciliation for why that's expected before this call runs, and Requirement 4 for the case where it stays unknown). - Invariants: identical result given identical core state, regardless of which driver calls it.
Sim Driver (sim_loop.c)
Sim Driver Round-Boundary Accounting
| Field | Detail |
|---|---|
| Intent | Call Core-Level Resource Accounting at the same relative round-boundary position driver.c already uses |
| Requirements | 1.3, 1.4, 5.3 |
Responsibilities & Constraints
- Replaces the current single
xce_core_go(&(sim->core))call with the split sequence, matchingxce_sched_driver_run_normal_mode_round()'s documented ordering exactly. - Must not change
xce_core_go()'s existing error-handling contract (a failedxce_core_begin_session()still skips the round's decision drain, per today'ssim_loop.cerror path) — the split must preserve this, not simplify it away. - Headless (non-interactive) and interactive (
-i) simulation runs both go through this same call site — no divergence between them (Requirement 5.1 depends on this).
Contracts: State [x]
Recorded-Decision Reconciliation
| Field | Detail |
|---|---|
| Intent | For a RUN-state job with no known allocation, reconstruct it from this round's recorded decision and report it through the existing decision channel |
| Requirements | 3.1, 3.2, 3.3, 4.1, 4.2 |
Responsibilities & Constraints
- Runs once per round, after
xce_core_begin_session(), beforesched_account_jobs(). - Trigger condition: a job in
sched->core.jobs(via the existing core.h iteration idiom) that is RUN-state and hasjob->accounts == NULL. - On trigger, search this round's
decoded_session->decisionsfor a matching jobid. If found, reconstructjob->allocs/job->accountsfrom the recorded payload (same field shapesim_verify_job_alloc_eq()already validates) and feed the reconstructed decision through the same reporting call the existing decision-drain loop uses for a freshly-drained decision (Requirement 3.1). - If not found (Requirement 4): report the job's usage as unavailable — do not fabricate a value, do not treat this as fatal, continue processing the rest of the replay. This is the expected case for a job already running at the moment the replay's starting snapshot was taken (see Risks).
- Because
job->accountsis set as a side effect, a job reconciled in round N is never reconsidered in round N+1 — the same NULL-check that identifies the trigger also prevents double-application (Requirement 3.3), including against a job that separately receives a fresh live decision in the same round (that path setsjob->accountstoo, via the unchangedsched_commit_decision(), so this component simply finds nothing to do for it).
Contracts: Service [x] / State [x]
Service Interface
/*
* For every RUN-state job in `sim->core.jobs` with job->accounts ==
* NULL, attempts to reconstruct its allocation from a matching
* decision in this round's decoded_session->decisions and reports it
* through the same channel a freshly-drained decision uses. A job with
* no matching recorded decision is left with job->accounts == NULL
* (Requirement 4 -- reported as unavailable elsewhere, not an error
* here) and is safe to call again next round.
*
* MUTATES *decoded_session: each consumed entry is freed and removed
* (shifted down, not swap-removed -- see Implementation Notes) from
* decisions[]/n_decisions, so decoded_session cannot be `const` here --
* this is deliberate, not an oversight: the caller's own subsequent
* --sim-verify positional walk over the same decoded_session depends on
* consumed entries already being gone by the time it runs.
*
* record_ctx is nullable and mirrors the existing decision-drain loop's
* own `if (record_ctx != NULL)` gate (this reporting path must recording-
* capture identically to a freshly-drained decision, and recording is
* optional per run) -- CONFIRMED during implementation this third
* parameter is necessary, not avoidable: record_ctx exists only as a
* stack-local inside sched_sim_driver_run()'s per-ReplaySession block
* (no field on xce_sched_sim_driver_t can supply it), and the round-
* boundary call site this function is wired into (task 3) sits in that
* exact same lexical scope, so passing it through is the natural,
* unambiguous plumbing rather than a workaround.
*/
void sim_reconcile_unaccounted_jobs(xce_sched_sim_driver_t* sim,
replay_session_t* decoded_session,
sched_record_session_ctx_t* record_ctx);
- Preconditions:
sim/decoded_sessionnon-NULL (NULL is a safe no-op, matching this codebase's established "never load-bearing" REPL-hook convention).record_ctxmay be NULL (recording not active for this run). - Postconditions: every RUN-state job either has
job->accountsset (reconstructed) or is left NULL (genuinely unavailable this round).decoded_session->decisions/n_decisionsreflect the removal of every consumed entry (freed, not merely uncounted -- see memory-ownership note below), leaving the remaining entries in their original relative order. - Invariants: never double-applies (idempotent per job, per the
job->accountsgate); never fabricates allocation data not present in the recording; never leaves a freed pointer reachable throughjob->allocsor through the remainingdecoded_session->decisionsentries.
Implementation Notes
- Integration with
--sim-verify(CONFIRMED during design validation, resolving the prior "read the comparison driver at implementation time" placeholder):sim_loop.c's verify branch (current code: thewhile ((cbuf_get(&(sim->core.decisions), &d) == 0) ...)loop) walksdecoded_session->decisionspositionally via an indexdi, comparing each live-drained decisiondagainstdecoded_session->decisions[di]and advancingdionce per live decision. A recorded decision belonging to a job this round's reconciliation step consumes (i.e., a job whose allocation/decision visibility is being restored from the recording rather than freshly re-derived by the live picker) will never produce a corresponding live-drainedd— the picker never re-decides an already-resolved job. Comparing positionally without accounting for this would misalign every subsequent entry and produce false mismatches (Requirement 5.2 violation), independent of any bug in the comparison logic itself.- Resolution: the reconciliation step removes each recorded decision it consumes from
decoded_session->decisionsbefore the verify drain loop runs for that round — it already has the exact jobid-matched entry in hand from its own lookup, so this is a natural side effect of the same pass, not a second scan. This keeps the remaining (unconsumed) recorded entries positionally aligned with the live-drained stream exactly as they are today, when no reconciliation was needed for a job. No change tosim_decision_eq()/sim_verify_job_alloc_eq()/sim_verify_job_preason_eq()(the comparison predicates insim_verify.c) is required — onlysim_loop.c's bookkeeping of which array entries reach that comparison. - Memory ownership (must not be overlooked):
decoded_session->decisions/n_decisions(replay_session_t,typemap.h) are a plain heap-owned array —free_ReplaySession_ptr()frees it viafree_ReplayDecision_list(d->n_decisions, d->decisions), which walks and frees exactly the firstn_decisionsentries, then frees the array itself. Consequently, simply decrementingn_decisionsto "drop" a consumed entry (without separately freeing that entry) is not safe compaction — it silently leaks that entry's payload every round a reconciliation happens, since the round-end free walk will never reach it again. The reconciliation step must instead: (1) extract the data it needs (see ownership sub-point immediately below — this is NOT a plain read for a DISPATCH entry), (2) call the existingfree_ReplayDecision(entry)+free(entry)(the same two callsfree_ReplayDecision_list()already makes per-element) on the one consumed entry, then (3) compact the array by shifting the remaining entries down over the removed slot (memmove), and decrementn_decisionsby one. Shift-down, not swap-with-last: swap-with-last is the usual O(1) removal trick, but it would relocate the last entry into the removed slot and change its position relative to the other remaining entries — corrupting the very relative ordering the positionaldicomparison in the point above depends on.decoded_sessionis decoded fresh once per round regardless, so this compaction is confined to the current round with no cross-round effect. - Ownership hazard in step (1) above — a DISPATCH entry's
allocmust be moved, not aliased, before step (2) frees the entry: the established live-path precedent (scheduler.c:1440,sched_commit_decision()) isjob->allocs = decision->u.alloc;— a pointer takeover, not a deep copy;job->accounts(make_job_account(job->allocs)) is then built directly on top of that same, now job-owned, memory. If reconciliation reconstructsjob->allocsthe same way from a recorded DISPATCH entry —job->allocs = entry->body.dispatch->alloc;(sched_dispatch_decision_t.alloc, anxce_job_alloc_t*,typemap.h) — that pointer is now aliased by BOTHjob->allocsand the entry about to be freed in step (2):free_ReplayDecision()→free_DispatchDecision()→free_JobAlloc_ptr(&d->alloc)would free that exactxce_job_alloc_t, leavingjob->allocsdangling immediately (and a double-free whenever the job's own allocation is later released through its normal lifecycle). Required fix: immediately after the takeover assignment, null out the source field —entry->body.dispatch->alloc = NULL;— before callingfree_ReplayDecision(entry); every_ptr-style free helper in this codebase (includingfree_JobAlloc_ptr) already no-ops onNULL, so this makes the subsequent free of the rest of the entry safe without a special case. This is DISPATCH-specific and, by construction, the only case this component needs to handle: the reconciliation trigger is a RUN-state job, and a job can only reach RUN-state via a recorded DISPATCH decision — a recorded PEND (or PREEMPT) entry for the same job in the same round is structurally impossible to match here, since PEND/PREEMPT are exactly the decisions that do not put a job into RUN-state. (Independently confirmed nojob->preason/equivalent field exists to alias in the first place —job->accounts/job->allocsare DISPATCH-only concepts, consistent with this.) No PEND/PREEMPT ownership case needs to be designed or implemented. - This also fixes a latent problem that exists independently of this feature:
--sim-verifyagainst any real replay containing already-resolved jobs likely already misaligns positionally today (the recorded stream has entries the live stream never independently reproduces), so this correction is a net improvement to verify-mode accuracy, not merely a regression guard.
- Resolution: the reconciliation step removes each recorded decision it consumes from
- Validation: must be exercised by a resource-contention fixture, not only a fixture where every job fits, per
scheduler-driver-equivalence.md. - Snapshot-restored jobs (CONFIRMED during design validation):
xce_sched_core_load_snapshot()(snapshot_load.c) restoresjob->statefrom snapshot data but never touchesjob->accounts/job->allocs. A job already RUN-state at snapshot-load time therefore always hasjob->accounts == NULLwith no in-window recorded decision to reconcile from — this is unambiguously Requirement 4's graceful-degradation case, not a separate path requiring its own handling.
Data Models
Domain Model
No new persistent entity. job->accounts/job->allocs (existing fields on xce_sched_job_t, xce/types.h) become the single source of truth for "has this job's allocation been established, by either the live path or reconciliation" — reused, not extended.
Logical Data Model
No new fields. The reconciliation step reads decoded_session->decisions (already-decoded, existing structure) and writes job->allocs/job->accounts (existing fields) exactly as the live sched_commit_decision() path already does for a fresh dispatch — same target shape, different source.
No Physical Data Model, API Data Transfer, or Event Schema sections apply — no persistent storage, no new wire format, no new wire-level contract. This stays inside the existing .replay stream shape.
Error Handling
Error Strategy
Consistent with this codebase's established "never load-bearing, degrade to unattended" discipline (repl_state_ensure()'s own precedent): a reconciliation failure for one job never blocks the rest of the replay.
Error Categories and Responses
- No matching recorded decision for a RUN-state, unaccounted job (Requirement 4.1, 4.2): report usage as unavailable, continue. Not an error condition — expected for jobs already running at snapshot time.
- Allocation failure while reconstructing
job->accountsfrom a recorded decision: fall back to leaving the job unaccounted (same as "not found") rather than a partial/corrupt reconstruction — preserves Requirement 4's "never fabricate" guarantee. xce_core_begin_session()failure: unchanged from today'ssim_loop.cbehavior — the round's decision drain is skipped, matching the existing error path this feature's split-call migration must preserve exactly.
Monitoring
No new logging framework — existing WARN/TRACE conventions apply if reconciliation needs to report a skip; no daemon-level behavior change.
Testing Strategy
Unit Tests
- Core-Level Resource Accounting produces identical
resInUse/sharedInUseoutput whether called with acoreobtained fromxce_scheduler_tor fromxce_sched_sim_driver_t(Requirement 1.4). - Recorded-Decision Reconciliation reconstructs
job->allocs/job->accountscorrectly from a synthetic recorded DISPATCH decision (Requirement 3.1, 3.2). - Recorded-Decision Reconciliation is a no-op (does not double-apply) for a job whose
job->accountsis already set (Requirement 3.3). - Recorded-Decision Reconciliation leaves a job unaccounted, without fabricating data or erroring, when no matching recorded decision exists (Requirement 4.1, 4.2).
- Reconciling a DISPATCH entry, then later releasing the job's allocation through its normal end-of-life path, must be ASAN-clean (no double-free, no leak) — this directly exercises the move-then-null-then-free ownership handling in the Implementation Notes above, run under the project's standard ASAN build per
tech.md/build-deployment.mdrather than relying on a plain functional assertion to catch it.
Integration Tests
- Resource-contention fixture (new, required per
scheduler-driver-equivalence.md): multiple jobs competing for insufficient capacity, replayed in simulation mode; assertresInUsematches what the equivalent normal-mode recording produced at every round, and that no job is dispatched in simulation mode that normal mode would have pended (Requirement 2.1, 2.2). test_repl_equivalence.c(existing) re-run unmodified to confirm no regression to REPL-vs-batch-sim equivalence (Requirement 5.1).--sim-verifyrun against a recording containing already-resolved jobs, confirming no false mismatch is reported as a result of this feature (Requirement 5.2).- A replay starting from a snapshot with jobs already running at snapshot time, confirming graceful degradation (Requirement 4) rather than a crash or fabricated value.
E2E
Live-verify against the real 108-round replay capture that originally motivated bug-1611/bug-1613: after the fix, info worker shows nonzero used= for jobs confirmed running via JOB_RUSAGE ticks, and their DISPATCH decisions become visible in step/continue output.
Optional Sections
Security Considerations, Performance & Scalability, and Migration Strategy are omitted: this is an internal scheduler-daemon correctness fix with no new attack surface, no new persistent data, and no schema/data migration. Performance is bounded by existing per-round job iteration this codebase already tolerates (sched_account_jobs()'s own existing full-job-table walk).
Supporting References
None beyond research.md.