myxce/.kiro/specs/scheduler-sim-eager-reconciliation/research.md
Rongsong Shen e2bb98c87e
feat(scheduler-sim-eager-reconciliation): task 1.1 state-independent dispatch capture
Widens sim_reconcile_unaccounted_jobs()'s trigger gate in sim_loop.c: removes
!JOB_IS_RUN(job->state) so a job's recorded DISPATCH decision is captured as
soon as the replay reaches the round it occurred in, regardless of the job's
current state -- closing bug-1611/bug-1613's residual (a job whose RUN
confirmation lands in a later round than its dispatch decision, the realistic
case under any real dispatch-to-confirmation latency, was never reconciled).
sched_account_jobs()'s own unmodified JOB_IS_RUN charging gate continues to
defer actual resource-in-use accounting until the job genuinely transitions
to RUN -- no other line in the match/capture/report path changes.

Replaces test_reconcile_skips_non_run_state_job (which asserted the bug as
correct behavior) with test_reconcile_captures_non_run_state_job_when_dispatch_recorded,
proving the opposite for a PEND-state job. Adds the xce-test-rc-file-style
spec docs (requirements/design/research/tasks) for this feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
2026-08-27 03:45:08 +00:00

90 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Research & Design Decisions
## Summary
- **Feature**: `scheduler-sim-eager-reconciliation`
- **Discovery Scope**: Extension (widens an existing, already-shipped mechanism's trigger condition; no new subsystem, no external dependency)
- **Key Findings**:
- The fix is a single-condition removal in `sim_reconcile_unaccounted_jobs()`'s trigger gate (`sim_loop.c:592`), not a new cross-round data structure. The per-round `decoded_session` decode/reconcile/free cycle already runs every round regardless of any job's state; the only reason cross-round matching fails today is that the gate additionally requires `JOB_IS_RUN(job->state)` before a job is even considered for a search, so a job still PEND at the round its own DISPATCH decision appears is skipped outright and never re-checked once it later becomes RUN (by then that round's `decoded_session` is long freed).
- `sched_account_jobs()` (the function that actually turns `job->accounts` into `w->resInUse`/`core->sharedInUse`) already independently gates on `JOB_IS_RUN(job->state)` before charging. This means capturing `job->accounts` early (while a job is still PEND) is inert until the job's real state later transitions to RUN — the existing charging gate is exactly the state-appropriate deferral this feature needs, and it requires no change.
- One existing test, `test_reconcile_skips_non_run_state_job` (`test_sim_reconcile_unaccounted.c:507-531`), asserts the CURRENT (buggy) behavior by name and message ("a non-RUN-state job must never be reconciled here") — this test encodes the bug and must be replaced (inverted), not left alongside new coverage.
## Research Log
### Where does resInUse actually go to zero, and what re-populates it?
- **Context**: bug-1613's buglog root-cause trail (confirmed) plus `.kiro/steering/scheduler-driver-equivalence.md`.
- **Sources Consulted**: `src/scheduler/src/scheduler.c` (`sched_account_jobs`, `sched_commit_decision`), `src/scheduler/src/resource.c` (`sched_update_res_accounts`, `sched_charge_account`), `.wolf/buglog.json` bug-1611/bug-1613 full history.
- **Findings**: `xce_core_begin_session()` zeroes `resInUse`/`sharedInUse` every round; `sched_account_jobs()` is the only re-summing mechanism, iterating `core->jobs` and charging every job that is both `JOB_IS_RUN` and has `job->accounts != NULL`. Normal mode's `sched_commit_decision()` sets `job->accounts` synchronously at DISPATCH-decision time, independent of `job->state` — so by the time a job's state reaches RUN, its accounts are already long-set. Simulation mode has no equivalent synchronous path (its own live picker never emits a DISPATCH — confirmed in the bug-1613 trail — it is PEND-only), so `job->accounts` for a replayed job can only come from `sim_reconcile_unaccounted_jobs()` reconstructing it from the recorded decision stream.
- **Implications**: the fix belongs entirely in how/when `sim_reconcile_unaccounted_jobs()` captures `job->accounts` — `sched_account_jobs()` itself is correct today and must not change.
### Why does today's reconciliation only work same-round?
- **Context**: bug-1611's confirmed root-cause note and the `scheduler-sim-decision-parity` spec's task 4.3 implementation notes (both quoted in `.wolf/buglog.json`).
- **Sources Consulted**: `src/scheduler/src/sim_loop.c:566-709` (`sim_reconcile_unaccounted_jobs`, read in full), `src/scheduler/src/sim_loop.c:840-960` (round-boundary call sequence, read in full).
- **Findings**: `decoded_session` is decoded fresh from the replay stream once per round (`decode_ReplaySession_ptr`, `sim_loop.c:871`) and freed at the end of the same round (`free_ReplaySession_ptr`, `sim_loop.c:1006`) — it never survives past its own round. `sim_reconcile_unaccounted_jobs()` is called unconditionally every round (`sim_loop.c:948`, between `xce_core_begin_session()` and `sched_account_jobs()`), but its trigger gate (`sim_loop.c:592`) is:
```c
if ((job == NULL) || !JOB_IS_RUN(job->state) || (job->accounts != NULL)) {
continue;
}
```
A job whose DISPATCH decision is recorded at round N but whose RUN-state confirmation is only replayed at round N+k (realistic dispatch-to-confirmation latency) is skipped at round N (state is still PEND) and is never re-considered once it reaches RUN at round N+k, because round N's `decoded_session` — the only place that DISPATCH decision ever existed — is already gone.
- **Implications**: no new persistent, cross-round data structure is needed. The function already re-runs every round with a live view of the round's own recorded decisions; it only needs to stop requiring `JOB_IS_RUN` before searching. Removing that one condition means every not-yet-captured job is (re-)checked against every round's own decoded stream until either found (captured, permanently, via the existing `job->accounts != NULL` guard) or the replay ends.
### Does removing the RUN-state gate risk fabricating or double-capturing?
- **Context**: Requirement 3's explicit no-fabrication / no-double-capture acceptance criteria.
- **Sources Consulted**: `sim_loop.c:592-693` (full match/capture logic, read in full).
- **Findings**: The match loop already restricts a capture to the job's own jobid (`entry->body.dispatch->alloc->jobid != job->jobid`, `sim_loop.c:605`) — this is state-independent and unaffected by the gate change. The `job->accounts != NULL` continue-guard (unchanged) already prevents a second capture on any later round, for any job, regardless of state. Widening the state condition therefore only widens *which jobs are eligible to be searched for*, never *what counts as a match* or *whether a second capture can happen*.
- **Implications**: Requirement 3 (no fabrication, no double-capture) is satisfied by logic that already exists and needs no modification — only new test coverage proving it still holds once the gate widens.
### Interaction with `--sim-verify`
- **Context**: Requirement 4.2 (`--sim-verify` must not report a new mismatch caused by this feature).
- **Sources Consulted**: `sim_loop.c:925-1004` (reconciliation call site through the verify positional-walk loop, read in full).
- **Findings**: Reconciliation runs and compacts `decoded_session->decisions` *before* the `--sim-verify` positional walk (`sim_loop.c:970-1004`) reads that same array. Today, a job whose confirmation lands in a later round leaves its DISPATCH entry sitting **unconsumed** in the round-N `decoded_session` (reconciliation skipped it), which `--sim-verify`'s walk would then see as a recorded entry with no matching live decision — a latent false-mismatch risk in `--sim-verify` mode that already exists independent of this fix. Widening the gate causes reconciliation to consume that entry at the correct round instead, which removes this latent risk rather than introducing a new one.
- **Implications**: this fix is verify-neutral-to-positive, not verify-risky. Still needs an explicit test per Requirement 4.2's own wording (a `--sim-verify` run across a multi-round-separated dispatch must not report a new mismatch).
### Does early capture risk an incorrect uncharge when a captured-but-not-yet-RUN job later leaves the job set entirely?
- **Context**: capturing `job->accounts` while a job is still PEND (this feature's whole point) means, for the first time, a job can carry non-NULL `accounts` without ever having been charged. If any cleanup path unconditionally treats "accounts non-NULL" as "this job was charged, so subtract it," early capture would cause `resInUse` to under-count (a phantom decrement for a charge that was never added) for any captured-but-never-actually-run job (e.g. cancelled while still PEND).
- **Sources Consulted**: every production (non-test) reference to `job->accounts`/`sched_uncharge_account` across `src/scheduler/src/*.c`, via grep: `resource.c:591-602` (`sched_uncharge_account`/`sched_commit_job_alloc` definitions), `scheduler.c:964-980` (`sched_job_state_change`), `core_events.c:898-921` (`XCE_CORE_EVENT_JOB_STATE_CHANGE` handler) — the two places a job's `job->accounts` gets freed outside of normal completion.
- **Findings**: `sched_uncharge_account()` has **zero production callers** anywhere in the scheduler (confirmed by grep across every `.c` file in `src/scheduler/src/` — its only callers are test files exercising it in isolation). Both places `job->accounts` gets freed on a non-RUN state transition (`scheduler.c:974-977`, `core_events.c:915-918`) do exactly one thing: `free_job_account(job->accounts); job->accounts = NULL;` — memory release only, no `resInUse` mutation, no call to `sched_uncharge_account`. Production accounting is entirely a **reset-and-resum** model, not incremental charge/uncharge: `xce_core_begin_session()` zeroes `resInUse`/`sharedInUse` every round, and `sched_account_jobs()` re-sums it from scratch by summing every *currently* `JOB_IS_RUN` job with non-NULL `accounts`. A job never counts toward `resInUse` until a round where it is both RUN and has accounts — regardless of how early those accounts were captured.
- **Implications**: a job captured while still PEND that later transitions directly to a terminal state (cancelled, failed) **without ever passing through RUN** is simply never summed by `sched_account_jobs()` at any round (it never satisfies `JOB_IS_RUN`), and its `job->accounts` is freed harmlessly with zero `resInUse` impact when its terminal state is applied. Early capture cannot cause an under-count. This is not a risk this design needs to mitigate — it is a consequence of an accounting model (reset+resum, no incremental uncharge) that was already correct before this feature and remains correct with a wider capture-eligibility gate.
### Does bug-1624's normal-mode fix (job->state = RUN set synchronously at dispatch-commit) already close this gap for current recordings?
- **Context**: `bug-1624` (fixed 2026-08-23, `scheduler-dispatch-commit` tasks 2.1/3.1) made `sched_commit_decision()` set `job->state = JOB_STAT_RUN` synchronously in the same operation as `job->accounts`/`job->allocs`, specifically to close a *live* normal-mode over-commit window. Since this predates the current investigation, it's worth confirming it doesn't also incidentally collapse the round-separation this spec fixes for any recording made after 2026-08-23.
- **Sources Consulted**: `grep -n "job->state\|JOB_STAT_RUN" src/scheduler/src/sim_loop.c` (only hit: the trigger gate itself, `sim_loop.c:592`); `src/scheduler/src/scheduler.c:1441-1447` (`sched_commit_decision`, read in full earlier); `.wolf/buglog.json` bug-1624's full fix note.
- **Findings**: `sim_reconcile_unaccounted_jobs()` never sets `job->state` anywhere — replay-mode state transitions come exclusively from the separately-recorded `XCE_CORE_EVENT_JOB_STATE_CHANGE` event stream, applied via `core_events.c`, independent of whatever the decision stream contains. `sched_commit_decision()`'s `job->state = JOB_STAT_RUN` (bug-1624's fix) is a direct in-memory field write in the *live* scheduler process — not a call into the core's event-push pipeline (`xce_core_push_events()`) — so it is not, by itself, a recordable core event. The recorded `JOB_STATE_CHANGE` event a replay later applies still originates from whatever asynchronous signal produces it for recording purposes (the controller's own worker-confirmation path), independent of bug-1624's live in-memory synchronization.
- **Implications**: bug-1624 fixed a live-mode admission-safety window; it does not change what ends up in a recording's event stream or its round timing. This spec's premise — a recorded DISPATCH decision and its recorded RUN-state-change event can land in different replay rounds — holds for recordings made before *and* after bug-1624, and is not something a future reader should mistake as already resolved by that unrelated fix.
### Existing test locking in the bug
- **Context**: identifying what existing coverage this feature must invalidate, not just extend.
- **Sources Consulted**: `src/scheduler/testsuite/test_sim_reconcile_unaccounted.c` (grepped test names, read `test_reconcile_skips_non_run_state_job` in full at lines 507-531).
- **Findings**: `test_reconcile_skips_non_run_state_job` adds a `JOB_STAT_PEND` job with a matching recorded DISPATCH entry and asserts `job->accounts` stays `NULL`, with the message "a non-RUN-state job must never be reconciled here, regardless of what recorded data exists for it this round." This is the literal bug this spec fixes, encoded as a passing test.
- **Implications**: this test must be replaced (inverted expectation, renamed), not left in place alongside new coverage — the design's File Structure Plan and the eventual task list must call this out explicitly, or the fix will be blocked by its own existing test suite.
## Architecture Pattern Evaluation
| Option | Description | Strengths | Risks / Limitations | Notes |
|--------|-------------|-----------|---------------------|-------|
| Widen existing gate (chosen) | Remove `!JOB_IS_RUN(job->state)` from `sim_reconcile_unaccounted_jobs()`'s trigger condition; rely on the function's existing every-round re-invocation and the existing `job->accounts != NULL` guard for persistence/idempotency | Minimal surface area (one condition), reuses every existing invariant (dedup, ownership transfer, decision reporting) untouched, symmetric with normal mode's own state-independent capture | Every not-yet-captured job is now re-searched against every round's decoded stream until found — O(jobs × rounds) worst case for a job with no recorded origin at all (Req 4.4 case) | Chosen: smallest design satisfying all requirements (design-synthesis Simplification lens) |
| New persistent cross-round pending-capture table | Maintain a separate `jobid -> pending decision` map populated across rounds, independent of `sim->core.jobs` iteration | Could in principle capture before a job even exists in `core->jobs` | Solves a problem that does not exist here — `decoded_session` already only ever contains decisions for jobs `sim->core.jobs` already knows about at that round; adds a new owned lifetime/lookup structure and a new invalidation surface for no behavioral gain | Rejected — violates the Simplification lens; not pursued further |
## Design Decisions
### Decision: Remove the `JOB_IS_RUN` pre-filter instead of adding a new capture-tracking structure
- **Context**: bug-1611/1613's residual gap is that reconciliation only matches within the same round a job happens to already be RUN.
- **Alternatives Considered**:
1. New persistent `jobid -> captured allocation` structure spanning rounds, decoupled entirely from `sim->core.jobs` iteration.
2. Widen the existing trigger gate on the existing per-round iteration (chosen).
- **Selected Approach**: Delete `!JOB_IS_RUN(job->state)` from the trigger condition at `sim_loop.c:592`; leave every other line of the function (match search, ownership transfer, decision reporting, compaction) unchanged.
- **Rationale**: The function already iterates `sim->core.jobs` every round and already searches that round's own decoded stream; the only defect is an overly-narrow precondition on which jobs are eligible. `sched_account_jobs()`'s own unchanged RUN-state gate already provides the correct "don't charge until actually running" deferral this feature's Requirement 1.2 needs — building a second deferral mechanism would duplicate an already-correct one.
- **Trade-offs**: A job that never has a recorded origin anywhere in the replay (Requirement 4.4) is now re-searched, and its "not found" TRACE-level log line re-emitted, every round for the rest of the replay instead of never being searched again after one failed same-round attempt. Accepted: TRACE-level (filtered by default log level in normal operation) and structurally required by Requirement 4.4's "shall continue processing... without terminating" wording, which implies retrying, not giving up.
- **Follow-up**: Verify during implementation that no other caller or test assumes today's "same-round-only" contract as load-bearing behavior beyond the one test identified above.
## Risks & Mitigations
- **Existing test asserts the bug as correct behavior** (`test_reconcile_skips_non_run_state_job`) — Mitigation: replace it with an inverted-expectation test in the same task that changes the gate, not as a follow-up; do not let old and new expectations coexist even briefly.
- **Resource-contention blind spot** (`scheduler-driver-equivalence.md`'s own documented testing discipline: a no-contention fixture cannot distinguish "accounting correct" from "accounting wrong but unread") — Mitigation: Testing Strategy must include at least one fixture where a worker's capacity is insufficient for all candidate jobs, so a wrongly-zero `resInUse` would visibly change a scheduling decision, not just a REPL display value.
- **`--sim-verify` false-mismatch risk already latent today** — Mitigation: add an explicit `--sim-verify` regression test spanning a multi-round dispatch-to-confirmation gap; confirm it reports zero mismatches after the fix (and, ideally, confirm it currently free of new mismatches — the fix is expected to strictly improve this, not just avoid regressing it).
- **Log noise from repeated "not found" TRACE lines** across many rounds for a job with no recorded origin — Mitigation: accepted as documented trade-off (see Design Decision above); TRACE level already filtered in default operation, not escalated further by this spec.
- **Wider per-round search cost**: every not-yet-captured job (previously only RUN-state ones) is now linearly searched against that round's decoded decision list every round until captured or the replay ends — a round with a large PEND backlog does more search work than before. Mitigation: the per-job, per-round search cost itself is unchanged (same linear scan the existing mechanism already performs); only the set of jobs subjected to it widens. Replay is a debugging/tuning tool, not a real-time or production hot path, and `--sim-speed` already exists to control replay pacing — accepted without further mitigation; revisit only if a real replay is observed to become impractically slow.
## References
- `.kiro/steering/scheduler-driver-equivalence.md` — the enforced state-equivalence invariant and its testing discipline this feature closes the remaining gap in.
- `.kiro/specs/scheduler-sim-decision-parity/` — the parent spec whose `sim_reconcile_unaccounted_jobs()` mechanism this feature extends (implementation-complete, not reopened).
- `.wolf/buglog.json` bug-1611, bug-1613 — full confirmed root-cause and partial-fix history.