Skip to content

fix(ipc): update the active-job gauge in the thread job executor - #7920

Open
BlueX888 wants to merge 3 commits into
livekit:mainfrom
BlueX888:fix/active-job-count-thread-executor
Open

BlueX888 wants to merge 3 commits into
livekit:mainfrom
BlueX888:fix/active-job-count-thread-executor

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

fix(ipc): update the active-job gauge in the thread job executor

What breaks

lk_agents_active_job_count never moves for the thread job executor: the gauge reports 0 while a job is running, and stays 0 after it ends. ThreadJobExecutor never calls telemetry.metrics.job_started() / job_ended(), unlike ProcJobExecutor which calls them in launch_job and its supervise task.

The thread executor is the default on Windows (worker.py falls back to JobExecutorType.THREAD) and is forced by the devmode CLI, so on those deployments the active-job metric is permanently zero — dashboards and autoscaling rules that read it see an idle worker while jobs are running.

Root cause

launch_job() (livekit-agents/livekit/agents/ipc/job_thread_executor.py) starts the runner and sends the StartJobRequest without touching the metrics module, and _main_task() completes after _join_fut without decrementing. The gauge is therefore only ever updated by the process executor path.

Fix

Mirror ProcJobExecutor exactly:

  • launch_job(): call metrics.job_started() once the initialized check passes; if sending the StartJobRequest fails, reset _running_job/_job_status and call metrics.job_ended() before re-raising (no leaked increment).
  • _main_task(): after await self._join_fut, if a job was running, call metrics.job_ended().

Tests

New test tests/test_ipc.py::test_thread_executor_updates_active_job_metric spies on telemetry.metrics.job_started/job_ended, runs a real ThreadJobExecutor through _create_thread_proc, and asserts the gauge is incremented while the entrypoint is live and decremented after aclose().

  • On base: fails — AssertionError: job_started must be called while a job is running (both metric functions never called; the job provably runs).
  • With the fix: passes; tests/test_ipc.py + tests/test_ipc_preload.py green (31 passed); ruff clean.

The test is marked no_concurrent (it monkeypatches process-global metric functions) so parallel thread-executor tests cannot pollute the spy. The change does not overlap the in-flight #7899 hunks in the same file (initialize()/aclose()/_monitor_task()); both patches apply independently.

ThreadJobExecutor never called telemetry.metrics.job_started()/job_ended(),
so lk_agents_active_job_count had no writer when jobs ran under
JobExecutorType.THREAD (the Windows default and the console/Jupyter path).
The gauge's only writers were ProcJobExecutor.launch_job/_supervise_task,
leaving the metric at 0 (and the labeled series absent entirely) for the
whole lifetime of thread-executed jobs.

Mirror the process executor's accounting: increment when a job is launched,
roll back the increment if the StartJobRequest cannot be sent, and decrement
once the runner thread exits while a job was assigned.

Add a regression test that spies on job_started()/job_ended() and asserts
the thread executor calls them around a job lifecycle.
@BlueX888
BlueX888 requested a review from a team as a code owner October 18, 2026 12:20
devin-ai-integration[bot]

This comment was marked as resolved.

BlueX888 and others added 2 commits October 19, 2026 04:51
The fake job's entrypoint could complete between launch_job() and the
'started' assertion, letting _main_task record 'ended' first. CI hit that
window; keep the entrypoint parked on a threading.Event until the test has
observed the running state, then release it and assert the final sequence.
The runner can exit while launch_job() is still sending the StartJobRequest:
_main_task then observes the join and decrements the gauge, and the pending
send fails afterwards, so the error path decremented the same job again and
left the gauge negative.

Track the outstanding increment on the executor and settle it through a single
idempotent helper used by both the runner-exit path and the failed-dispatch
path, so whichever sees it first owns the only decrement.

Cancellation of launch_job() is handled explicitly: it can only land at the
send's single suspension point (drain()), after the whole request has been
written to the channel, so the dispatch is kept assigned and settled when the
runner exits instead of being rolled back as unstarted.

Add regression tests for the runner-exit-during-dispatch double decrement and
for a cancelled dispatch.
@BlueX888

Copy link
Copy Markdown
Contributor Author

Follow-up for the two Devin findings on lk_agents_active_job_count.

Failed-dispatch double decrement — fixed. The runner can exit while launch_job() is still sending StartJobRequest. _main_task then observes the join and decrements the gauge, and the pending send fails afterwards, so the error path decremented the same job twice and left the gauge negative. The outstanding increment is now tracked on the executor and settled through one idempotent helper shared by the runner-exit path and the failed-dispatch path; whichever observes it first owns the only decrement. New test test_thread_executor_metric_settles_once_when_runner_exits_during_dispatch pauses the failing send until after the join callback has run: on the previous head it fails with ['started', 'ended', 'ended'], with the fix it passes. test_thread_executor_failed_dispatch_settles_metric_once covers the opposite order (send fails first, runner exit later).

Cancelled dispatch — rebutted, and made explicit. A cancelled launch_job() cannot leave an undelivered phantom job. channel.asend_message() writes the whole frame into the duplex before its only suspension point (StreamWriter.drain()), and cancelling the waiting task does not discard the transport buffer. A probe that cancels the send task after an 8 MiB payload was buffered shows the peer still received all 8388608 bytes. The dispatch therefore stays assigned (never rolled back as unstarted) and _main_task settles the gauge exactly once when the runner exits; CancelledError is now handled explicitly instead of only bypassing the error path by luck. If the channel is actually broken, the send raises DuplexClosed instead, the runner exits, and the same idempotent settle runs once. test_thread_executor_cancelled_dispatch_settles_metric_once covers cancellation after delivery (the job runs, the gauge stays at 1 while it runs, and drops to 0 exactly once at runner exit); it passes on the previous head too — it locks the intended semantics rather than reproducing a leak.

Verification: pytest tests/test_ipc.py tests/test_ipc_preload.py tests/test_worker_config.py — 44 passed; the three focused metric tests repeated 15x; ruff check and format clean.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant