Skip to content

UN-1031 [FEAT] In Prompt Studio, provide a way to stop a running prompt - #2283

Open
praveen-formido wants to merge 8 commits into
mainfrom
UN-1031-abort-running-prompt
Open

praveen-formido wants to merge 8 commits into
mainfrom
UN-1031-abort-running-prompt

Conversation

@praveen-formido

@praveen-formido praveen-formido commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Closes UN-1031. Scope is Prompt Studio only — the same capability for API deployments is planned separately in UN-4102.

Prompt Studio gave no way to stop a prompt once it was running. A user who picked the wrong model or document, or simply changed their mind, had to sit and watch it — and pay for it. This adds a Stop on each prompt card and a Stop All in the toolbar.

How it works

Nothing can interrupt a running task from outside: the PG queue has no revoke, Celery's revoke(terminate=True) is inert here (children SIG_IGN SIGTERM, the worker uses the threads pool), and a synchronous litellm.completion parks its thread on a socket read. So the design inverts control — the canceller writes a fact, and the run reads it:

Layer Catches a stop that lands… Effect
Consumer pre-claim drop before the task starts dropped, ACKed, zero spend
13 executor checkpoints between stages stops before the next charge
In-flight abort (SDK) inside a call already running closes the request, stops waiting

The abort predicate is a Callable[[], bool] closure the executor injects, so unstract/sdk1 never learns about Prompt Studio or Redis. Workflow and API-deployment runs pass no predicate and keep the previous code path byte for byte.

Key mechanics: one process-wide event loop (litellm caches async clients keyed to the loop they were built on), reset via os.register_at_fork because the consumer forks children; sliced sleeps so a stop isn't swallowed by retry backoff; AbortedError deliberately not a TimeoutError subclass, or the retry predicate would retry the call we just walked away from; negative predicate results memoised for 1s, since a 0.5s poll across a 900s call is ~1800 Redis round trips.

UNSTRACT_LLM_ABORT_INFLIGHT=false reverts to the synchronous path if a provider misbehaves on async.

What it does not promise

Closing our end of a request does not stop the provider processing it or reliably avoid the bill. LLMWhisperer has no cancel endpoint, so an abandoned extraction runs to completion on their side — observed in testing, completing 2.5s after our stop was recorded. And an aborted call returns no response, so its token counts never reach the usage rows. All three are documented in the module docstring, the pg_queue README and the QA criteria.

Which button stops what

Stop All cancels the whole run including the extraction and indexing its prompts share. A per-prompt Stop deliberately spares those shared stages, because the run's other prompts still need them. Measured on a local stack: Stop All during extraction ended the run in 2.4s; a per-prompt stop at the same moment took 9.5s. Both correct — worth knowing before testing stages 3–5.

Review remediation

A standardised review found five defects, all fixed in 7eecb4142, each pinned by a test confirmed to fail without its fix:

  1. A stop could delete a complete index. is_document_indexed embeds a query to probe the store, and that call is abortable — by then doc_id names a document a previous run indexed, so cleanup destroyed vectors this run never wrote.
  2. Per-prompt Stop was too weak on a single-prompt run — sparing shared stages when no sibling needed them.
  3. Stopping one prompt of a bulk run disabled its siblings' Stop buttons, leaving work the user could see billing and no control to stop it.
  4. A cancel arriving as a failure result was shown as a red error, violating "a stop is never reported as a failure".
  5. The progress message counted prompts that never ran as done — "Stopped after 9 of 10" when it stopped at prompt 2.

Behaviour change outside the stop path

LLM.complete_vision now goes through _invoke_completion, the same path as complete, so a stop can abandon a vision call too. As a side effect its retry behaviour changes: on main it called litellm.completion directly, with the adapter's max_retries passed through and applied by litellm itself; it now has max_retries popped and applied by the SDK's call_with_retry, which uses our retryable-error predicate and backoff, the same policy complete already uses. The attempt budget is still the adapter's max_retries, but which errors are retried and how long it waits between them now match complete — and for providers where litellm did not honour max_retries itself, vision calls now actually get retried. (Raised by @harini-venkataraman in review.)

