Skip to content

feat: job engine#2718

Merged
fl0rianr merged 25 commits into
lemonade-sdk:mainfrom
pwilkin:feat/job-engine
Jul 17, 2026
Merged

feat: job engine#2718
fl0rianr merged 25 commits into
lemonade-sdk:mainfrom
pwilkin:feat/job-engine

Conversation

@pwilkin

@pwilkin pwilkin commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

This is the backend powering the #2613 llama.cpp AutoOpt (#2696 ), but not only - figured it would be useful to have an actual server-side job module, with more complex sequences of tasks that can be queued on the server. Currently, it's impossible to do something like this without either (a) hardcoding the sequence in the backend, which is inflexible and requires updating the backend every time we want to add a new sequence or (b) driving everything from the frontend, which is not very versatile.

For now, the engine, language governing the jobs etc. are kept pretty minimal (bare sequences with simple if/then/else branching).

@fl0rianr
fl0rianr requested a review from bitgamma July 15, 2026 19:28
@github-actions github-actions Bot added the enhancement New feature or request label Jul 15, 2026

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together, I'm all for this! The overall direction is strong, and the separation between job definitions, operation registry, expression handling, persistence, and forward-only control flow looks promising.

However, I found several issues that should be addressed before merge:

  1. GET /jobs is shadowed by the automatic 405 handler.
    register_post("jobs", ...) registers a GET 405 route before register_get("jobs", ...). Since cpp-httplib uses the first matching handler, the list endpoint likely always returns 405. Please exclude jobs from the automatic GET fallback or register GET first, and add an integration test for listing jobs.

  2. Deleting an active job skips cleanup.
    remove() immediately erases the active job from jobs_. After execute() returns, the worker can no longer observe an Interrupted state, so reconcile_unload() is never called. A running exclusive job can therefore be deleted while leaving its loaded model behind. Active jobs should be marked for deletion and only removed after interrupt handling and cleanup complete.

  3. Chat interruption is not actually immediate.
    The chat op ignores the cancellation flag and synchronously calls router_->chat_completion(). The job only becomes interrupted after the chat request returns. This does not match the documented “cancel the current step now” behavior and can hold the exclusive slot for a long time.

  4. Interrupt cleanup unloads every model.
    reconcile_unload calls router_->unload_model(""), so interrupting any exclusive job unloads all resident models, including models that were loaded before the job or pinned. A chat-only job can also unload the model it needs when resumed. Cleanup should restore the pre-job state or track only models loaded by the job.

  5. The exclusive load gate has a race.
    A normal load can pass wait_for_slot_clearance(), then wait on is_loading_. If an exclusive job starts while it is waiting, the load resumes without checking exclusivity again and can run inside the supposedly exclusive job. Both conditions need to be checked together or exclusivity must be rechecked after every load_cv_ wake-up.

  6. The nuclear retry is not cancellable.
    The initial new_server receives the load cancel flag, but retry_server does not. Interrupting during the retry therefore cannot stop backend startup.

  7. Expression validation only tokenizes.
    validate_steps() calls tokenize() but does not parse the expression. Invalid conditions such as 1 +, (true, or 1 < 2 < 3 are accepted at creation and only fail during execution. Creation-time validation should run the full parser, while deferring only reference-path resolution.

  8. The new tests are not run by CI.
    CMake defines JobExprTest and JobGraphTest, but the workflow builds a fixed list of targets and uses a restricted ctest -R expression that excludes both. test/server_jobs.py is also not integrated. The current workflow runs are still action_required, so there is no actual CI signal yet.

Smaller follow-ups:

  • id_counter_ is not restored after restart, so IDs can collide within the same second.
  • Branch-skipped steps remain pending, which makes completed-job progress misleading.
  • Step IDs such as inputs or IDs containing dots can collide with or become inaccessible through the context reference syntax.

I would currently request changes. The architecture is promising, but the lifecycle, cancellation, exclusivity, and cleanup guarantees need to be made reliable and covered by CI before this becomes a safe server-side execution engine.

@pwilkin

pwilkin commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Aight, fixes are up.

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is significantly improved, and most of the original review points appear to be addressed correctly.

In particular, the following now look fixed:

  • GET /jobs is no longer shadowed by the automatic 405 handler.
  • Active job deletion is deferred until interrupt handling and cleanup complete.
  • Chat cancellation is passed through Router, WrappedServer, and curl.
  • The exclusive-load race is addressed with a combined wait condition.
  • The nuclear retry receives the cancellation flag.
  • Expressions are now fully parsed during validation.
  • Reserved/inaccessible step IDs and misleading branch progress are handled.
  • The new C++ unit tests are included in CI.

I still found two functional issues and one important test gap:

  1. Job persistence is likely broken on Windows after the first write.

    persist_locked() writes jobs.json.tmp and then directly calls std::filesystem::rename(tmp, jobs.json). On Windows, rename() does not reliably replace an existing destination file.

    The first persistence may work, but subsequent status updates can fail once jobs.json already exists. This could leave running, paused, interrupted, completed, resumed, or deleted job states stale on disk while everything still appears correct in memory.

    Please use a platform-safe atomic replacement mechanism, ideally the same helper already used elsewhere in the repository, and add a test that performs multiple consecutive persistence writes on Windows.

  2. A model loaded by the job with pinned: true leaks after interrupt or delete.

    The new reconciliation correctly snapshots the models that existed before the job and unloads newly introduced models afterward. However, unload_models_not_in() skips every currently pinned model unconditionally.

    A job can therefore:

    • load a new model with pinned: true,
    • be interrupted or deleted,
    • and leave that model resident permanently because reconciliation skips it.

    Pinning performed by the job itself should not bypass cleanup. Only models that were already present before the job should be preserved. The snapshot may need to include the original pin state, or reconciliation should otherwise track which models the job introduced.

    Please add an integration test that loads a model with pinned: true, waits in a later step, deletes the active job, and verifies that the model is unloaded.

  3. The server-level job tests still do not appear to run in CI.

    test/server_jobs.py now contains valuable regression coverage for listing, active deletion, exclusive cleanup, pre-existing model preservation, queueing, and restart persistence. However, the workflow still appears to run only JobExprTest and JobGraphTest.

    These integration tests are exactly what would catch the remaining lifecycle and persistence issues, so they should be included in an appropriate server CI job.

One smaller safety improvement: a successfully loaded WrappedServer still retains the raw load-cancellation pointer after startup. It should be reset to nullptr immediately after the load attempt, ideally with a scope guard, so the long-lived server never stores a pointer to a job control object that may later be deleted.

This is much closer now, but I would still request changes for the Windows persistence behavior and the pinned-model cleanup leak, with the server integration tests added to CI.

@bitgamma bitgamma left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can pave the way to interesting scenarios beyond AutoOpt. I left a few comments about things I'd like to see added but these can well be followup PRs.

when @fl0rianr's remarks are addressed it is good to go for me

Comment thread docs/dev/job-system.md Outdated
Comment thread docs/dev/job-system.md
Comment thread docs/dev/job-system.md
@pwilkin
pwilkin force-pushed the feat/job-engine branch 2 times, most recently from 1961fe0 to 2eaaebe Compare July 16, 2026 12:47
@pwilkin

pwilkin commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Aight, should be good now.

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the previous review findings are now largely addressed. The Windows persistence path uses a platform-safe replacement helper, pinned models introduced by the job are cleaned up correctly, load cancellation pointers are reset, and server_jobs.py is now integrated into CI.

I am treating the current CI failure as unrelated, as noted.

After reviewing the current head again, I still found several issues:

  1. A late load interrupt can still unload all pre-existing models.

    After router_->load_model(...) returns successfully, the job load provider checks the cancellation flag again and calls:

    router_->unload_model("");

    if cancellation arrived in that small window.

    An empty model name unloads every resident model, not only the one loaded by the job. The later snapshot reconciliation cannot restore models that were already removed.

    Please remove the global unload from this path. Returning an interrupted error and letting the snapshot-based reconciliation clean up the job-introduced model should be sufficient.

  2. Deleting an active job is not durable across a crash.

    Active deletion currently sets only in-memory interrupt_requested, cancel, and delete_requested flags and immediately responds with {"status":"deleted"}. The job is removed from persistence only after its running operation and cleanup finish.

    If the server crashes after acknowledging the DELETE but before that final removal, the job is recovered after restart as interrupted and becomes visible again.

    The delete request should be persisted as a tombstone or durable state before acknowledging success. A restart after an acknowledged deletion must not resurrect the job.

  3. Pause and interrupt do not take effect immediately for queued jobs.

    Both operations accept jobs in the queued state, but they only set control flags. The job remains queued until it reaches the worker, is briefly changed to running, and only then becomes paused or interrupted.

    This can leave the API reporting queued for a long time after returning pausing or interrupting, and the job cannot be resumed during that period.

    For queued jobs, pause or interrupt should remove the job from queue_, update and persist its status immediately, and let resume enqueue it again.

  4. The exclusive gate is not fully exclusive.

    begin_exclusive() waits for an active load to finish, but it does not wait for already-running inference requests. An external chat that passed the gate just before the job starts can therefore overlap with the supposedly exclusive benchmark job.

    Some model-touching Router paths, including tokenize() and slots_action(), also do not call wait_for_slot_clearance() at all and can run during an exclusive job.

    All model/backend operations should use the same gate, and beginning an exclusive session should wait for existing in-flight operations to drain.

  5. The pre-job model snapshot cannot restore models evicted by a job load.

    The snapshot stores only loaded model names. If a job load evicts a pre-existing model because of the slot limit, reconciliation removes the job-loaded model but cannot reload the evicted baseline model.

    It also cannot restore changed options or pin states of a model that existed before the job.

    This conflicts with the documentation that interrupted jobs leave pre-existing models untouched. Either the snapshot needs enough information to restore the previous Router state, or the documented guarantee should be weakened explicitly.

  6. The documented 50-job cap is not actually enforced.

    When the history exceeds 50 entries, only completed or failed jobs are eligible for eviction. If all existing jobs are queued, paused, interrupted, or running, creation continues and the collection can grow without limit.

    If no terminal entry can be evicted, new job creation should be rejected with a clear status such as 429 or 409, or active and historical limits should be documented separately.

  7. Completed recovery jobs can report incomplete progress.

    Progress counts only completed and skipped steps. A failed step handled through on_fail: continue or a recovery branch remains failed, even when the overall job later completes successfully.

    A completed two-step recovery job can therefore report only one completed step. Handled failed steps should count as terminal progress, or progress should expose separate processed/succeeded counts.

The implementation is much closer now, and the earlier issues were addressed well. I would still request changes for the late global unload, crash-durable active deletion, queued control handling, and incomplete exclusivity before merge.

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — after another pass over the current head, this is now very close.

I still see one lifecycle issue before I would consider this fully merge-ready:

A job can lose ownership of models it introduced across pause/resume.

Example:

  1. An exclusive job loads model A.
  2. The job is paused during a later step. The exclusive gate is released, but A remains loaded.
  3. On resume, a new exclusive snapshot is taken. A is now included in that snapshot as though it predated the job.
  4. If the resumed job is then interrupted or deleted, reconciliation preserves A instead of cleaning it up.

There is a related case when deleting the job while it is paused: because it is no longer the active job, it is removed directly and no reconciliation runs, so a model introduced before the pause can remain resident.

This conflicts with the current lifecycle wording that interrupted or deleted exclusive jobs clean up models they introduced.

Please either:

  • preserve model ownership across pause/resume and reconcile it when the job is later interrupted or deleted, or
  • explicitly define pause as committing all model-side effects up to that point, document that semantic, and add tests covering pause → resume → interrupt and deletion while paused.

Apart from that issue, I do not see another code blocker. Once it is addressed and the very simple conflict is resolved (main at a C++ unitest file update) additively, I would consider the PR merge-ready, assuming the job-engine tests pass on the updated head. The currently known unrelated CI failure should not block it.

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the actual current head (4306f790) after the conflict resolution, focusing only on merge blockers and ignoring the known unrelated CI failure.

The previously reported pause/resume ownership issue is improved, but I still see two blocking lifecycle cases:

  1. Cleanup can unload models introduced externally while the job is paused.

    The job keeps its original model snapshot across pause/resume, while pausing releases the exclusive gate and allows normal model traffic.

    Example:

    • the job starts with no model and loads A,
    • the job is paused,
    • an external request loads B,
    • the job is deleted while paused, or resumed and then interrupted.

    Reconciliation compares the current model set against the original job snapshot and unloads everything not in that snapshot. This removes both job-owned A and externally loaded B.

    Cleanup therefore needs explicit per-job ownership tracking, rather than treating every post-snapshot model as job-owned.

  2. An interrupted job may no longer be resumable after cleanup.

    Example:

    load A -> chat using A
    

    If the chat step is interrupted:

    • the chat step is reset to pending,
    • reconciliation unloads A because the job introduced it,
    • resume starts again at the chat step,
    • the chat fails because the completed load step is not replayed.

    The same applies when interruption happens during a later step after a successful load.

    Resume must either restore the job-owned model state or rewind to a safe step that reloads the required model.

These affect external model residency and the core interrupt/resume contract, so I consider them merge blockers. Apart from these two cases, I did not find another blocker in the current head. The known unrelated CI failure should not block this PR.

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, found only one remaining detail:

The model ownership and restore state is kept only in memory.

JobModelState lives in the server-local job_states map and is not serialized with the job record. After a server restart, active jobs are recovered as interrupted with their cursor intact, but the corresponding ownership and captured model configuration are gone.

Example:

load A -> chat using A
          server crashes during chat
          restart
          resume

After restart:

  • the job is restored as interrupted,
  • the chat step remains pending,
  • job_states is empty,
  • restore_exclusive() has nothing to restore,
  • resume re-runs the chat step without model A loaded.

The existing persistence test uses only a sleep step, so it does not cover this model-dependent resume case.

Please either persist the job-owned model restore state with the job, or reconstruct it safely from the completed steps before resuming after restart. A regression test should cover load -> later step -> hard restart -> resume and verify that the dependent step completes successfully.

Apart from this restart/resume issue, I do not see another code blocker in the current head. The known unrelated CI issue should not block the PR.

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! Let's see if we can get through the CI trouble...

@fl0rianr
fl0rianr enabled auto-merge July 17, 2026 14:00
@fl0rianr
fl0rianr added this pull request to the merge queue Jul 17, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Jul 17, 2026
pwilkin and others added 10 commits July 17, 2026 19:49
…ion evaluator, graph validation

Phase 1 of the generic job engine (own PR). Header-only and unit-tested with
no server dependency:
- job_types.h: Job/StepRecord/Case model with JSON round-tripping.
- job_expr.h: ${ref} resolution + the when/branch evaluator (references,
  literals, comparisons, boolean logic, basic arithmetic; no arbitrary code).
- job_graph.h: step-graph validation — unique ids, known ops, forward-only
  jump targets (forward-only ⇒ acyclic ⇒ no loops).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
Document the when/branch condition grammar and ${ref} resolution: paths,
type-preserving vs interpolating params, literals, operator precedence,
truthiness, comparison/arithmetic semantics, errors, the non-short-circuit
caveat, and worked examples. EBNF included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
Add the generic job engine on top of the Phase 1 pure core: a single
persistent worker runs one client-posted job at a time, passing data
forward through the job context, evaluating forward-only branches, and
persisting every transition to <cache_dir>/jobs.json (atomic temp+rename,
50-job cap). A restart marks in-flight jobs interrupted but resumable from
their cursor.

- OpHandler/OpRegistry plus read-only ops (system_info, system_stats,
  models, sleep), built from injected providers so job_ops does not depend
  on the whole Server.
- JobManager lifecycle: create/list/get/pause/interrupt/resume/remove.
- Seven quad-prefixed HTTP handlers; path-param routes registered manually
  across /api/v0,/api/v1,/v0,/v1.
- test/server_jobs.py drives the full lifecycle end-to-end with an isolated
  lemond (no model load), including kill-and-restart persistence recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
INFO on create/run/complete/pause/interrupt/resume/delete and startup
recovery; WARN with the failure reason on on_fail continue/goto and on
persist/load errors; ERROR on job failure; DEBUG for per-step
running/completed/skipped. Category 'Jobs'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
Add the load/unload/chat exclusive ops and a Router-level exclusive gate
keyed by owner thread so a running job queues all normal load/inference
traffic behind it, plus cancel-of-in-flight-load.

- Router: exclusive_active_/exclusive_owner_/exclusive_cv_ under load_mutex_;
  begin_exclusive/end_exclusive and wait_for_slot_clearance inserted at
  load_model, unload_model, execute_inference, execute_streaming.
- WrappedServer::set_load_cancel_flag + wait_for_ready abort-and-kill;
  load_model threads a cancel flag and skips the nuclear retry on cancel.
- OpProviders/OpRegistry gain load/unload/chat callbacks and begin/end
  exclusive; JobManager holds the slot for any job with an exclusive step,
  releasing it exactly once via a guard on every exit path.
- server_jobs.py: real load->chat->unload job (asserts timings/usage),
  queue-behind-job timing test, interrupt-mid-job clean-release test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
An interrupted exclusive job abandons its run and could leave a model
loaded. Reconcile by unloading (router unload-all) while the worker still
owns the slot, before releasing the gate. Test asserts /health reports no
model loaded after interrupt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
Post the AutoOpt methodology as a job: two configs, each unload->load(args)
->timed chat->unload, extract timings into context, then branch on the
measured tps to pick a winner. Proves the engine runs the real bench recipe
end-to-end with no engine changes. Also asserts no model left resident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
…ase 5)

Add docs/dev/job-system.md (concepts, ops, recipe schema, control flow,
lifecycle, exclusivity/queuing, persistence, API, bench example); a Job
Engine API section in docs/api/lemonade.md; and the jobs endpoints in
AGENTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
Strip all comments from the job-engine sources and tests, and remove the
comments added to the shared router/wrapped_server/server files. The
concepts they described are documented in docs/dev/job-system.md and
docs/dev/job-expression-language.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
- GET /jobs no longer 405s: exclude jobs from the POST GET-fallback
- delete of an active job defers erase until the worker finishes cleanup
  (reconcile unload), so exclusive jobs never leak a resident model
- chat interrupt is now a genuine in-flight HTTP abort (curl XFERINFO)
  threaded via chat_op -> Router::chat_completion -> forward_request
- interrupt cleanup unloads only models the job loaded: exclusive session
  snapshots the loaded set at begin and reconcile unloads (current - snapshot),
  leaving pre-existing and pinned models
- load gate uses one combined predicate wait on load_cv_ so an exclusive
  session that begins mid-wait cannot be bypassed; begin/end_exclusive notify it
- nuclear retry sets the load cancel flag so interrupt aborts backend startup
- expression validation runs the full parser (syntax-only) to reject
  malformed when/branch expressions at creation, deferring ref/type errors
