Skip to content

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

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

feat(cli): dispatch jobs asynchronously and push results/logs to the caller [PC-4873]#1835
robert-ursu wants to merge 2 commits 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.

Scope: async dispatch and result/log push only. Stopping a job that is already executing is stacked on top in #1842.

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

Review follow-up: the suite now type-checks

CI runs mypy --config-file pyproject.toml . with files = ["src", "tests"]. main is clean; this branch was not — the recording fakes handed to JobRegistry.start and JobLogTailer are not HandlerCallbacks.

Neither collaborator wants the socket transport, it wants somewhere to send a result and somewhere to send logs, so that is what the signature says now: JobReporter is a Protocol with those two methods and HandlerCallback satisfies it structurally. The fakes type-check as themselves instead of needing a cast per call site.

The protocol immediately found a real gap: test_server_async's FakeCallback had no post_logs at all.

Jira

PC-4873

@github-actions github-actions Bot added test:uipath-langchain Triggers tests in the uipath-langchain-python repository test:uipath-integrations labels Jul 30, 2026
connector=conn, timeout=ClientTimeout(total=CALLBACK_TIMEOUT_SECONDS)
) as session:
async with session.post(
f"http://localhost{path}", json=payload
…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

The readiness ACK advertises protocolVersion + capabilities so the caller knows
whether logs will be forwarded before it decides to tail the file itself.

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.

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.
- Logs are tailed from the file rather than captured via a handler: the
  runtime's log interceptor strips every handler but its own, and
  uipath-runtime ships on its own release train.

Stopping a job that is already executing is deliberately NOT in this change --
it follows on top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@robert-ursu
robert-ursu force-pushed the feat/async-job-dispatch-and-result-push branch from 114a204 to 71c81e9 Compare August 4, 2026 11:13
@robert-ursu robert-ursu changed the title feat(cli): dispatch jobs asynchronously and push results/logs to the caller feat(cli): dispatch jobs asynchronously and push results/logs to the caller [PC-4873] Aug 4, 2026
CI runs mypy over tests as well as src, and these tests were red: the recording
fakes they pass to JobRegistry.start and JobLogTailer are not HandlerCallbacks.

Neither collaborator wants the socket transport -- they want somewhere to send a
result and somewhere to send logs -- so say that: JobReporter is a Protocol with
those two methods, and HandlerCallback satisfies it structurally. The fakes then
type-check as themselves rather than needing a cast at every call site.

Worth noting the protocol immediately found a real gap: test_server_async's
FakeCallback had no post_logs at all, so the tailer's calls into it were only
ever working by accident of it never being exercised there.

The rest is local: cast the _FakeRequest stand-ins to web.Request at the two
handle_start call sites, and narrow web.Response.text (str | None) before
json.loads and `in`.
@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
82.6% 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

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🚨 Heads up: uipath-langchain cross-tests are FAILING 🚨

Your changes may break the uipath-langchain-python integration.

⚠️ These checks are NOT enforced by branch protection rules. Please review the failures before merging.

🔍 Inspect the failed run →

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