Verification

  • workers 1542 passed, frontend 613 passed, sdk1 606 passed, core 49 passed
  • New suites: test_aborting, test_retry_abort, test_llm_abort, test_whisperer_v2_abort, plus executor/consumer/callback cancellation tests
  • Manual end-to-end on a local stack (Playwright-driven): Stop All abandoned an extraction mid-flight in 2.49s against its usual ~11s; no previous answer was ever blanked; cancelled runs wrote no output rows; no indexing locks left behind after five cancelled runs; usage rows still written

QA acceptance criteria: https://claude.ai/code/artifact/fcd0c433-4d59-4b41-a03b-9c3583ce1207

Known gap, needs a cloud-side change

The lookup plugin lives in unstract-cloud. Its own broad handler in workers/plugins/lookup_enrichment/src/base.py catches the abort below this repo's bridge fix, so a stop landing while a lookup call is already in flight still shows "Lookup failed" and writes that error onto the saved output. The new checkpoint covers the common timing; the passthrough there is a separate two-line PR.

Before merge

One clean run per configured LLM provider — Bedrock, Claude and VertexAI at minimum. Prompt Studio now reaches providers through litellm's async entry point, so an authentication or transport difference would surface there and nowhere else. This is the highest-risk item in the change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TRwXE5CmWoLy3gjSq8Pm4H

praveen-formido and others added 4 commits September 3, 2026 23:55
A Prompt Studio run could not be stopped. Once Run (or Run All) was
clicked the spinner stayed up until a socket event arrived or a
16-minute client timer gave up, while the backend kept spending OCR and
LLM tokens to completion. Closing the tab changed nothing.

Nothing in the stack could be interrupted from outside: the PG queue
exposes only send/read/set_vt/delete with no revoke or dead-letter, and
the consumer runs a claimed task eagerly in-process with no time limit.
So the stop is cooperative. The backend records an intent in Redis keyed
on run_id — the one identifier that spans extract, index and
answer_prompt, and that the browser knows before the first response
comes back — and the pipeline checks it where stopping is cheap.

- unstract-core owns the vocabulary: the key, is_cancelled, and the
  sentinel every layer matches on. Reads are best effort, so an
  unreachable Redis reads as "not cancelled" and never kills a live run.
- POST prompt-studio/<pk>/cancel/ records the intent. Naming prompts
  stops just those; naming none stops the whole run including the
  extract/index stages its prompts share.
- The blocking stages unwind the request itself, clearing the document's
  indexing flag on the way out — left set, it would block every prompt
  on that document for INDEXING_FLAG_TTL.
- The consumer drops a stopped run before starting it and fires its
  on_error, because the continuations live in the payload and would die
  silently with a deleted row.
- The executor checks between prompts and before each billable stage. A
  stopped bulk run returns SUCCESS carrying the answers that finished:
  reporting failure would send the callback down the error path and
  throw away work the user already paid for. Tokens spent before the
  stop are still billed.
- The UI replaces a running prompt's Run buttons with Stop, adds Stop
  All beside Run All, and drops queued-but-unsent runs locally.

Cloud follow-ups (single-pass and Simple Prompt Studio run ids, and
mid-run checkpoints inside the table/agentic executors) are not in this
change; those runs still stop only at the pre-run check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Caz4hivG25nLnnZZD6aULh
The first pass of this ticket stopped the *next* call. A prompt wedged
inside a slow LLM or OCR call kept its worker occupied until that call
returned on its own, while the UI sat on "Stopping…" and then, after
sixteen minutes, misreported it as a timeout. Stop now abandons whatever
is running.

Two facts set the shape of the fix. A synchronous `litellm.completion`
parks a thread on a socket read and nothing outside that thread can
interrupt it, so only litellm's async entry point yields a cancellable
task whose cancellation closes the request. Embedding and OCR need no
async: llama-index calls the embedding leaf once per batch, and
LLMWhisperer is a poll loop, so both abort by raising at a boundary we
already control.

Abort is a predicate, not a dependency. `unstract/sdk1` is shared with
the workflow and API-deployment paths and must not learn about Prompt
Studio or Redis, so the executor injects a `() -> bool` closure and the
SDK only ever calls it. A caller with no Stop button passes none and
keeps today's code path byte for byte.

