Skip to content

Commit a0aa1b2

Browse files
pwilkinclaude
andcommitted
fix(jobs): job model ownership survives pause/resume; delete-while-paused 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
1 parent 54b327a commit a0aa1b2

6 files changed

Lines changed: 185 additions & 31 deletions

File tree

docs/dev/job-system.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,23 @@ A single worker runs one job at a time.
7777
- **pause** — stop *after the current step*; the job goes `paused` at the next
7878
step boundary and (if exclusive) releases the slot. Pausing a still-`queued`
7979
job takes effect immediately: it is removed from the queue and persisted as
80-
`paused` before the call returns.
80+
`paused` before the call returns. Pause does **not** commit the job's model
81+
side-effects: models the job loaded stay resident for the resume, and the
82+
job keeps owning them (see below).
8183
- **interrupt** — cancel the *current step now* (kills an in-flight `load`, and
8284
aborts an in-flight `chat` at the HTTP layer rather than waiting for the
8385
backend to reply); the step returns to `pending`, the job goes `interrupted`.
8486
Interrupting a still-`queued` job likewise dequeues and persists it as
8587
`interrupted` immediately. An interrupted exclusive job unloads only the
8688
model(s) it introduced before releasing the slot: the reconcile snapshots the
87-
router's loaded models (with their pin state) when the exclusive session
88-
begins, unloads `(current set − snapshot)` — including models the job pinned
89-
itself — and restores the recorded pin state of surviving pre-job models.
89+
router's loaded models (with their pin state) **once, the first time the job
90+
acquires the exclusive slot**, and that snapshot is the job's for its whole
91+
lifetime — a pause/resume cycle does not re-baseline it, so a model the job
92+
loaded before pausing is still recognized as job-introduced after resume.
93+
Reconcile unloads `(current set − snapshot)` — including models the job
94+
pinned itself — and restores the recorded pin state of surviving pre-job
95+
models. A job that ends `completed` or `failed` commits its model
96+
side-effects instead (its snapshot is discarded, resident models stay).
9097
**Guarantee scope:** a model that was resident before the job is preserved
9198
only if it is *still resident* at reconcile time. If a job `load` evicted it
9299
(loaded-model cap) or replaced it with different options, the reconcile does
@@ -97,10 +104,12 @@ A single worker runs one job at a time.
97104
- **delete** — removes the job. Deleting an active job persists a deletion
98105
tombstone *before* the call returns, then interrupts it and defers the actual
99106
removal until the worker has finished cleanup (reconcile unload), so a deleted
100-
exclusive job never leaks a resident model. The tombstone makes the deletion
101-
durable: a crash between the acknowledgement and the final removal does not
102-
resurrect the job on restart, and a tombstoned job is already invisible to
103-
`GET`/list.
107+
exclusive job never leaks a resident model. Deleting a `paused` or
108+
`interrupted` job takes the same tombstone-then-worker-cleanup path, so a
109+
model introduced before a pause is still unloaded. The tombstone makes the
110+
deletion durable: a crash between the acknowledgement and the final removal
111+
does not resurrect the job on restart, and a tombstoned job is already
112+
invisible to `GET`/list.
104113
- **query** — the full job record (status, per-step state, context).
105114

106115
Chat interrupts are genuine aborts: the chat op passes its cancel flag through

src/cpp/include/lemon/jobs/job_ops.h

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ class OpRegistry {
2424
const OpHandler* find(const std::string& name) const;
2525
std::set<std::string> names() const;
2626

27-
std::function<bool(CancelFlag*)> begin_exclusive;
27+
std::function<bool(const std::string& job_id, CancelFlag*)> begin_exclusive;
2828
std::function<void()> end_exclusive;
2929

30-
std::function<void()> reconcile_unload;
30+
std::function<void(const std::string& job_id)> reconcile_unload;
31+
std::function<void(const std::string& job_id)> discard_exclusive;
3132

3233
private:
3334
std::map<std::string, OpHandler> handlers_;
@@ -43,9 +44,10 @@ struct OpProviders {
4344
std::function<json(const json& params, CancelFlag& cancel)> unload_op;
4445
std::function<json(const json& params, CancelFlag& cancel)> chat_op;
4546

46-
std::function<bool(CancelFlag*)> begin_exclusive;
47+
std::function<bool(const std::string& job_id, CancelFlag*)> begin_exclusive;
4748
std::function<void()> end_exclusive;
48-
std::function<void()> reconcile_unload;
49+
std::function<void(const std::string& job_id)> reconcile_unload;
50+
std::function<void(const std::string& job_id)> discard_exclusive;
4951
};
5052

5153
OpRegistry build_op_registry(OpProviders providers);

src/cpp/server/jobs/job_manager.cpp

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,17 @@ bool JobManager::remove(const std::string& id, bool& active_out) {
359359
<< " (tombstoned; erased after cleanup)" << std::endl;
360360
return true;
361361
}
362+
if (it->second.status == JobStatus::Paused || it->second.status == JobStatus::Interrupted) {
363+
it->second.deleted = true;
364+
control_for_locked(id)->delete_requested.store(true);
365+
persist_locked();
366+
enqueue_locked(id);
367+
cv_.notify_all();
368+
LOG(INFO, "Jobs") << "delete requested for " << to_string(it->second.status)
369+
<< " job " << id << " (tombstoned; erased after reconcile)"
370+
<< std::endl;
371+
return true;
372+
}
362373
jobs_.erase(id);
363374
controls_.erase(id);
364375
order_.erase(std::remove(order_.begin(), order_.end(), id), order_.end());
@@ -373,6 +384,7 @@ void JobManager::worker_main() {
373384
std::string id;
374385
std::shared_ptr<Control> ctrl;
375386
bool exclusive = false;
387+
bool cleanup_only = false;
376388
{
377389
std::unique_lock<std::mutex> lock(mutex_);
378390
cv_.wait(lock, [&] { return stop_ || !queue_.empty(); });
@@ -381,23 +393,41 @@ void JobManager::worker_main() {
381393
id = queue_.front();
382394
queue_.pop_front();
383395
auto it = jobs_.find(id);
384-
if (it == jobs_.end() || it->second.status != JobStatus::Queued) continue;
396+
if (it == jobs_.end()) continue;
385397
ctrl = control_for_locked(id);
386-
it->second.status = JobStatus::Running;
387-
if (it->second.started_at.empty()) it->second.started_at = iso_now();
388-
active_id_ = id;
389-
exclusive = job_needs_exclusive(it->second, registry_);
398+
if (it->second.deleted) {
399+
cleanup_only = true;
400+
} else if (it->second.status != JobStatus::Queued) {
401+
continue;
402+
} else {
403+
it->second.status = JobStatus::Running;
404+
if (it->second.started_at.empty()) it->second.started_at = iso_now();
405+
active_id_ = id;
406+
exclusive = job_needs_exclusive(it->second, registry_);
407+
persist_locked();
408+
LOG(INFO, "Jobs") << "running job " << id << " from step '" << it->second.cursor
409+
<< "'" << std::endl;
410+
}
411+
}
412+
413+
if (cleanup_only) {
414+
if (registry_.reconcile_unload) registry_.reconcile_unload(id);
415+
std::lock_guard<std::mutex> lock(mutex_);
416+
jobs_.erase(id);
417+
controls_.erase(id);
418+
order_.erase(std::remove(order_.begin(), order_.end(), id), order_.end());
419+
queue_.erase(std::remove(queue_.begin(), queue_.end(), id), queue_.end());
390420
persist_locked();
391-
LOG(INFO, "Jobs") << "running job " << id << " from step '" << it->second.cursor << "'"
392-
<< std::endl;
421+
LOG(INFO, "Jobs") << "erased deleted job " << id << " after reconcile" << std::endl;
422+
continue;
393423
}
394424

395425
struct ExclusiveGuard {
396426
const OpRegistry& reg;
397427
const std::string& id;
398428
bool held = false;
399429
bool begin(CancelFlag* cancel) {
400-
if (reg.begin_exclusive && !reg.begin_exclusive(cancel)) return false;
430+
if (reg.begin_exclusive && !reg.begin_exclusive(id, cancel)) return false;
401431
held = true;
402432
LOG(INFO, "Jobs") << "job " << id << " acquired exclusive slot" << std::endl;
403433
return true;
@@ -421,18 +451,23 @@ void JobManager::worker_main() {
421451
<< " interrupted while waiting for the exclusive slot" << std::endl;
422452
}
423453
bool interrupted = false;
454+
bool terminal = false;
424455
{
425456
std::lock_guard<std::mutex> lock(mutex_);
426457
auto it = jobs_.find(id);
427-
interrupted = it != jobs_.end() && it->second.status == JobStatus::Interrupted;
458+
if (it != jobs_.end()) {
459+
interrupted = it->second.status == JobStatus::Interrupted;
460+
terminal = is_terminal(it->second.status);
461+
}
428462
active_id_.clear();
429463
}
430464

431-
if (guard.held && interrupted && registry_.reconcile_unload) {
432-
registry_.reconcile_unload();
433-
LOG(INFO, "Jobs") << "job " << id << " interrupted — unloaded resident model(s)"
465+
if (interrupted && registry_.reconcile_unload) {
466+
registry_.reconcile_unload(id);
467+
LOG(INFO, "Jobs") << "job " << id << " interrupted — reconciled job-loaded model(s)"
434468
<< std::endl;
435469
}
470+
if (terminal && registry_.discard_exclusive) registry_.discard_exclusive(id);
436471

437472
if (ctrl->delete_requested.load()) {
438473
std::lock_guard<std::mutex> lock(mutex_);

src/cpp/server/jobs/job_ops.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ OpRegistry build_op_registry(OpProviders providers) {
7373
reg.begin_exclusive = providers.begin_exclusive;
7474
reg.end_exclusive = providers.end_exclusive;
7575
reg.reconcile_unload = providers.reconcile_unload;
76+
reg.discard_exclusive = providers.discard_exclusive;
7677

7778
return reg;
7879
}

src/cpp/server/server.cpp

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -500,24 +500,38 @@ Server::Server(std::shared_ptr<RuntimeConfig> config, const std::string& cache_d
500500
}
501501
return lemon::jobs::json::parse(response.dump());
502502
};
503-
auto exclusive_snapshot = std::make_shared<std::map<std::string, bool>>();
503+
auto job_snapshots =
504+
std::make_shared<std::map<std::string, std::map<std::string, bool>>>();
504505
auto snapshot_mutex = std::make_shared<std::mutex>();
505-
providers.begin_exclusive = [this, exclusive_snapshot, snapshot_mutex](
506+
providers.begin_exclusive = [this, job_snapshots, snapshot_mutex](
507+
const std::string& job_id,
506508
lemon::jobs::CancelFlag* cancel) -> bool {
507509
if (!router_->begin_exclusive(cancel)) return false;
508-
auto snap = router_->snapshot_loaded_models();
509510
std::lock_guard<std::mutex> lk(*snapshot_mutex);
510-
*exclusive_snapshot = std::move(snap);
511+
if (!job_snapshots->count(job_id))
512+
(*job_snapshots)[job_id] = router_->snapshot_loaded_models();
511513
return true;
512514
};
513515
providers.end_exclusive = [this] { router_->end_exclusive(); };
514-
providers.reconcile_unload = [this, exclusive_snapshot, snapshot_mutex] {
516+
providers.reconcile_unload = [this, job_snapshots, snapshot_mutex](
517+
const std::string& job_id) {
515518
std::map<std::string, bool> keep;
519+
bool have = false;
516520
{
517521
std::lock_guard<std::mutex> lk(*snapshot_mutex);
518-
keep = *exclusive_snapshot;
522+
auto it = job_snapshots->find(job_id);
523+
if (it != job_snapshots->end()) {
524+
keep = std::move(it->second);
525+
job_snapshots->erase(it);
526+
have = true;
527+
}
519528
}
520-
router_->unload_models_not_in(keep);
529+
if (have) router_->unload_models_not_in(keep);
530+
};
531+
providers.discard_exclusive = [job_snapshots, snapshot_mutex](
532+
const std::string& job_id) {
533+
std::lock_guard<std::mutex> lk(*snapshot_mutex);
534+
job_snapshots->erase(job_id);
521535
};
522536
job_manager_ = std::make_unique<lemon::jobs::JobManager>(
523537
lemon::utils::get_cache_dir(), lemon::jobs::build_op_registry(std::move(providers)));

test/server_jobs.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,99 @@ def test_pin_state_of_preexisting_model_restored_after_job(self):
10651065
"the pre-job model did not actually survive the job (it was reloaded)",
10661066
)
10671067

1068+
def test_job_ownership_survives_pause_resume_interrupt(self):
1069+
backend = self.require_real_backend()
1070+
steps = [
1071+
{
1072+
"id": "ld",
1073+
"op": "load",
1074+
"params": {
1075+
"model": TEST_MODEL,
1076+
"llamacpp_backend": backend,
1077+
"ctx_size": 2048,
1078+
},
1079+
},
1080+
{"id": "hold", "op": "sleep", "params": {"ms": 4000}},
1081+
{"id": "hold2", "op": "sleep", "params": {"ms": 20000}},
1082+
]
1083+
job = self.create_job("pause-resume-interrupt", steps)
1084+
jid = job["id"]
1085+
self.poll_cursor(jid, "hold", timeout=60)
1086+
self.assertEqual(
1087+
requests.get(f"{BASE}/health", timeout=5).json().get("model_loaded"),
1088+
TEST_MODEL,
1089+
)
1090+
1091+
r = requests.post(f"{BASE}/jobs/{jid}/pause", timeout=10)
1092+
self.assertEqual(r.status_code, 200, r.text)
1093+
self.poll_status(jid, "paused", timeout=15)
1094+
self.assertEqual(
1095+
requests.get(f"{BASE}/health", timeout=5).json().get("model_loaded"),
1096+
TEST_MODEL,
1097+
"pause must keep the job's models resident for the resume",
1098+
)
1099+
1100+
r = requests.post(f"{BASE}/jobs/{jid}/resume", timeout=10)
1101+
self.assertEqual(r.status_code, 200, r.text)
1102+
self.poll_cursor(jid, "hold2", timeout=30)
1103+
1104+
r = requests.post(f"{BASE}/jobs/{jid}/interrupt", timeout=10)
1105+
self.assertEqual(r.status_code, 200, r.text)
1106+
self.poll_status(jid, "interrupted", timeout=20)
1107+
self.assertNotEqual(
1108+
requests.get(f"{BASE}/health", timeout=5).json().get("model_loaded"),
1109+
TEST_MODEL,
1110+
"a pause/resume cycle re-baselined the snapshot: the interrupt "
1111+
"preserved a model the job itself introduced",
1112+
)
1113+
1114+
def test_delete_while_paused_cleans_up(self):
1115+
backend = self.require_real_backend()
1116+
steps = [
1117+
{
1118+
"id": "ld",
1119+
"op": "load",
1120+
"params": {
1121+
"model": TEST_MODEL,
1122+
"llamacpp_backend": backend,
1123+
"ctx_size": 2048,
1124+
},
1125+
},
1126+
{"id": "hold", "op": "sleep", "params": {"ms": 4000}},
1127+
{"id": "hold2", "op": "sleep", "params": {"ms": 20000}},
1128+
]
1129+
job = self.create_job("delete-while-paused", steps)
1130+
jid = job["id"]
1131+
self.poll_cursor(jid, "hold", timeout=60)
1132+
1133+
r = requests.post(f"{BASE}/jobs/{jid}/pause", timeout=10)
1134+
self.assertEqual(r.status_code, 200, r.text)
1135+
self.poll_status(jid, "paused", timeout=15)
1136+
self.assertEqual(
1137+
requests.get(f"{BASE}/health", timeout=5).json().get("model_loaded"),
1138+
TEST_MODEL,
1139+
)
1140+
1141+
r = requests.delete(f"{BASE}/jobs/{jid}", timeout=10)
1142+
self.assertEqual(r.status_code, 200, r.text)
1143+
self.assertEqual(self.get_job(jid).status_code, 404)
1144+
self.poll_gone(jid)
1145+
1146+
deadline = time.time() + 20
1147+
while time.time() < deadline:
1148+
loaded = (
1149+
requests.get(f"{BASE}/health", timeout=5).json().get("model_loaded")
1150+
)
1151+
if loaded != TEST_MODEL:
1152+
break
1153+
time.sleep(0.2)
1154+
self.assertNotEqual(
1155+
requests.get(f"{BASE}/health", timeout=5).json().get("model_loaded"),
1156+
TEST_MODEL,
1157+
"deleting a paused job skipped reconciliation and leaked the "
1158+
"model the job introduced",
1159+
)
1160+
10681161
def test_bench_shaped_sweep(self):
10691162
backend = self.require_real_backend()
10701163

0 commit comments

Comments
 (0)