- id_counter restored to max parsed suffix on load; pending steps marked
  skipped at completion; step ids reject 'inputs' and '.'
- CI builds and runs JobExprTest/JobGraphTest and server_jobs.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
pwilkin and others added 15 commits July 17, 2026 19:51
…lusive snapshot

Two concurrency defects found by adversarial review of the review-fix commit:

- The chat cancel flag lived on a shared per-server member, so a concurrent
  non-job chat to the same model adopted the job's flag (aborted on interrupt)
  and the RAII reset raced an HTTP thread reading the non-atomic pointer. Move
  it to a per-thread (thread_local) pointer: each request thread carries its
  own, no sharing, no race.
- begin_exclusive set the gate but did not drain an in-flight concurrent load,
  so a model that finished loading just after the job claimed the slot was
  missing from the snapshot and got evicted by the interrupt reconcile. Wait
  for is_loading_ to clear before returning so the snapshot is complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
…cleanup, CI

- persist jobs.json via atomic_replace_file (MoveFileExW on Windows,
  rename on POSIX, copy+remove cross-device fallback)
- reconcile-unload now evicts job-introduced models even if pinned;
  only pre-job snapshot models are preserved
- clear load_cancel pointer after every load via RAII guard
- run server_jobs.py in the Linux PR CI job
- tests: multi-write persistence, pinned-model delete/interrupt cleanup,
  preexisting-pinned survival

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
Adversarial review of the round-2 fixes found:
- LoadCancelGuard (function scope) wrote set_load_cancel_flag(nullptr) into a
  WrappedServer already freed during exception unwinding on every load-failure
  path (file-not-found, cancelled, retry failure) — a UAF write on a hot error
  path. Clear the flag inline while each server is still alive, right after its
  load attempt; drop the guard.