- `sdk1.utils.aborting`: `AbortedError` (deliberately not a TimeoutError,
  or the retry loop would classify a stop as transient), `abort_scope`
  as a ContextVar for adapters built deep inside plugin code, sliced
  sleeps, and `run_abortable` over one process-wide event loop — litellm
  caches async HTTP clients in a module global, so a closed loop poisons
  every later call; the loop is reset after a fork because the consumer
  forks its children.
- `retry_utils`: check before each attempt and sleep the backoff in
  slices, so a stop is not swallowed by up to a minute of delay.
- `llm.py`: with a predicate, run `acompletion` on that loop and cancel
  it; `UNSTRACT_LLM_ABORT_INFLIGHT=false` forces the synchronous path
  back if a provider misbehaves on async.
- LLMWhisperer v2 drives its own status polling
  (`wait_for_completion=False`) rather than blocking inside the vendored
  client's uninterruptible loop; v1's loop was already ours.
- `legacy_executor`: builds the predicate for IDE runs only, memoizing
  negatives since the SDK polls twice a second, and translates
  `AbortedError` into the existing `ExecutionCancelled` — so the
  consumer, callbacks, sentinel, socket event and UI are unchanged.
  A stop during indexing removes the half-written nodes, or the document
  would later read as indexed.
- Frontend: after thirty seconds the button says the step in progress is
  still finishing, and a run the user stopped is reported as stopped
  rather than as a timeout.

What this does not promise, and is stated in the README and the QA
criteria: closing our end of a request does not stop the provider
processing it or reliably avoid being billed; LLMWhisperer has no cancel
endpoint at all; and an aborted call returns no response, so its usage
is never read and that spend does not reach the usage rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EsA8TZB83nkXcAe6uR7wL
Raised by QA against the stage map: lookup enrichment was missing from
it, and the code had two matching holes.

Lookup enrichment is a billable LLM call, and the post-processing
webhook fires immediately after it, with no checkpoint in front of
either. A stop landing there let both run — and a webhook is the one
thing in this pipeline that cannot be taken back once another system has
received it. There is now a checkpoint before the pair.

The bridge's broad `except Exception`, which exists to degrade gracefully
on plugin contract drift, also swallowed `AbortedError`. A stop was
logged as "lookup failed, continuing without enrichment" and the run
carried on to the webhook — the same mistake already fixed in
`_handle_index` and `_handle_summarize`. Aborts now pass through; plugin
failures still degrade as before.

Both tests were verified to fail against the unfixed code. The
checkpoint test times its stop to land after the model has answered, so
the only checkpoint that can catch it is the new one — a run stopped
earlier would never reach this part of the prompt and would prove
nothing about it.

Not fixed here, because it lives in unstract-cloud: the lookup plugin's
own `except Exception` around `enricher.run()`
(workers/plugins/lookup_enrichment/src/base.py) catches the abort one
layer below this bridge, streams a red "Lookup failed" to the user and
persists a lookup error onto the output. The new checkpoint covers the
common timing; a stop landing once the lookup call is already in flight
still needs that plugin to re-raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EsA8TZB83nkXcAe6uR7wL
All five found by a standardised review of the branch, each verified
against the code and pinned by a test confirmed to fail without its fix.

A stop could DELETE A COMPLETE INDEX. `is_document_indexed` probes the
vector store by embedding a query, and that embedding call is itself
abortable. By the time it runs, `doc_id`, `index` and `vector_db` are
all set, so the stop handler called `delete_nodes` — destroying vectors
a PREVIOUS run wrote and this one never touched. A `wrote_nodes` flag,
set immediately before `perform_indexing`, scopes cleanup to an index
this run actually began writing.

The prompt card's Stop was too weak on a run it was the only prompt of.
Naming `prompt_ids` deliberately spares the extraction and indexing a
run's prompts SHARE, because the others still need them — but with no
sibling left running there is nothing to spare, and the stop sat out
the whole of extraction. Manual testing measured the gap: 9.5s for a
per-prompt stop against 2.4s for Stop All, at the same moment of the
same extraction. It now sends a whole-run cancel when no other prompt
of that run is still going.

Stopping one prompt of a bulk run disabled its siblings' Stop buttons.
`markRunsStopping` marked the whole run, so every prompt in it read as
stopping while the backend kept running and billing them — and with one
active run, Stop All went too. The user was left with work they could
see costing money and no control that would stop it. Stopping is now
tracked per prompt.

