Skip to content

Commit 9629d1e

Browse files
committed
Merge branch 'main' into chore/infra-quick-wins
2 parents bd7426b + 79f6c12 commit 9629d1e

10 files changed

Lines changed: 756 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## [1.10.0](https://github.com/runpod/runpod-python/compare/v1.9.1...v1.10.0) (2026-06-24)
4+
5+
6+
### Features
7+
8+
* add agent source tracking to user-agent header ([#508](https://github.com/runpod/runpod-python/issues/508)) ([92239c8](https://github.com/runpod/runpod-python/commit/92239c817a64dada82b578e3447c1b4015afef9a))
9+
* add per-job stop capability to serverless worker ([#510](https://github.com/runpod/runpod-python/issues/510)) ([21c4786](https://github.com/runpod/runpod-python/commit/21c4786847bd2e5fd2ca6d15f85737203a3044a1))
10+
* detect all coding agents in user-agent, not just Claude Code ([#519](https://github.com/runpod/runpod-python/issues/519)) ([d14c67c](https://github.com/runpod/runpod-python/commit/d14c67c4e82963e61396b58a11dfd7046abbdde0))
11+
* support Python 3.10-3.14, drop EOL 3.8/3.9 ([#512](https://github.com/runpod/runpod-python/issues/512)) ([8e9d94e](https://github.com/runpod/runpod-python/commit/8e9d94e69105859c159e70fa9ae6bbf094297a2e))
12+
13+
14+
### Bug Fixes
15+
16+
* **deps:** bump cryptography floor to >=48.0.1 for OpenSSL fix ([#516](https://github.com/runpod/runpod-python/issues/516)) ([18c6841](https://github.com/runpod/runpod-python/commit/18c6841f77388e9f48f0b82d14e32bc6a0e26f7c)), closes [#511](https://github.com/runpod/runpod-python/issues/511)
17+
318
## [1.9.1](https://github.com/runpod/runpod-python/compare/v1.9.0...v1.9.1) (2026-05-05)
419

520

docs/serverless/worker.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ For more complex operations where you are downloading files or making changes to
5858
return {"output": "Job completed successfully"}
5959
```
6060

61+
## Stopping Individual Jobs
62+
63+
A worker can process more than one job concurrently. When a single request is cancelled, expires, or times out, the Runpod server signals the worker to stop just that request without affecting the worker's other in-progress jobs. The worker continuously polls a dedicated stop channel; the server is expected to hold each request open (long-poll) until a stop signal is available or the poll times out. When a signal arrives, the worker cancels the task running the matching job, so a stopped job no longer consumes worker time.
64+
65+
No handler changes are required to support this. Async handlers that hold resources can perform cleanup by catching `asyncio.CancelledError`, but they **must re-raise** it after cleaning up. Swallowing the cancellation makes the worker report the job as completed instead of stopped.
66+
6167
## See Also
6268

6369
- [Worker Fitness Checks](./worker_fitness_checks.md) - Validate your worker environment at startup

runpod/agent.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Detects which AI coding agent (if any) is driving the SDK.
2+
3+
Detection is based on the environment variables that agent harnesses set in
4+
the processes they spawn. The registry mirrors Hugging Face's public
5+
agent-harnesses list so that traffic is attributed under the same agent
6+
identifiers across tools:
7+
https://github.com/huggingface/huggingface.js/blob/main/packages/tasks/src/agent-harnesses.ts
8+
"""
9+
10+
import os
11+
12+
# Each entry maps an agent identifier to the environment variables that
13+
# identify it. Detection matches if ANY of the listed variables is set to a
14+
# non-empty value. The list is checked in order and the first match wins;
15+
# order matters so that more specific signals come before broader ones they
16+
# can co-occur with (e.g. cowork before claude-code, cursor-cli before cursor).
17+
HARNESSES = [
18+
("antigravity", ["ANTIGRAVITY_AGENT"]),
19+
("augment-cli", ["AUGMENT_AGENT"]),
20+
("cline", ["CLINE_ACTIVE"]),
21+
("cowork", ["CLAUDE_CODE_IS_COWORK"]),
22+
("claude-code", ["CLAUDECODE", "CLAUDE_CODE"]),
23+
("codex", ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"]),
24+
("crush", ["CRUSH"]),
25+
("gemini-cli", ["GEMINI_CLI"]),
26+
("github-copilot", ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"]),
27+
("goose", ["GOOSE_TERMINAL"]),
28+
("hermes-agent", ["HERMES_SESSION_ID"]),
29+
("kilo-code", ["KILOCODE_FEATURE"]),
30+
("kiro", ["AGENT_CONTEXT_OUT"]),
31+
("openclaw", ["OPENCLAW_SHELL"]),
32+
("opencode", ["OPENCODE_CLIENT"]),
33+
("pi", ["PI_CODING_AGENT"]),
34+
("replit", ["REPL_ID"]),
35+
("trae", ["TRAE_AI_SHELL_ID"]),
36+
("zed", ["ZED_TERM"]),
37+
("cursor-cli", ["CURSOR_AGENT"]),
38+
("cursor", ["CURSOR_TRACE_ID"]),
39+
]
40+
41+
# Generic variables any tool can set to identify itself. When set, the value is
42+
# sanitized and used as the agent id. Only AI_AGENT is honored: a bare AGENT is
43+
# too common in unrelated environments (CI runners, shell setups) and would
44+
# attribute traffic to arbitrary values.
45+
STANDARD_ENV_VARS = ["AI_AGENT"]
46+
47+
48+
def known_env_vars():
49+
"""Returns every environment variable the registry inspects, including the
50+
standard AI_AGENT signal. Useful for tests that need to isolate detection
51+
from the ambient environment.
52+
"""
53+
variables = []
54+
for _, env_vars in HARNESSES:
55+
variables.extend(env_vars)
56+
return variables + list(STANDARD_ENV_VARS)
57+
58+
59+
def _sanitize(value):
60+
"""Keeps only User-Agent-safe characters ([A-Za-z0-9._-]), capped at 64
61+
characters, so an arbitrary env value cannot produce a malformed header.
62+
"""
63+
value = value.strip()
64+
safe = []
65+
for char in value:
66+
if len(safe) >= 64:
67+
break
68+
if char.isascii() and (char.isalnum() or char in "._-"):
69+
safe.append(char)
70+
return "".join(safe)
71+
72+
73+
def detect():
74+
"""Returns the identifier of the AI coding agent driving the SDK, or an
75+
empty string if none is detected. Specific harness markers take priority
76+
over the generic AI_AGENT signal.
77+
"""
78+
for agent_id, env_vars in HARNESSES:
79+
for env in env_vars:
80+
if os.getenv(env, "").strip():
81+
return agent_id
82+
83+
for env in STANDARD_ENV_VARS:
84+
value = _sanitize(os.getenv(env, ""))
85+
if value:
86+
return value
87+
88+
return ""
89+
90+
91+
def suffix():
92+
"""Returns the " (via <id>)" User-Agent fragment for the detected agent, or
93+
an empty string when none is detected. Centralizing the fragment here keeps
94+
the tag format identical across every client's User-Agent.
95+
"""
96+
agent_id = detect()
97+
if agent_id:
98+
return f" (via {agent_id})"
99+
return ""

runpod/serverless/modules/rp_job.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,81 @@
2626
job_progress = JobsProgress()
2727

2828

29+
def _job_stop_url() -> Optional[str]:
30+
"""
31+
Prepare the URL for the worker's dedicated stop channel.
32+
33+
Derived from the job-take URL so it points at the same endpoint and worker,
34+
preserving its query string (auth and routing params). Returns None when the
35+
job-take URL is not in the expected form.
36+
"""
37+
if "/job-take/" not in JOB_GET_URL:
38+
return None
39+
return JOB_GET_URL.replace("/job-take/", "/job-stop/")
40+
41+
42+
async def get_stop_signals(session: ClientSession) -> List[str]:
43+
"""
44+
Long-poll the dedicated stop channel for request ids the worker should stop.
45+
46+
The server is expected to hold the request open (long-poll) until a stop
47+
signal is available or the poll times out, so cancellations and timeouts
48+
reach the worker without waiting for the next heartbeat.
49+
50+
Returns:
51+
A list of request ids to stop. Empty when the poll returned no signals.
52+
"""
53+
stop_url = _job_stop_url()
54+
if not stop_url:
55+
return []
56+
57+
async with session.get(stop_url) as response:
58+
if response.status == 204:
59+
return []
60+
61+
if response.status == 429:
62+
raise TooManyRequests(
63+
response.request_info,
64+
response.history,
65+
status=response.status,
66+
message=response.reason,
67+
)
68+
69+
response.raise_for_status()
70+
71+
if response.content_type != "application/json":
72+
log.warn(
73+
f"rp_job | get_stop_signals: unexpected content type: {response.content_type}"
74+
)
75+
return []
76+
77+
try:
78+
payload = await response.json()
79+
except (aiohttp.ContentTypeError, ValueError) as error:
80+
log.warn(f"rp_job | get_stop_signals: failed to parse response: {error}")
81+
return []
82+
83+
if not isinstance(payload, dict):
84+
log.warn(
85+
f"rp_job | get_stop_signals: unexpected payload type: {type(payload).__name__}"
86+
)
87+
return []
88+
89+
raw_ids = payload.get("jobsToStop", [])
90+
if not isinstance(raw_ids, list):
91+
log.warn(
92+
f"rp_job | get_stop_signals: jobsToStop is not a list: {type(raw_ids).__name__}"
93+
)
94+
return []
95+
96+
job_ids = [job_id for job_id in raw_ids if isinstance(job_id, str)]
97+
if len(job_ids) != len(raw_ids):
98+
log.warn(
99+
f"rp_job | get_stop_signals: dropped {len(raw_ids) - len(job_ids)} non-string job ids"
100+
)
101+
return job_ids
102+
103+
29104
def _job_get_url(batch_size: int = 1):
30105
"""
31106
Prepare the URL for making a 'get' request to the serverless API (sls).

runpod/serverless/modules/rp_scale.py

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import Any, Dict, Set
1111

1212
from ...http_client import AsyncClientSession, ClientSession, TooManyRequests
13-
from .rp_job import get_job, handle_job
13+
from .rp_job import _job_stop_url, get_job, get_stop_signals, handle_job
1414
from .rp_logger import RunPodLogger
1515
from .worker_state import JobsProgress, IS_LOCAL_TEST
1616

@@ -48,6 +48,13 @@ def __init__(self, config: Dict[str, Any]):
4848
self.config = config
4949
self.job_progress = JobsProgress() # Cache the singleton instance
5050

51+
# maps in-progress job ids to their running tasks so individual jobs
52+
# can be stopped without killing the whole worker
53+
self.jobs_tasks: Dict[str, asyncio.Task] = {}
54+
55+
self.stop_signals_fetcher = get_stop_signals
56+
self.stop_signals_fetcher_timeout = 90
57+
5158
self.jobs_queue = asyncio.Queue(maxsize=self.current_concurrency)
5259

5360
self.concurrency_modifier = _default_concurrency_modifier
@@ -71,6 +78,12 @@ def __init__(self, config: Dict[str, Any]):
7178
if jobs_handler := self.config.get("jobs_handler"):
7279
self.jobs_handler = jobs_handler
7380

81+
if stop_signals_fetcher := self.config.get("stop_signals_fetcher"):
82+
self.stop_signals_fetcher = stop_signals_fetcher
83+
84+
if stop_signals_fetcher_timeout := self.config.get("stop_signals_fetcher_timeout"):
85+
self.stop_signals_fetcher_timeout = stop_signals_fetcher_timeout
86+
7487
async def set_scale(self):
7588
self.current_concurrency = self.concurrency_modifier(self.current_concurrency)
7689

@@ -125,13 +138,14 @@ def handle_shutdown(self, signum, frame):
125138
async def run(self):
126139
# Create an async session that will be closed when the worker is killed.
127140
async with AsyncClientSession() as session:
128-
# Create tasks for getting and running jobs.
141+
# Create the worker's concurrent loops.
129142
jobtake_task = asyncio.create_task(self.get_jobs(session))
130143
jobrun_task = asyncio.create_task(self.run_jobs(session))
144+
jobstop_task = asyncio.create_task(self.monitor_stop_signals(session))
131145

132-
tasks = [jobtake_task, jobrun_task]
146+
tasks = [jobtake_task, jobrun_task, jobstop_task]
133147

134-
# Concurrently run both tasks and wait for both to finish.
148+
# Run the worker's concurrent loops until shutdown.
135149
await asyncio.gather(*tasks)
136150

137151
def is_alive(self):
@@ -226,9 +240,10 @@ async def run_jobs(self, session: ClientSession):
226240
# Fetch as many jobs as the concurrency allows
227241
while len(tasks) < self.current_concurrency and not self.jobs_queue.empty():
228242
job = await self.jobs_queue.get()
229-
# Create a new task for each job and add it to the task list
243+
# Create a new task for each job and track it by job id
230244
task = asyncio.create_task(self.handle_job(session, job))
231245
tasks.add(task)
246+
self.jobs_tasks[job["id"]] = task
232247

233248
# Wait for any job to finish
234249
if tasks:
@@ -249,8 +264,79 @@ async def run_jobs(self, session: ClientSession):
249264
await asyncio.sleep(0.1)
250265

251266

252-
# Ensure all remaining tasks finish before stopping
253-
await asyncio.gather(*tasks)
267+
# Ensure all remaining tasks finish before stopping. Stopped jobs raise
268+
# CancelledError during this drain, which is expected, but a genuine
269+
# handler error must not be silently discarded.
270+
results = await asyncio.gather(*tasks, return_exceptions=True)
271+
for result in results:
272+
if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError):
273+
log.error(f"JobScaler.run_jobs | Task failed during shutdown drain: {result}")
274+
275+
async def monitor_stop_signals(self, session: ClientSession):
276+
"""
277+
Long-polls the dedicated stop channel and stops signalled jobs.
278+
279+
Runs in an infinite loop while the worker is alive. The Runpod server
280+
signals a request to be stopped (for example when it is cancelled or
281+
times out) and this loop stops just that in-progress job, leaving the
282+
worker's other jobs running.
283+
"""
284+
if self.stop_signals_fetcher is get_stop_signals and _job_stop_url() is None:
285+
log.warn(
286+
"JobScaler.monitor_stop_signals | Stop channel could not be derived "
287+
"from the job-take URL; per-job stop is disabled for this worker."
288+
)
289+
return
290+
291+
while self.is_alive():
292+
try:
293+
# Bound the long-poll so shutdown is not blocked by the shared
294+
# session's much longer default timeout.
295+
job_ids = await asyncio.wait_for(
296+
self.stop_signals_fetcher(session),
297+
timeout=self.stop_signals_fetcher_timeout,
298+
)
299+
for job_id in job_ids:
300+
await self.stop_job(job_id)
301+
302+
if not job_ids:
303+
# floor delay so the loop can't busy-spin when the server
304+
# returns immediately instead of holding the poll open
305+
await asyncio.sleep(1)
306+
except TooManyRequests:
307+
await asyncio.sleep(5) # debounce
308+
except asyncio.CancelledError:
309+
log.debug("JobScaler.monitor_stop_signals | Request was cancelled.")
310+
raise # CancelledError is a BaseException
311+
except asyncio.TimeoutError:
312+
log.debug("JobScaler.monitor_stop_signals | Stop poll timed out. Retrying.")
313+
except Exception as error:
314+
log.error(
315+
f"JobScaler.monitor_stop_signals | Error Type: {type(error).__name__} | Error Message: {str(error)}"
316+
)
317+
await asyncio.sleep(1) # don't spin on persistent errors
318+
finally:
319+
await asyncio.sleep(0)
320+
321+
async def stop_job(self, job_id: str) -> bool:
322+
"""
323+
Stop a single in-progress job by cancelling its running task.
324+
325+
Args:
326+
job_id: The id of the job to stop.
327+
328+
Returns:
329+
True if a matching in-progress job was found and stopped,
330+
False otherwise.
331+
"""
332+
task = self.jobs_tasks.get(job_id)
333+
if task is None:
334+
log.debug(f"JobScaler.stop_job | No in-progress job for {job_id}.")
335+
return False
336+
337+
log.info("Stopping job.", job_id)
338+
task.cancel()
339+
return True
254340

255341
async def handle_job(self, session: ClientSession, job: dict):
256342
"""
@@ -264,15 +350,20 @@ async def handle_job(self, session: ClientSession, job: dict):
264350
if self.config.get("refresh_worker", False):
265351
self.kill_worker()
266352

353+
except asyncio.CancelledError:
354+
log.info("Job stopped.", job["id"])
355+
raise # CancelledError is a BaseException
356+
267357
except Exception as err:
268358
log.error(f"Error handling job: {err}", job["id"])
269-
raise err
359+
raise
270360

271361
finally:
272362
# Inform Queue of a task completion
273363
self.jobs_queue.task_done()
274364

275365
# Job is no longer in progress
276366
self.job_progress.remove(job)
367+
self.jobs_tasks.pop(job["id"], None)
277368

278369
log.debug("Finished Job", job["id"])

0 commit comments

Comments
 (0)