- persist_locked promoted the temp without checking the ofstream, so a partial
  write (disk full) could atomically replace a good jobs.json with truncated
  JSON. Verify the stream succeeded before replacing; drop the temp otherwise.
- atomic_replace_file did not propagate the copy-fallback error_code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
Removed source section from job-system documentation.
… full exclusivity, scoped cleanup, cap, progress

- load op: drop the late-cancel global unload; snapshot reconciliation cleans up
- active-job DELETE persists a tombstone before the ack; restarts drop
  tombstoned jobs and they are invisible to GET/list immediately
- pause/interrupt on a queued job dequeues and persists the new status
  immediately instead of waiting for the worker
- begin_exclusive drains in-flight requests after closing the gate; tokenize,
  slots, get_slots, and set_model_pinned now check the same gate
- the exclusive snapshot records pin state and reconcile restores it on
  surviving pre-job models; the docs state the preserve-if-still-resident
  guarantee explicitly (evicted models are not reloaded)
- POST jobs returns 429 when the 50-job store has nothing terminal to evict
- summary progress counts handled failures (on_fail continue/goto) as processed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
…ct, cancellable drain, tombstone scrub, test hardening

- the idle-eviction/downsize engine suspends during exclusive sessions, and
  evict_if_committed rescues a model that got (re-)pinned or caught by an
  exclusive session after its EVICTING mark — fixes the reconcile-repin TOCTOU