A cancel arriving as a failure RESULT was reported as an error. When a
handler converts a stop rather than raising, `ide_prompt_complete` and
`ide_index_complete` emitted `status="failed"` carrying the sentinel as
error text, so the user got a red toast for a run they stopped
themselves. Both now make the same check `ide_prompt_error` already
made.

The progress message counted prompts that never ran as done. A
whole-run stop breaks the loop, leaving unreached prompts out of both
lists, so subtracting reported them as answered: stopping at prompt 2
of 10 streamed "Stopped by user after 9 of 10 prompts". It now counts
what actually answered.

Verified: workers 1542 passed, frontend 613 passed. Each new test was
run against the unfixed source first and observed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRwXE5CmWoLy3gjSq8Pm4H
@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with no new actionable defects introduced since the previous review and all previous Greptile threads resolved.

Summary

This PR adds cooperative cancellation for Prompt Studio runs across the frontend, Django request stages, queue consumer, executor, SDK calls, and callbacks. Changes since the previous review make construction and reset of the shared cancellation Redis client thread-safe and apply a formatting-only cleanup to callback payload assembly.

  • Adds per-prompt Stop and run-wide Stop All controls.
  • Records tool-scoped cancellation intent and checks it throughout extraction, indexing, queued execution, retries, and in-flight LLM calls.
  • Preserves completed partial results and reports user-requested cancellation as a stopped outcome rather than an error.
  • Protects lazy Redis-client initialization and reset with a process-local lock.

Diagram

sequenceDiagram
    participant UI as Prompt Studio UI
    participant API as Django API
    participant Redis as Cancellation Store
    participant Queue as PG Queue
    participant Worker as Executor
    participant SDK as LLM/X2Text SDK
    UI->>API: Start run with run_id
    API->>Redis: Bind run_id to tool
    API->>Queue: Dispatch work
    UI->>API: Stop run or selected prompts
    API->>Redis: Record cancellation intent
    Queue->>Redis: Check before claiming work
    Worker->>Redis: Check between stages/prompts
    SDK->>Redis: Poll abort predicate in flight
    Worker-->>UI: Emit terminal cancelled result
Loading

Reviews (5) · Last reviewed commit: "UN-1031 [FIX] Address review: lock the c..."

Comment thread backend/prompt_studio/prompt_studio_core_v2/views.py
Comment thread frontend/src/hooks/usePromptRun.js
Both P1 findings from the Greptile review of PR #2283, fixed together in
one commit so the bot re-reviews once rather than per fix.

CANCELLATION BYPASSED RUN AUTHORIZATION. A run id is minted in the
browser and reaches the endpoint unverified; nothing tied it to anything
server-side, and `request_cancel` consumes only the organisation and the
run id. The endpoint checked that the caller could reach the tool in the
URL, not that the run belonged to it — so a user who learned another
run's id, in a project they may only be able to view, could stop its
billable work.

There was no relational record to check against, so one is now kept
beside the cancel intent: `ps:run-tool:{org}:{run_id}` is written at each
of the three dispatch sites and read at cancel. A run owned by a
different tool is refused with 403, and refused for the whole request —
the same all-or-nothing rule the id validation already followed, because
a half-applied cancel leaves the caller unable to tell which runs died.

An unrecorded owner stays permissive. "Unknown" is not "not ours": Redis
may be unreachable, or the run may predate the record. Refusing then
would break legitimate stops during a blip, and buys nothing — with
Redis down `request_cancel` cannot record the intent either, so the
cancel fails anyway and is reported in `failed`.

The permission is deliberately left as is. Cancelling requires exactly
what running requires, which is the coherent rule: whoever can start the
work can stop it. The gap was the missing binding, not the permission
level.

STOP ALL CROSSED PROJECTS. `activeRuns` is global and outlives navigating
between projects, and carried no record of which tool owned each run. A
run started in project A, then Stop All pressed in project B, sent A's
run ids through B's endpoint — and the backend accepted them. Runs now
record their `toolId`, and both Stop All and the per-prompt Stop consider
only the project on screen. With the binding above, the backend would
now refuse the cross-project ids anyway; this stops them being sent.

