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
18 KiB
18 KiB
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-rounddecoded_sessiondecode/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 requiresJOB_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'sdecoded_sessionis long freed). sched_account_jobs()(the function that actually turnsjob->accountsintow->resInUse/core->sharedInUse) already independently gates onJOB_IS_RUN(job->state)before charging. This means capturingjob->accountsearly (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.
- The fix is a single-condition removal in
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.jsonbug-1611/bug-1613 full history. - Findings:
xce_core_begin_session()zeroesresInUse/sharedInUseevery round;sched_account_jobs()is the only re-summing mechanism, iteratingcore->jobsand charging every job that is bothJOB_IS_RUNand hasjob->accounts != NULL. Normal mode'ssched_commit_decision()setsjob->accountssynchronously at DISPATCH-decision time, independent ofjob->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), sojob->accountsfor a replayed job can only come fromsim_reconcile_unaccounted_jobs()reconstructing it from the recorded decision stream. - Implications: the fix belongs entirely in how/when
sim_reconcile_unaccounted_jobs()capturesjob->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-parityspec'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_sessionis 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, betweenxce_core_begin_session()andsched_account_jobs()), but its trigger gate (sim_loop.c:592) is:
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'sif ((job == NULL) || !JOB_IS_RUN(job->state) || (job->accounts != NULL)) { continue; }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_RUNbefore 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 existingjob->accounts != NULLguard) 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. Thejob->accounts != NULLcontinue-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-verifymust 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->decisionsbefore the--sim-verifypositional 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-Ndecoded_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-verifymode 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-verifyrun 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->accountswhile a job is still PEND (this feature's whole point) means, for the first time, a job can carry non-NULLaccountswithout ever having been charged. If any cleanup path unconditionally treats "accounts non-NULL" as "this job was charged, so subtract it," early capture would causeresInUseto 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_accountacrosssrc/scheduler/src/*.c, via grep:resource.c:591-602(sched_uncharge_account/sched_commit_job_allocdefinitions),scheduler.c:964-980(sched_job_state_change),core_events.c:898-921(XCE_CORE_EVENT_JOB_STATE_CHANGEhandler) — the two places a job'sjob->accountsgets freed outside of normal completion. - Findings:
sched_uncharge_account()has zero production callers anywhere in the scheduler (confirmed by grep across every.cfile insrc/scheduler/src/— its only callers are test files exercising it in isolation). Both placesjob->accountsgets 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, noresInUsemutation, no call tosched_uncharge_account. Production accounting is entirely a reset-and-resum model, not incremental charge/uncharge:xce_core_begin_session()zeroesresInUse/sharedInUseevery round, andsched_account_jobs()re-sums it from scratch by summing every currentlyJOB_IS_RUNjob with non-NULLaccounts. A job never counts towardresInUseuntil 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 satisfiesJOB_IS_RUN), and itsjob->accountsis freed harmlessly with zeroresInUseimpact 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-committasks 2.1/3.1) madesched_commit_decision()setjob->state = JOB_STAT_RUNsynchronously in the same operation asjob->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.jsonbug-1624's full fix note. - Findings:
sim_reconcile_unaccounted_jobs()never setsjob->stateanywhere — replay-mode state transitions come exclusively from the separately-recordedXCE_CORE_EVENT_JOB_STATE_CHANGEevent stream, applied viacore_events.c, independent of whatever the decision stream contains.sched_commit_decision()'sjob->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 recordedJOB_STATE_CHANGEevent 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, readtest_reconcile_skips_non_run_state_jobin full at lines 507-531). - Findings:
test_reconcile_skips_non_run_state_jobadds aJOB_STAT_PENDjob with a matching recorded DISPATCH entry and assertsjob->accountsstaysNULL, 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:
- New persistent
jobid -> captured allocationstructure spanning rounds, decoupled entirely fromsim->core.jobsiteration. - Widen the existing trigger gate on the existing per-round iteration (chosen).
- New persistent
- Selected Approach: Delete
!JOB_IS_RUN(job->state)from the trigger condition atsim_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.jobsevery 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-zeroresInUsewould visibly change a scheduling decision, not just a REPL display value. --sim-verifyfalse-mismatch risk already latent today — Mitigation: add an explicit--sim-verifyregression 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-speedalready 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 whosesim_reconcile_unaccounted_jobs()mechanism this feature extends (implementation-complete, not reopened)..wolf/buglog.jsonbug-1611, bug-1613 — full confirmed root-cause and partial-fix history.