myxce/.kiro/specs/scheduler-sim-decision-parity/design.md
Rongsong Shen 0c237f601a
feat(scheduler-sim-decision-parity): report reconciled decisions through the existing channel (task 2.4)
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>
2026-08-22 09:07:24 +00:00

273 lines
32 KiB
Markdown

# 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()` (currently `resource.c`, `xce_scheduler_t*`-coupled) and their relocation to `xce_sched_core_t*`.
- `sim_loop.c`'s round-boundary sequence (the `xce_core_go()` replacement and the new reconciliation step).
- The logic that reconstructs `job->allocs`/`job->accounts` from 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 existing `sim_verify_job_alloc_eq()`/`sim_verify_host_alloc_eq()`/`sim_verify_alloc_eq()` field shapes as the reference for what a reconstructed `xce_job_alloc_t` must contain — not necessarily calling these functions, but matching the payload shape they already validate.
- `decoded_session->decisions`, already decoded every round by existing `sim_loop.c` code — 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-verify` compares decisions must be re-checked against this spec's reconciliation step, and vice versa.
- If `scheduler-sim-diagnostics` later adds a "why did this job dispatch" trace, it can build on the now-correct `job->accounts` this spec establishes rather than re-deriving it.
- **When `scheduler-preemption` lands** (currently requirements-generated, not yet designed — `driver.h:96` presently 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 emits `XCE_DECISION_PREEMPT` yet, so no recorded replay can contain one), but once `scheduler-preemption` starts 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 whether `scheduler-preemption`'s own design should own that instead.
## Architecture
### Existing Architecture Analysis
- `driver.c`'s `xce_sched_driver_run_normal_mode_round()` is the reference implementation for the exact sequence this spec ports to `sim_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 via `sched_record_session_add_decision()` when recording, then `core->on_decision()`).
- `sim_loop.c` currently calls the older monolithic `xce_core_go()` (itself now just `xce_core_begin_session()`+`xce_core_signal_go()` back-to-back, per `core_session.c`) with no interleaving room, and decodes `decoded_session->decisions` every round but only consumes it under `sim->verify`.
- The accounting functions being relocated — `sched_account_jobs()` (`scheduler.c`) and its callees `sched_charge_account()`/`sched_uncharge_account()`/`sched_update_account()`/`sched_update_res_accounts()`/`sched_commit_job_alloc()` (`resource.c`) — already only touch `sched->core` in their bodies despite their `xce_scheduler_t*` signature. Confirmed by reading every function in this call chain, and independently re-confirmed with `grep "sched->" resource.c | grep -v "sched->core"` returning zero matches across that whole file (covers the five `resource.c` functions; `sched_account_jobs()` itself was separately read in full).
### Architecture Pattern & Boundary Map
```mermaid
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.c` already 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.c` only; the relocated accounting functions live in `resource.c`/`core.h` and are shared by both drivers; `driver.c` gets a one-line call-site update.
- Existing patterns preserved: the `job->accounts == NULL` check `sched_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 before `xce_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.c` remains 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` — relocate `sched_charge_account()`, `sched_uncharge_account()`, `sched_update_account()`, `sched_update_res_accounts()`, `sched_commit_job_alloc()` from `xce_scheduler_t*` to `xce_sched_core_t*` parameters (body unchanged — every reference is already `sched->core.*`).
- `src/scheduler/include/scheduler.h` — remove/update the now-relocated declarations.
- `src/scheduler/include/core.h` — add the relocated declarations, mirroring `sched_resolve_job_picker()`'s existing placement and doc-comment precedent.
- `src/scheduler/src/scheduler.c` — relocate `sched_account_jobs()` itself the same way (it is defined here, not in `resource.c`; body likewise unchanged — every reference is already `sched->core.*`); update this file's own two call sites for the new signatures: `sched_commit_decision()`'s call to `sched_commit_job_alloc()` (pass `&(sched->core)`) and `sched_begin_session()`'s call to `sched_account_jobs()` likewise.
- `src/scheduler/src/driver.c` — update `xce_sched_driver_run_normal_mode_round()`'s `sched_account_jobs(sched)` call to `sched_account_jobs(&(sched->core))`.
- `src/scheduler/src/sim_loop.c` — replace the `xce_core_go(&(sim->core))` call with `xce_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 from `decoded_session->decisions` in the same pass so the existing `--sim-verify` positional walk (`di` against `cbuf_get(&(sim->core.decisions), ...)`, unchanged) stays correctly aligned.
- `src/scheduler/src/sim_verify.c` — **no change** (confirmed during design validation; see `sim_loop.c` entry above and the Recorded-Decision Reconciliation component's Implementation Notes).
### New/Modified Test Files
- `src/scheduler/testsuite/test_driver_session_ordering.c` (or a new `test_sim_session_ordering.c` mirroring it) — proves `sim_loop.c` now upholds the same "picker never observes zeroed-but-not-yet-resummed accounting" property `driver.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 already `sched->core.*`.
- Both drivers call it at the identical relative position: after `xce_core_begin_session()`'s resval reset, before `xce_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 read `resInUse`/`sharedInUse` unconditionally; 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
```c
/*
* 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: `core` non-NULL; called strictly after `xce_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, matching `xce_sched_driver_run_normal_mode_round()`'s documented ordering exactly.
- Must not change `xce_core_go()`'s existing error-handling contract (a failed `xce_core_begin_session()` still skips the round's decision drain, per today's `sim_loop.c` error 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()`, before `sched_account_jobs()`.
- Trigger condition: a job in `sched->core.jobs` (via the existing core.h iteration idiom) that is RUN-state and has `job->accounts == NULL`.
- On trigger, search this round's `decoded_session->decisions` for a matching jobid. If found, reconstruct `job->allocs`/`job->accounts` from the recorded payload (same field shape `sim_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->accounts` is 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 sets `job->accounts` too, via the unchanged `sched_commit_decision()`, so this component simply finds nothing to do for it).
**Contracts**: Service [x] / State [x]
##### Service Interface
```c
/*
* 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_session` non-NULL (NULL is a safe no-op, matching this codebase's established "never load-bearing" REPL-hook convention). `record_ctx` may be NULL (recording not active for this run).
- Postconditions: every RUN-state job either has `job->accounts` set (reconstructed) or is left NULL (genuinely unavailable this round). `decoded_session->decisions`/`n_decisions` reflect 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->accounts` gate); never fabricates allocation data not present in the recording; never leaves a freed pointer reachable through `job->allocs` or through the remaining `decoded_session->decisions` entries.
**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: the `while ((cbuf_get(&(sim->core.decisions), &d) == 0) ...)` loop) walks `decoded_session->decisions` *positionally* via an index `di`, comparing each live-drained decision `d` against `decoded_session->decisions[di]` and advancing `di` once 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-drained `d` — 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->decisions` *before* 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 to `sim_decision_eq()`/`sim_verify_job_alloc_eq()`/`sim_verify_job_preason_eq()` (the comparison predicates in `sim_verify.c`) is required — only `sim_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 via `free_ReplayDecision_list(d->n_decisions, d->decisions)`, which walks and frees exactly the first `n_decisions` entries, then frees the array itself. Consequently, simply decrementing `n_decisions` to "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 existing `free_ReplayDecision(entry)` + `free(entry)` (the same two calls `free_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 decrement `n_decisions` by 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 positional `di` comparison in the point above depends on. `decoded_session` is 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 `alloc` must be moved, not aliased, before step (2) frees the entry**: the established live-path precedent (`scheduler.c:1440`, `sched_commit_decision()`) is `job->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 reconstructs `job->allocs` the same way from a recorded DISPATCH entry — `job->allocs = entry->body.dispatch->alloc;` (`sched_dispatch_decision_t.alloc`, an `xce_job_alloc_t*`, `typemap.h`) — that pointer is now aliased by BOTH `job->allocs` and the entry about to be freed in step (2): `free_ReplayDecision()` → `free_DispatchDecision()` → `free_JobAlloc_ptr(&d->alloc)` would free that exact `xce_job_alloc_t`, leaving `job->allocs` dangling 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 calling `free_ReplayDecision(entry)`; every `_ptr`-style free helper in this codebase (including `free_JobAlloc_ptr`) already no-ops on `NULL`, 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 no `job->preason`/equivalent field exists to alias in the first place — `job->accounts`/`job->allocs` are 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-verify` against 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.
- 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`) restores `job->state` from snapshot data but never touches `job->accounts`/`job->allocs`. A job already RUN-state at snapshot-load time therefore always has `job->accounts == NULL` with 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->accounts` from 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's `sim_loop.c` behavior — 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`/`sharedInUse` output whether called with a `core` obtained from `xce_scheduler_t` or from `xce_sched_sim_driver_t` (Requirement 1.4).
- Recorded-Decision Reconciliation reconstructs `job->allocs`/`job->accounts` correctly 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->accounts` is 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.md` rather 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; assert `resInUse` matches 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-verify` run 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`.