Verified: workers + core 1591 passed, frontend 614 passed, backend
prompt_studio 132 passed (17 pre-existing errors, all DB-bound tests
needing live Postgres, unchanged from before this branch). Each new test
was run against the unfixed source first and observed to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRwXE5CmWoLy3gjSq8Pm4H
Comment thread backend/prompt_studio/prompt_studio_core_v2/views.py Outdated
praveen-formido and others added 2 commits September 14, 2026 11:54
Greptile's second review found that the ownership record added in
19e026b was itself bypassable, by the same mechanism it was meant to
guard. It is right.

`remember_run_owner` did an unconditional SET, so the binding was only
as trustworthy as its last writer. A caller who knew a live run id could
dispatch their own work under it through a tool they control, taking
ownership of someone else's run, and then cancel it through that tool —
passing the authorization check on the way through.

The binding is now immutable: SET NX, first writer wins. A second tool's
attempt leaves the original owner in place, so the cancel is refused as
it should have been.

Dispatch now also refuses a run id owned by another tool, rather than
merely declining to rebind it. Without that the foreign run still
EXECUTES under the victim's run id — two runs sharing one id, one
cancellation key and one stream of socket events. That is no longer a
security problem once the binding holds, but it is a collision nobody
should have to debug.

The fail-open rule is unchanged and deliberate: a signal-store failure
returns True and the dispatch proceeds. Refusing to start work because
Redis blinked would be worse than leaving one run unverifiable, and it
is the same principle the rest of the feature rests on.

Verified: workers + core 1596 passed, backend prompt_studio 132 passed
(17 pre-existing errors, DB-bound tests needing live Postgres,
unchanged). Five new core tests, including the rebinding attack itself
and the legitimate same-tool retry that must keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRwXE5CmWoLy3gjSq8Pm4H
The branch had fallen nine commits behind, which is why pre-commit.ci
was failing: it could not produce a merge to run the hooks against, and
reported "error during mergeable check" rather than any hook result.

Two files conflicted.

`internal_views.py` — main's UN-3815 added an org-scoping guard that
404s when the scoped queryset resolves fewer prompts than were asked
for. UN-1031 drops prompts with no answer on a stopped run, so a stop
never saves a blank over a previous good answer. Both are needed, and
the ORDER IS LOAD-BEARING: the guard compares against the ids the caller
asked for, and the stop filter deliberately shrinks that set. Filtering
first would fire the guard on every stopped run, answering 404 and
discarding exactly the partial results the stop exists to preserve. The
guard runs first, the filter second, and a comment says why so it is not
reordered later.

`prompt_studio_helper.py` — main renamed `success` to `status_result`
and switched the check to `ExtractionStatusResult.OK`; UN-1031 adds an
early raise for the cancel sentinel ahead of that call. Kept both.

Verified on the merged tree: workers + core 1738 passed, frontend 614
passed, backend prompt_studio 132 passed. The backend error count rises
from 17 to 57 because main brings new DB-bound test files; all of them,
main's included, fail with the same "no schema has been selected"
configuration error, unrelated to this merge. Main's tests for the
org-scoping guard are among those, so that resolution was verified by
reading the merged source rather than by running them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRwXE5CmWoLy3gjSq8Pm4H
@harini-venkataraman

Copy link
Copy Markdown
Contributor

PR #2283 Review — UN-1031: Stop Running Prompts in Prompt Studio

Architecture

The PR implements cooperative cancellation across the full stack: Redis-backed intent recording → backend checkpoints → consumer pre-run checks → executor stage boundaries → SDK abort scope
(ContextVar-based predicate for in-flight LLM calls) → frontend per-prompt/stop-all UX.

The "fail-open" design (Redis down = not cancelled) is the correct choice — no healthy run can be killed by a cache outage.


Critical Issues

  1. Thread-safety gap in Redis client singleton — unstract/core/src/unstract/core/prompt_run_cancellation.py

_get_client() and _reset_client() mutate globals (_client_singleton, _client_last_failure) without a lock. In Django's multi-threaded request handling, two threads can race on initialization or reset.
The same codebase already does this correctly in aborting.py:_get_loop() using threading.Lock.

Consequence is mild (worst case: a duplicate client or missed reset), but the fix is trivial — add a threading.Lock to match the existing pattern.

  1. Misleading ternary formatting in callback — workers/ide_callback/tasks.py

"prompt_ids": prompt_ids + cancelled_prompt_ids
if was_cancelled
else prompt_ids,

Python's operator precedence makes this correct (+ binds tighter than if), but the formatting strongly invites future editors to misread or break it. Should use explicit parentheses:

"prompt_ids": (prompt_ids + cancelled_prompt_ids)
if was_cancelled
else prompt_ids,


Important Observations

  1. Latent bug fix bundled in — unstract/sdk1/src/unstract/sdk1/llm.py

complete_vision previously called litellm.completion(...) directly, bypassing pop_litellm_retry_kwargs. This meant max_retries was passed to litellm AND applied by the retry wrapper = double retries.
The PR fixes this by routing through _invoke_completion. Good fix, but it's a behavior change that should be called out in the PR description.

  1. No deduplication on prompt_ids concatenation — workers/ide_callback/tasks.py

prompt_ids + cancelled_prompt_ids could contain duplicates if a race causes a prompt to appear in both lists. The frontend uses includes() so it's not functionally broken, but a Set-based
deduplication would be cleaner.


What's Done Well

  • Per-prompt granularity — stopping one prompt preserves others' results. Correct for cost-awareness.
  • Run ownership via immutable binding — SET NX prevents run-id hijacking; None (unknown owner) treated as permissive so Redis downtime can't block legitimate stops.
  • Index node cleanup on abort — partially written vector nodes are deleted to prevent is_document_indexed from reporting half-indexed docs as complete. The wrote_nodes flag correctly avoids deleting a
    previous run's fully-indexed nodes.
  • Excellent test coverage — 600+ lines of executor tests, 265 lines of callback tests, 160+ lines of consumer tests, 229 lines of core tests, 417 lines of frontend tests.
  • Security fixes — the two P1 findings (authorization gap, cross-project stop-all) are properly addressed.

Verdict

Well-architected PR with thoughtful design decisions. The two issues worth fixing before merge are the missing thread lock on the Redis singleton and the misleading ternary formatting — both
straightforward fixes.

Two points from Harini's review of PR #2283.

The cancellation Redis client is built lazily into module globals, and
`_get_client` / `_reset_client` mutated them with no lock. Django serves
requests on several threads, so two could race to build the client, or
interleave a reset with a build and lose the reset. Mild in effect, but
the fix is small and matches `aborting._get_loop`, which already guards
the same shape with a lock. The common path stays lock-free, since
checkpoints call this in tight per-prompt loops and a global read is
atomic; only the build and the reset take the lock, and the build
re-checks under it. A new test races eight threads through a slow client
factory: without the lock it built the client eight times, with it once.

`"prompt_ids": prompt_ids + cancelled_prompt_ids if was_cancelled else
prompt_ids` was correct only because `+` binds tighter than the
conditional, and read as though it might not be. Parenthesised.

Not changed: deduplicating that concatenation. The two lists cannot
overlap, because `prompt_ids` is filtered a few lines above to exclude
every id in `cancelled_prompt_ids`. The `complete_vision` retry
behaviour change she noted is documented in the PR description instead,
as it needs no code change.

Verified: workers + core 1739 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Caz4hivG25nLnnZZD6aULh
@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

All checks passed! No linting or formatting issues found.

@praveen-formido

Copy link
Copy Markdown
Contributor Author

@harini-venkataraman thanks for the review. I've addressed it in 6d086e1a3; here's what I did on each point.

1. Thread-safety of the Redis client singleton: fixed.
You're right. _get_client and _reset_client changed the module globals with no lock. I've added a threading.Lock using the same pattern as aborting._get_loop. The common path stays lock-free, because checkpoints call this in tight per-prompt loops and reading a module global is atomic. Only the build and the reset take the lock, and the build checks again once it holds the lock. I also added a regression test that races 8 threads through a slow client factory. Without the lock it built the client 8 times; with the lock, once.

2. Misleading ternary formatting: fixed.
It is now (prompt_ids + cancelled_prompt_ids) if was_cancelled else prompt_ids. As you said, it was already correct, but it read as though it might not be.

3. complete_vision behaviour change: documented, no code change.
Agreed that it should be called out, so I've added a "Behaviour change outside the stop path" section to the PR description. One correction to the explanation: on main it wasn't a double retry. complete_vision called litellm.completion directly, with no retry wrapper, so all retries came from litellm. It now goes through _invoke_completion, so max_retries is removed before the call and applied by our call_with_retry. That means which errors get retried, and the backoff between attempts, now match complete. Providers where litellm ignored max_retries also now get their vision calls retried. It's a real change, just not a doubling.

4. Deduplicating prompt_ids: not changed, because the lists can't overlap.
A few lines above the concatenation, when was_cancelled is true, prompt_ids is filtered to [p for p in prompt_ids if str(p) not in cancelled_prompt_ids]. So the two lists are disjoint, and a set would only hide a bug if that filter were ever removed. I'd rather keep the filter as the single guarantee than add a second guard that makes the code look as if duplicates were possible.

All workers and core tests pass: 1739. Separately, e2e and test (integration) are still failing, but that isn't this PR: minio/minio and minio/mc can no longer be pulled from Docker Hub, and every other PR will hit the same failure on its next run.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
frontend unit 0 1 0 0 0.0
unit-backend unit 1309 0 0 1 44.5
unit-connectors unit 63 0 0 0 9.8
unit-core unit 137 0 0 0 2.4
unit-platform-service unit 15 0 0 0 2.6
unit-rig unit 120 0 0 0 4.7
unit-runner unit 5 0 0 0 2.9
unit-sdk1 unit 604 0 0 0 27.7
unit-workers unit 1469 0 0 1 124.5
TOTAL 3722 1 0 2 219.0

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
💤 Covered, but not exercised in this build
  • auth-login — User can log in and obtain a session cookie. (covered by e2e-login; no result reported in this build)
  • adapter-register-llm — Register and validate an LLM adapter. (covered by integration-backend; no result reported in this build)
  • workflow-author — Create a workflow; its source+destination endpoints materialise and are configurable. (covered by integration-backend; no result reported in this build)
  • co-owner-manage — Add/remove co-owners of a shared resource; enforce the last-owner guard. (covered by integration-backend, e2e-coowners; no result reported in this build)
  • workflow-create-execute — Create a workflow, configure source+destination, execute, poll, fetch result. (covered by e2e-workflow; no result reported in this build)
  • api-deployment-provision — Deploying a workflow as an API mints a usable key and a resolvable endpoint. (covered by integration-backend; no result reported in this build)
  • api-deployment-auth — Unauthenticated or mis-scoped API-deployment calls are rejected before dispatch. (covered by integration-backend; no result reported in this build)
  • api-deployment-run — Deploy a workflow as an API, POST a document, receive structured JSON. (covered by e2e-api-deployment; no result reported in this build)
  • mcp-server-auth — Unauthenticated or mis-scoped hosted-MCP calls are rejected before any tool runs. (covered by integration-backend; no result reported in this build)
  • mcp-platform-auth — The org-scoped MCP endpoint stays behind the platform-API-key middleware; unauthenticated or mis-scoped calls reach no tool. (covered by integration-backend; no result reported in this build)
  • platform-key-whoami — A platform API key resolves its own organisation over the org-less whoami endpoint; the org comes from the key row, not the URL. (covered by integration-backend; no result reported in this build)
  • prompt-studio-author — Create a Prompt Studio project and add a prompt to it. (covered by integration-backend; no result reported in this build)
  • prompt-studio-fetch-response — Prompt Studio: create project, add prompt, run a prompt, get response. (covered by e2e-prompt-studio; no result reported in this build)
  • connector-register-test — Connector credentials are validated against the live system and stored encrypted. (covered by integration-backend; no result reported in this build)
  • pipeline-etl-execute — Run an ETL pipeline from source connector to destination. (covered by e2e-etl; no result reported in this build)
  • usage-aggregate-read — Per-run token usage aggregates correctly and stays scoped to its organization. (covered by integration-backend; no result reported in this build)
  • usage-token-tracking — Per-execution token usage is recorded and retrievable. (covered by e2e-api-deployment; no result reported in this build)
  • callback-result-delivery — Async results are posted back via the callback worker. (covered by e2e-api-deployment; no result reported in this build)

@harini-venkataraman harini-venkataraman left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, tests are failing, please check and merge.

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.

2 participants