- begin_exclusive(cancel) polls the job's cancel flag while draining, so
  interrupt/delete/shutdown abort the acquisition instead of blocking behind a
  long-running stream; an aborted acquisition marks the job interrupted and
  never runs a stale-snapshot reconcile
- the job cap counts only live jobs (a tombstoned active job no longer 429s
  creation), and startup scrubs tombstoned payloads from jobs.json
- tests: pin-restore of a surviving pre-job model is now asserted end-to-end;
  the durable-delete test asserts the tombstone on disk before the crash;
  the drain test tolerates client-read lag; real-backend tests self-install
  the cpu backend once and seed it into each test cache so they run in CI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
…pdate in place

- load_from_disk closes jobs.json before the tombstone-scrub persist, so the
  Windows replace does not hit a sharing violation on the startup it targets
- 'pinned' no longer participates in the option-change reload diff: it never
  reaches the subprocess command line, so a pin-only load updates the pin in
  place instead of evicting and reloading the model
- the pin-restore test asserts PID stability so it fails if the pre-job model
  is reloaded rather than surviving; the one-time backend install records
  failed attempts and handles request timeouts instead of retrying per test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
set_pinned now updates recipe_options_ alongside the pinned_ flag (guarded by
state_mutex_, since recipe_options_ is no longer immutable after publish), so
/health no longer self-contradicts after an in-place pin update and a *_bin
hot-swap reload cannot resurrect a cleared pin from stale stored options. Also
fixes the same pre-existing mismatch in the set_model_pinned endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
…used reconciles

The exclusive snapshot is now per-job and captured once, the first time the
job acquires the slot — a pause/resume cycle no longer re-baselines it, so a
model the job loaded before pausing is still recognized as job-introduced and
cleaned up on a later interrupt or delete. Deleting a paused or interrupted
job routes through the worker for the same snapshot reconcile instead of being
erased without cleanup. Jobs that end completed/failed commit their model
side-effects (snapshot discarded). Reconcile is keyed by job id and is a no-op
for jobs that never held the slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
…loads

set_pinned records the option only for a genuine pin and erases it otherwise —
an absent key already means unpinned, so the flag/options consistency for the
*_bin hot-swap is preserved while auto-load keeps its request-scoped-field
contract (server_endpoints test_013, test_ollama test_015a).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
…al residency

Reconcile no longer treats every post-snapshot model as job-owned. Ownership
is explicit and scoped to the specific residency the job created: the load op
records the backend instance (pid) it produced, the unload op relinquishes
ownership, and reconcile unloads a resident model only when it is that same
instance — so external loads of any model, including the same name while the
job is paused or interrupted and the gate is open, are never touched by job
cleanup. Pre-job snapshot membership still takes precedence (residents
preserved, pin state restored).

Interrupt cleanup records each owned instance's options and pin before
unloading, and resume reloads them (cancellable) before re-running from the
cursor, so a step that depends on an earlier completed load — e.g. a chat
interrupted mid-generation — finds its model again. If an external client
re-loaded the same model meanwhile, the restore adopts that residency instead
of replacing it and the model stops being job-owned. A failed restore logs and
lets the dependent step fail with its normal on_fail semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
…estart resume

Ownership and captured restore state are in-memory, so a hard restart lost
them: a job recovered as interrupted would resume its pending step without the
model an earlier completed load had established. Resume now also receives a
manifest reconstructed from the job's persisted record — completed load and
unload steps in execution order (forward-only, so list order), with parameters
resolved against the persisted context. In-memory captured entries take
precedence when present; manifest leftovers are loaded, or adopted if an
external client already loaded the model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z9WMqprhqjWEDuv1gnGaH
@fl0rianr
fl0rianr enabled auto-merge July 17, 2026 18:03
@fl0rianr
fl0rianr added this pull request to the merge queue Jul 17, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 17, 2026
@fl0rianr
fl0rianr added this pull request to the merge queue Jul 17, 2026
Merged via the queue into lemonade-sdk:main with commit b7168dc Jul 17, 2026
93 checks passed
@pwilkin

pwilkin commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Yaaaay!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants