feat(cli): really stop a job that is already executing [PC-4873] - #1842
Draft
robert-ursu wants to merge 3 commits into
Draft
feat(cli): really stop a job that is already executing [PC-4873]#1842robert-ursu wants to merge 3 commits into
robert-ursu wants to merge 3 commits into
Conversation
robert-ursu
force-pushed
the
feat/python-job-cancellation
branch
2 times, most recently
from
August 4, 2026 11:09
ffcf810 to
bca74f9
Compare
robert-ursu
force-pushed
the
feat/async-job-dispatch-and-result-push
branch
from
August 4, 2026 11:13
114a204 to
71c81e9
Compare
robert-ursu
force-pushed
the
feat/python-job-cancellation
branch
from
August 4, 2026 11:18
bca74f9 to
b05d195
Compare
Builds on async dispatch. Until now StopJob could only refuse a running job:
its body runs on a thread via asyncio.to_thread, which cannot be cancelled.
That reasoning missed a fact. run/debug/eval each drive their own event loop
with asyncio.run INSIDE the worker thread (cli_run.py:333, cli_debug.py:269,
cli_eval.py:531), so a job IS an event loop -- and an event loop can be
cancelled. The three call sites now go through `run_job_loop`, which publishes
that loop and its root task to a JobControl carried on a ContextVar.
asyncio.to_thread propagates contextvars, so no monkeypatching is needed, and
outside the server (uipath run on a terminal) it is asyncio.run verbatim.
Cancellation is cooperative, not a thread kill: the runtime's context managers
still unwind, so UiPathRuntimeContext.__exit__ still writes output.json and the
caller's file fallback still works.
The stop ladder:
1. cancel the ROOT task only, then wait STOP_GRACE_SECONDS. Cancelling every
task would land a second CancelledError inside the cleanup that writes
output.json and abort it mid-write.
2. if cleanup itself is stuck, sweep the loop and wait a shorter window.
3. otherwise return False -- a job wedged in a non-cancellable C call (a
socket read inside an LLM request) cannot be stopped, and saying so is
better than claiming a stop that did not happen.
A stopped job reports Stopped, not Faulted. The runtime records it as
FAULTED/ERROR_CancelledError because it sees a CancelledError, which is the
wrong story for a stop the caller asked for, so the result push carries an
explicit `stopped` flag that wins over the document.
Stop is also reachable now: POST /jobs/{job_key}/stop on the HTTP transport,
which carries all current traffic and previously had no way to reach the
registry at all.
Notes for review:
- _invoke_command discriminates the two CancelledErrors via Task.cancelling():
0 means the job's own loop was cancelled (an outcome, swallow it); >0 means
our awaiting task was cancelled (a shutdown, re-raise).
- StopJob takes resume_version as a trailing optional parameter rather than a
DTO: uipath-ipc ignores a surplus wire arg and defaults a missing one, so old
and new peers interoperate both ways. A suspended job resumes under the same
key, so a stop aimed at the previous run must not kill the resumed one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… when asked Two defects in the cancellation path, both found in review. JobControl.cancel() documented that it must be called at most once, but JobRegistry.stop() called it unguarded on every request. A stop followed by a force-stop escalation is ordinary, and the second delivery lands inside the runtime's cleanup finally blocks -- the ones that write output.json -- and aborts them, destroying the fallback the caller relies on. Absorbing the repeat belongs in JobControl, not in every caller. _invoke_command treated any CancelledError with cancelling() == 0 as a user-requested stop. cancelling() reports on the awaiting server task, so a CancelledError the job's own code let escape was reported as exit 143 "stopped on request" with no StopJob in sight -- a fault filed as a clean stop. Gate the classification on the control's own cancel_requested. Also corrects JobRegistry.stop()'s docstring, which still described the pre-cancellation behaviour of refusing to stop executing work.
Same CI gate as the parent commit, applied to the tests this branch adds: give the module-level events a real Event type instead of letting them infer None, annotate the recording callback against the JobReporter protocol, and cast the _FakeRequest stand-ins at the handle_stop call sites.
robert-ursu
force-pushed
the
feat/python-job-cancellation
branch
from
August 4, 2026 16:00
b05d195 to
1df44eb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
StopJobused to be able to do nothing but refuse a running job: its body runs on a thread viaasyncio.to_thread, which cannot be cancelled.That reasoning missed a fact.
run/debug/evaleach drive their own event loop withasyncio.runinside the worker thread (cli_run.py:333,cli_debug.py:269,cli_eval.py:531) — so a job is an event loop, and an event loop can be cancelled.The three call sites now go through
run_job_loop, which publishes that loop and its root task to aJobControlcarried on aContextVar.asyncio.to_threadpropagates contextvars, so no monkeypatching is needed; outside the server (uipath runon a terminal) it isasyncio.runverbatim.Cancellation is cooperative, not a thread kill: the runtime's context managers still unwind, so
UiPathRuntimeContext.__exit__still writesoutput.jsonand the caller's file fallback still works. A test asserts the job'sfinallyactually ran.The stop ladder
Cancel the root task only, wait
STOP_GRACE_SECONDS. Cancelling every task would land a secondCancelledErrorinside the cleanup that writesoutput.jsonand abort it mid-write.If cleanup itself is stuck, sweep the loop and wait a shorter window.
Otherwise return False — a job wedged in a non-cancellable C call (a socket read inside an LLM request) cannot be stopped, and saying so beats claiming a stop that did not happen.
Stopped, not Faulted
The runtime records a cancelled job as
FAULTED/ERROR_CancelledError, which is the wrong story for a stop the caller asked for. The result push carries an explicitstoppedflag that wins over the document.Stop is reachable now
POST /jobs/{job_key}/stopon the HTTP transport — which carries all current traffic and previously had no route to the registry at all. Stop existed only over uipath-ipc, and the pooled server is launched without--ipc-pipe.Worth a careful look
_invoke_commanddiscriminates the twoCancelledErrors viaTask.cancelling():0means the job's own loop was cancelled (an outcome — swallow it, so the lock unwinds and the result document is still read);>0means our awaiting task was cancelled (a shutdown — re-raise, or the shutdown stalls).StopJobis wired to the registry. TheStopJobRequestDTO and itsResumeVersion/ForceStopfields already landed onmainin feat(cli): carry ResumeVersion and ForceStop on the IPC job DTOs [ROBO-5779] #1838 and were a no-op stub; this change makes them do something. A suspended job resumes under the same key, soResumeVersionis what keeps a stop aimed at the previous run from killing the resumed one.(I had originally argued for a trailing optional parameter over a DTO — moot now that the .NET peer sends
StopJobRequest; interop wins.)Testing
tests/cli/test_server_cancellation.py(9 tests) uses commands shaped like the real ones — a click command whose body isrun_job_loop(...)— and asserts a running job really stops, its cleanup runs, the process-wide lock is released for the next job, and an uncancellable job reportsFalse. Fulltests/clisuite green.Known gaps
A job blocked in a C call cannot be interrupted; reported honestly rather than papered over.
No rung-3 hard exit: taking the process down would kill queued jobs too.
🤖 Generated with Claude Code
Review follow-ups
Two defects found reviewing this branch (Codex GPT-5.6), each fixed in its own commit with a test verified to fail beforehand.
A repeated stop aborted the cleanup.
JobControl.cancel()documented that it must be called at most once, butJobRegistry.stop()called it unguarded on every request. A stop followed by a force-stop escalation is ordinary, and the second delivery lands inside the runtime's cleanupfinallyblocks — the ones that writeoutput.json— and aborts them, destroying the fallback the caller relies on. Absorbing the repeat belongs inJobControl, not in every caller.Self-cancellation was reported as a user-requested stop.
_invoke_commandtreated anyCancelledErrorwithcancelling() == 0as our stop landing.cancelling()reports on the awaiting server task, so aCancelledErrorthe job's own code let escape came back as exit 143 "stopped on request" with noStopJobin sight — a fault filed as a clean stop. The classification is now gated on the control's owncancel_requested; anything else is reported as a failure.Jira
PC-4873