Skip to content

feat(cli): dispatch jobs asynchronously and push results/logs to the caller - #1835

Draft
robert-ursu wants to merge 1 commit into
mainfrom
feat/async-job-dispatch-and-result-push
Draft

feat(cli): dispatch jobs asynchronously and push results/logs to the caller#1835
robert-ursu wants to merge 1 commit into
mainfrom
feat/async-job-dispatch-and-result-push

Conversation

@robert-ursu

@robert-ursu robert-ursu commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Draft — paired with UiPath/hdens#7684. Neither side is useful alone. Ship this one first: it is inert until a caller sends resultCallbackSocket.

Summary

uipath server used to block for a whole job and leave its outcome on disk for the caller to find. StartJob now enqueues and returns; the server pushes logs while the job runs and the terminal result when it finishes.

Behaviour is a pure function of the request:

request behaviour
resultCallbackSocket present async dispatch → 202 {"disposition":"accepted"}, result pushed later
absent the original blocking call, byte for byte

That is the whole compatibility story — an older caller cannot send the field, and could not serve the callback if it did. No handshake on the dispatch path.

POST {callback}/api/python/jobs/{jobKey}/result
POST {callback}/api/python/jobs/{jobKey}/logs

Execution stays serialised behind the process-wide lock: a job mutates process globals (logging handlers, OTel providers, env, cwd), so queueing changes who waits, not how many run.

Real cancellation

run/debug/eval each drive their own event loop via asyncio.run inside the to_thread worker — so a job is an event loop. They now go through run_job_loop, which publishes that loop to a JobControl carried on a ContextVar (to_thread propagates contextvars, so no monkeypatching, and it is asyncio.run verbatim when no control is in scope).

StopJob cancels the root task only, which unwinds the runtime cooperatively — its context managers still run, so output.json is still written and the file fallback still works. Cancelling every task would abort that cleanup mid-write. It escalates to a full sweep, then reports False rather than claiming a stop that did not happen.

A cancelled job is reported Stopped, not Faulted: the runtime records it as FAULTED/ERROR_CancelledError, which is the wrong story for a stop the caller asked for.

Also fixes two pre-existing defects

Both made the API result meaningless, and were masked only because the caller overwrote the status from the file afterwards:

  1. _run_command_isolated hardcoded ExitCode: 0, while click returns ctx.exit(N)'s code under standalone_mode=False — so every ConsoleLogger.error path reported success.
  2. The HTTP body now carries exitCode, which is the field the un-upgraded .NET handler already reads. That alone makes an old handler correct against a new runtime with no .NET change.

Worth a careful look

  • Server diagnostics go to a stderr handle bound at import, not ConsoleLogger. ConsoleLogger resolves sys.stdout at call time, and the runtime's interceptor has replaced it with a writer feeding the job's execution.log — which the tailer reads and posts back. With the callback down that is a self-feeding loop.
  • A 4xx from the callback is REJECTED, not UNREACHABLE. The caller is up and has moved on; retrying cannot help and must not trip the shutdown path.
  • The log tailer holds back an unterminated tail. A logging handler writes the record and then flushes, so a poll can otherwise split one line into two entries that cannot be rejoined.
  • StopJob takes resume_version as a trailing optional parameter, not a DTO. uipath-ipc ignores a surplus wire arg and defaults a missing one, so old and new peers interoperate both ways; a DTO would break an old venv loudly and a new one silently.
  • Logs are tailed from the file, not captured via a handler. UiPathRuntimeLogsInterceptor._clean_all_handlers strips every handler but its own, and uipath-runtime ships on its own release train. The file is still written unchanged.

Testing

uv run pytest tests/cli green (~1400 tests; ~60 new across test_server_{result,async,logs,callbacks,cancellation}.py). ruff check, ruff format --check, mypy clean.

The cancellation tests use commands shaped like the real ones and assert the job's finally actually ran — an early cut used asyncio.run(loop_factory=...), which is 3.12+, and every one of them failed on the 3.11 venv. Now on asyncio.Runner.

Known gaps (deliberate)

  • state.db stays on disk — agent code opens that path directly.
  • A job wedged in a non-cancellable C call cannot be stopped; reported honestly rather than papered over.
  • The shutdown-on-undeliverable-result path cannot recover jobs that never wrote a document.
  • An accepted job has no timeout backstop on either side.

🤖 Generated with Claude Code

…caller

`uipath server` used to block for a whole job and leave its outcome on disk for
the caller to find. StartJob now enqueues the work and returns; the server
pushes logs while the job runs and the terminal result when it finishes.

Behaviour is a pure function of the request: a caller that supplies
`resultCallbackSocket` gets async dispatch, one that does not gets the original
blocking call, byte for byte. No handshake, no capability gate on the dispatch
path -- an older caller cannot send the field and could not serve the callback
if it did.

  POST {callback}/api/python/jobs/{jobKey}/result
  POST {callback}/api/python/jobs/{jobKey}/logs

Execution stays serialised behind the process-wide lock: a job mutates process
globals (logging handlers, OTel providers, env, cwd), so queueing changes who
waits, not how many run.

Real cancellation. run/debug/eval each drive their own event loop via
asyncio.run inside the to_thread worker, so a job IS an event loop. They now go
through `run_job_loop`, which publishes that loop to a JobControl carried on a
ContextVar (to_thread propagates contextvars, so no monkeypatching). StopJob
cancels the root task, which unwinds the runtime cooperatively -- its context
managers still run, so output.json is still written and the file fallback still
works. Escalates to a full loop sweep, then reports False rather than claiming
a stop that did not happen.

Also fixes two pre-existing defects that made the API result meaningless:
_run_command_isolated hardcoded ExitCode 0 while click RETURNS ctx.exit(N)'s
code under standalone_mode=False, so every ConsoleLogger.error path reported
success; and the HTTP body now carries exitCode, which is the field the
un-upgraded .NET handler already reads.

Notes for review:
- Server diagnostics go to a stderr handle bound at import, NOT ConsoleLogger.
  ConsoleLogger resolves sys.stdout at call time, and the runtime's interceptor
  has replaced it with a writer feeding the job's execution.log -- which the
  tailer then reads and posts back. With the callback down that is a
  self-feeding loop.
- A 4xx from the callback is REJECTED, not UNREACHABLE: the caller is up and has
  moved on, so retrying cannot help and must not trip the shutdown path.
- The log tailer holds back an unterminated tail; a handler writes the record
  and only then flushes, so a poll can otherwise split one line into two
  entries that cannot be rejoined.
- 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 in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added test:uipath-langchain Triggers tests in the uipath-langchain-python repository test:uipath-integrations labels Jul 30, 2026
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
86.1% Coverage on New Code (required ≥ 90%)
C Reliability Rating on New Code (required ≥ A)
B Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

connector=conn, timeout=ClientTimeout(total=CALLBACK_TIMEOUT_SECONDS)
) as session:
async with session.post(
f"http://localhost{path}", json=payload
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:uipath-integrations test:uipath-langchain Triggers tests in the uipath-langchain-python repository

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants