feat(cli): report the real exit code and really stop what uipath server runs - #1842
robert-ursu wants to merge 2 commits into
Conversation
ffcf810 to
bca74f9
Compare
114a204 to
71c81e9
Compare
bca74f9 to
b05d195
Compare
b05d195 to
1df44eb
Compare
6c8af15 to
e509941
Compare
1df44eb to
e14f4b2
Compare
e509941 to
db4653c
Compare
e14f4b2 to
50e0cc7
Compare
50e0cc7 to
0654cec
Compare
0654cec to
7e4af34
Compare
7e4af34 to
5c4ea34
Compare
5c4ea34 to
37cc492
Compare
🚨 Heads up:
|
195d328 to
3c195d6
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
A cancellation race can incorrectly report self-cancellation as an externally requested stop, and HTTP stop validation has type-safety gaps.
Review effort: Balanced
Findings: 3
Open (4)
What changed in this PR
Improves CLI server exit-code reporting and adds cooperative job cancellation across HTTP and IPC transports.
Changes:
- Propagates real Click exit codes and maps runtime statuses safely.
- Adds shared execution control and stop endpoints.
- Expands cancellation, transport, and discovery-failure tests.
| File | Description |
|---|---|
packages/uipath/pyproject.toml |
Bumps package version. |
packages/uipath/uv.lock |
Updates locked package version. |
packages/uipath/src/uipath/_cli/_execution_control.py |
Adds cross-thread execution cancellation. |
packages/uipath/src/uipath/_cli/_server_core.py |
Tracks, stops, and reports executions. |
packages/uipath/src/uipath/_cli/_job_api.py |
Maps stopped and unknown statuses. |
packages/uipath/src/uipath/_cli/cli_server.py |
Adds HTTP stop and exit-code responses. |
packages/uipath/src/uipath/_cli/cli_server_ipc.py |
Implements IPC job stopping. |
packages/uipath/src/uipath/_cli/cli_run.py |
Enables controlled run cancellation. |
packages/uipath/src/uipath/_cli/cli_eval.py |
Enables controlled evaluation cancellation. |
packages/uipath/src/uipath/_cli/cli_debug.py |
Enables controlled debug cancellation. |
packages/uipath/tests/cli/test_server_cancellation.py |
Covers cancellation behavior and transports. |
packages/uipath/tests/cli/test_server.py |
Tests HTTP exit codes. |
packages/uipath/tests/cli/test_server_job_core.py |
Tests core outcomes and teardown ordering. |
packages/uipath/tests/cli/test_server_ipc.py |
Updates IPC stop and mock coverage. |
packages/uipath/tests/cli/test_run.py |
Verifies discovery failures exit nonzero. |
packages/uipath/tests/cli/test_job_api.py |
Tests new status mappings. |
packages/uipath/tests/cli/eval/test_eval_discovery.py |
Verifies evaluation discovery failures. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if worker.cancelled() or isinstance(worker.exception(), asyncio.CancelledError): | ||
| if execution.control.cancel_requested: | ||
| return _stopped_outcome() |
| resume_version = get_field(message, "resumeVersion", "ResumeVersion") | ||
| if resume_version is not None and not isinstance(resume_version, int): |
| }, | ||
| status=400, | ||
| ) | ||
| force = get_field(message, "forceStop", "ForceStop") is True |
| @@ -0,0 +1,99 @@ | |||
| """Lets the server cancel the execution that runs on its worker thread. | |||
|
|
|||
| run/debug/eval drive their own event loop inside the ``asyncio.to_thread`` worker. That | |||
3c195d6 to
350a70c
Compare
Under standalone_mode=False click returns ctx.exit(N)'s code instead of raising, so every ConsoleLogger.error path came back as a result and _run_command_isolated reported ExitCode 0. Map a returned int to the exit code. The HTTP start reply now carries exitCode, the field the .NET HTTP client already reads (it defaulted a missing one to 0, so every 200 was a success). StopJob returns False: this server cannot stop what it runs, and the handler must not be told it did. A result status the IPC sink does not know is reported as Faulted instead of Successful, and "stopped" maps to the wire's Stopped. Entrypoint discovery failures in `uipath run` and `uipath eval` exit 1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
run/debug/eval drive their own event loop on the server's worker thread. They
now publish it through run_execution_loop to an ExecutionControl, so a stop
cancels the execution's root task and the runtime unwinds cooperatively,
still writing its result.
stop_execution backs both IPC StopJob and the new POST /jobs/{key}/stop. It
cancels the root task, waits a grace period, cancels every task on the
execution's loop, and answers False if it is still running, because it is
blocked in a call that only ending the process can interrupt. forceStop
shortens the waits. A queued execution is dropped before it runs. A stop that
targets another resume version, or an unknown key, answers True: that run is
not running.
The lock, env and cwd are no longer handed on while the execution's thread
still runs. A cancelled caller (a dropped IPC connection, a shutdown) stops
the execution and re-raises only once the thread has exited, and the job
scope's teardown completes before the env is restored.
A stopped execution ends with exit code 143 and "Stopped on request"; a
CancelledError it raised on its own is reported as an unexpected failure.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
350a70c to
2226dfe
Compare
|
🚨 Heads up:
|





Pairs with UiPath/hdens#7868, which makes the handler send the stop, act on the answer, and kill and restart the server when a job cannot be stopped.
Why
Exit codes
A failed execution inside
uipath serverwas reported as a success, on both transports:ConsoleLogger.errorends inclick.get_current_context().exit(1). Understandalone_mode=Falseclick returns that code instead of raising (click/core.py,except Exit)._run_command_isolatedtook the return value as the result and hard-codedExitCode: 0. Everyconsole.errorpath (runtime errors, unexpected exceptions, bad--simulation) came back as exit code 0./jobs/{key}/startreply had noexitCode. The .NETPythonRuntimeClientreadsexitCodeinto anintand treats a missing one as 0, so every HTTP 200 counted as a success, even when the body saidsuccess: false.The handler usually overrides the exit code with the status in
output.jsonor the IPC result. The exit code decides the outcome when neither exists: failures before or outsideUiPathRuntimeContext, and crashes.Stop
uipath servercould not stop what it was running:StopJobwas a no-op that answeredTrue.uipath_ipc/client/connection.py,_teardown).await asyncio.to_thread(...)raised, the lock was released and env/cwd were restored while the execution's thread kept running. The next execution then ran concurrently in a mutated process.What: exit codes
_server_core: anintreturned bycmd.mainis the exit code.run,debugandevalnever return an int of their own.exitCodeis added to every response. An unchanged handler becomes correct against this runtime with no .NET change._job_api: a result status the sink does not know is reported as Faulted, not Successful."stopped"maps to the wire'sStopped(4), which the handler already accepts.uipath run/uipath eval: entrypoint or eval-set discovery failure exits 1 instead of 0.What: how an execution is stopped
run,debugandevaleach drive their own event loop on the worker thread, so an execution is an event loop. They now callrun_execution_loopinstead ofasyncio.run. It publishes the loop and its root task to theExecutionControlcarried on aContextVar. Outside the server (uipath runon a terminal) it isasyncio.rununchanged.stop_execution(job_key, resume_version, force)is shared by IPCStopJoband the newPOST /jobs/{key}/stop(200 {"stopped": bool}).Both a stop and a kill are cooperative and run the same sequence; a kill (
forceStop: true) only has tighter windows:UiPathRuntimeContext.__exit__still writes the result. Wait 30 s, or 5 s for a kill.Falseif the execution still runs. It is typically blocked inside a call that cancellation cannot reach (a sync socket read, a lock wait, a busy loop with noawait), and only ending the process stops it. Whether to do that is the handler's call, since the process and the execution instance belong to it.So a stop answers within about 40 s and a kill within about 10 s, both within the handler's 60 s
StopJobTimeout.Cases that answer
True:Falsethere would invite the handler to kill a resumed run for a stop aimed at the previous one.The lock waits for the thread
_run_command_isolatednow returns or raises only once the worker thread has exited. Cancelling the caller, for example on a dropped connection, stops the execution. TheCancelledErroris re-raised only after that, so the lock, env and cwd are never handed on while the job still runs.The job-scope teardown also completes before the env is restored, so the
xfailintest_server_job_core.pyis removed.Outcomes
ExitCodeError143Stopped on requestCancelledErrorescape1(unexpected)The execution cancelled itselfExit code: NA stopped execution's result document still says Faulted/
ERROR_CancelledError; that is written by uipath-runtime. The handler reports Stopped for an Orchestrator stop regardless of what the runtime wrote.Worth a careful look
run_in_executorfuture, not a task. A task re-raises the execution'sSystemExitinto the server loop, and_run_command_isolatedturnsSystemExitinto an exit code.to_threadwork (sync LangGraph nodes, sync HTTP clients) runs itsfinallyat once. But the execution'sasyncio.Runnerdoes not close until that call returns.run_execution_loopwithdraws the loop before the runner closes, so the sweep in step 2 cannot cancel the runner's wait on those threads, which would leak them. Such an execution answersFalseif the call outlives the grace.ExecutionControl.cancel()delivers at most once. A stop followed by a force stop is ordinary, and a second delivery would land inside the cleanup that writesoutput.json.Testing
uv run pytest tests/cli: 1521 passed, 1 skipped, with coverage on as in CI.ruff check,ruff format --checkandmypyare clean.tests/cli/test_server_cancellation.py(18). They use click commands shaped like the real ones and cover:Falseand keeps the lock until its thread exits;StopJobstops a runningRunJob;/startand validates its body.ConsoleLogger.errorreports exit code 1 through_run_command_isolated;success: false, exitCode: 1;"stopped"to Stopped;🤖 Generated with Claude Code
Development Packages
uipath