@miller-tech/uap 1.172.9 → 1.172.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Stop a detached deliver run once the session that ordered it is gone.
3
+ *
4
+ * `uap deliver` detaches on purpose (see deliver-detach.ts): a mission must
5
+ * outlive the agent's bash tool call, which is a short-lived, process-group-
6
+ * killed container. That is correct and this must not undo it.
7
+ *
8
+ * What it must not outlive is the SESSION. Observed live: an agent client
9
+ * exited, and two detached deliver runs kept driving the model for over an hour
10
+ * — holding both slots, generating tokens for a conversation nobody would ever
11
+ * read — while the operator reasonably reported "I have nothing running". Only
12
+ * killing them by pid stopped it.
13
+ *
14
+ * WHY NOT THE OBVIOUS CHECKS
15
+ * - `ppid === 1`: wrong on any modern Linux desktop. Both orphans observed here
16
+ * had ppid 11681 — the systemd --user manager, which registers as a child
17
+ * subreaper, so orphans are re-parented to IT and never to init.
18
+ * - "re-parented since start": fires immediately on every detached run, because
19
+ * being re-parented is exactly what detaching does. It would kill the feature.
20
+ *
21
+ * So the guard watches a specific OWNER pid — the nearest ancestor that is an
22
+ * agent client — resolved once at detach time and inherited by the child.
23
+ */
24
+ import { readFileSync } from 'node:fs';
25
+ /** Env var carrying the resolved owner pid across the detach boundary. */
26
+ export const OWNER_PID_ENV = 'UAP_DELIVER_OWNER_PID';
27
+ /** Poll interval. Slow on purpose — a janitor, not a latency path. */
28
+ const DEFAULT_INTERVAL_MS = 20_000;
29
+ /**
30
+ * Process names that own a deliver run. A deliver started by one of these is
31
+ * work on behalf of a live session; when that session goes, so does the reason
32
+ * for the run. Anything else (a plain shell, CI, systemd) is deliberately NOT
33
+ * matched — the guard then stays off rather than guessing.
34
+ */
35
+ const CLIENT_COMMS = ['opencode', 'claude', 'cursor', 'codex', 'windsurf'];
36
+ /** `comm` and `ppid` for a pid, or null if it is gone / unreadable. */
37
+ export function readProcInfo(pid, procRoot = '/proc') {
38
+ try {
39
+ // /proc/<pid>/stat: "pid (comm) state ppid ..." — comm can contain spaces and
40
+ // parens, so split on the LAST ')' rather than tokenising the whole line.
41
+ const stat = readFileSync(`${procRoot}/${pid}/stat`, 'utf8');
42
+ const close = stat.lastIndexOf(')');
43
+ const open = stat.indexOf('(');
44
+ if (open < 0 || close < 0 || close < open)
45
+ return null;
46
+ const comm = stat.slice(open + 1, close);
47
+ const rest = stat.slice(close + 2).trim().split(/\s+/);
48
+ const ppid = Number(rest[1]);
49
+ return Number.isFinite(ppid) ? { comm, ppid } : null;
50
+ }
51
+ catch {
52
+ return null; // gone or unreadable — treat as absent
53
+ }
54
+ }
55
+ /**
56
+ * Nearest ancestor that is an agent client, or undefined.
57
+ *
58
+ * Undefined is a first-class answer: a deliver run from a plain shell or from CI
59
+ * has no session to outlive, so it gets no guard at all.
60
+ */
61
+ export function resolveOwnerPid(startPid = process.ppid, opts = {}) {
62
+ const procRoot = opts.procRoot ?? '/proc';
63
+ let pid = startPid;
64
+ for (let depth = 0; depth < (opts.maxDepth ?? 12); depth++) {
65
+ if (pid <= 1)
66
+ return undefined;
67
+ const info = readProcInfo(pid, procRoot);
68
+ if (!info)
69
+ return undefined;
70
+ const comm = info.comm.toLowerCase();
71
+ if (CLIENT_COMMS.some((c) => comm === c || comm.startsWith(c)))
72
+ return pid;
73
+ pid = info.ppid;
74
+ }
75
+ return undefined;
76
+ }
77
+ /** Is that pid still around? */
78
+ export function pidAlive(pid) {
79
+ try {
80
+ process.kill(pid, 0); // signal 0: existence check, delivers nothing
81
+ return true;
82
+ }
83
+ catch (err) {
84
+ // EPERM means it exists but belongs to someone else — alive for our purpose.
85
+ return err?.code === 'EPERM';
86
+ }
87
+ }
88
+ /**
89
+ * Exit when the owning session exits. No-op when there is no owner, or when the
90
+ * operator opted out with UAP_ALLOW_ORPHAN=1 (a deliberately detached run — say,
91
+ * a long mission started over SSH).
92
+ *
93
+ * Returns a stop function. The timer is unref'd: a watchdog that by itself kept
94
+ * the process alive would be its own bug.
95
+ */
96
+ export function guardAgainstOwnerExit(opts = {}) {
97
+ const optOut = (process.env.UAP_ALLOW_ORPHAN ?? '').toLowerCase();
98
+ if (['1', 'true', 'on', 'yes'].includes(optOut))
99
+ return () => { };
100
+ const ownerPid = Number(process.env[OWNER_PID_ENV]);
101
+ if (!Number.isFinite(ownerPid) || ownerPid <= 1)
102
+ return () => { };
103
+ const isAlive = opts.isAlive ?? pidAlive;
104
+ const timer = setInterval(() => {
105
+ if (isAlive(ownerPid))
106
+ return;
107
+ clearInterval(timer);
108
+ if (opts.onOwnerGone) {
109
+ opts.onOwnerGone(ownerPid);
110
+ return;
111
+ }
112
+ // Say why on the way out: a run that vanishes silently is the same
113
+ // debugging problem as one that never stops.
114
+ console.error(`\nuap: the session that started this run (pid ${ownerPid}) has exited — ` +
115
+ `stopping rather than holding a model slot for output nobody will read. ` +
116
+ `Set ${'UAP_ALLOW_ORPHAN'}=1 to keep detached runs alive.`);
117
+ process.exit(130); // 128 + SIGINT: ended by circumstance, not by failing
118
+ }, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
119
+ timer.unref?.();
120
+ return () => clearInterval(timer);
121
+ }
122
+ //# sourceMappingURL=orphan-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orphan-guard.js","sourceRoot":"","sources":["../../src/delivery/orphan-guard.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.172.9",
3
+ "version": "1.172.11",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -83,6 +83,7 @@ Dependencies
83
83
 
84
84
  import asyncio
85
85
  import contextvars
86
+ from typing import Awaitable, Callable
86
87
  import copy
87
88
  import hashlib
88
89
  import json
@@ -2958,6 +2959,47 @@ _current_request_session: contextvars.ContextVar[str | None] = contextvars.Conte
2958
2959
  "uap_current_request_session", default=None
2959
2960
  )
2960
2961
 
2962
+ # Is the DOWNSTREAM client still there?
2963
+ #
2964
+ # Nothing used to ask. Once a turn was accepted, the guardrail loops kept
2965
+ # retrying and re-POSTing upstream on their own — so when the client died the
2966
+ # work carried on regardless. Observed live: two `uap deliver` runs were killed,
2967
+ # no socket remained on :4000, and the proxy still drove llama for minutes,
2968
+ # generating 32k tokens per orphaned turn that nobody would ever read. Only a
2969
+ # proxy restart stopped it.
2970
+ #
2971
+ # Same ContextVar trick as the session above: request-local without threading a
2972
+ # Request through every signature. Holds Starlette's `request.is_disconnected`.
2973
+ _current_client_gone: contextvars.ContextVar[
2974
+ Callable[[], Awaitable[bool]] | None
2975
+ ] = contextvars.ContextVar("uap_current_client_gone", default=None)
2976
+
2977
+
2978
+ class ClientGoneError(Exception):
2979
+ """The downstream client disconnected; abandon the turn.
2980
+
2981
+ Raised instead of returning a value so the whole in-flight chain unwinds at
2982
+ once — every guardrail loop, retry and recovery step above it stops without
2983
+ each needing its own check. Caught at the handler boundary and answered with
2984
+ a status nobody reads, since by definition there is no one left to read it.
2985
+ """
2986
+
2987
+
2988
+ async def _client_gone() -> bool:
2989
+ """True when the caller has hung up. Never raises, never blocks meaningfully.
2990
+
2991
+ A disconnect check that can itself fail or stall would be worse than no
2992
+ check: it sits in front of every upstream call. Any error means "assume the
2993
+ client is still there" — the pre-existing behaviour.
2994
+ """
2995
+ probe = _current_client_gone.get()
2996
+ if probe is None:
2997
+ return False
2998
+ try:
2999
+ return bool(await probe())
3000
+ except Exception: # noqa: BLE001
3001
+ return False
3002
+
2961
3003
  # Session admission state. _admitted_sessions maps session_id -> last-seen
2962
3004
  # monotonic ts; OrderedDict insertion order is the LRU (oldest = front).
2963
3005
  # Guarded by _admission_cond's lock (created lazily on the running event loop).
@@ -3289,6 +3331,12 @@ async def _post_with_retry(
3289
3331
  # don't evict each other's KV (sticky, released by idle-TTL). No-op when
3290
3332
  # PROXY_SESSION_ADMISSION is off. Runs BEFORE the per-request semaphore so a
3291
3333
  # queued new session waits without holding a concurrency slot.
3334
+ # Every upstream call funnels through here, so one check covers all of them:
3335
+ # the guardrail retry loop, the completion-contract retries, recovery passes.
3336
+ # Placed BEFORE admission and the slot acquire so a dead client never takes a
3337
+ # concurrency slot away from a live one, and never queues behind one either.
3338
+ if await _client_gone():
3339
+ raise ClientGoneError("client disconnected before upstream call")
3292
3340
  await _ensure_session_admitted(_current_request_session.get())
3293
3341
  acquired = await _acquire_upstream_slot()
3294
3342
  if not acquired:
@@ -3542,6 +3590,23 @@ app = FastAPI(
3542
3590
  lifespan=lifespan,
3543
3591
  )
3544
3592
 
3593
+ @app.exception_handler(ClientGoneError)
3594
+ async def _client_gone_handler(request: Request, exc: ClientGoneError):
3595
+ """The caller hung up mid-turn: stop, and say so at INFO rather than ERROR.
3596
+
3597
+ 499 is nginx's "client closed request" — chosen because no standard code
3598
+ means this and the body goes nowhere anyway; what matters is that the log
3599
+ line reads as an abandoned turn rather than a proxy fault, so this does not
3600
+ masquerade as an incident when someone kills an agent run.
3601
+ """
3602
+ logger.info(
3603
+ "CLIENT GONE: abandoned %s mid-turn (session=%s) — no further upstream calls",
3604
+ request.url.path,
3605
+ (_current_request_session.get() or "?")[:24],
3606
+ )
3607
+ return Response(status_code=499)
3608
+
3609
+
3545
3610
  @app.exception_handler(httpx.PoolTimeout)
3546
3611
  async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
3547
3612
  """Saturated upstream pool: answer 529 overloaded (Anthropic semantics —
@@ -10791,6 +10856,10 @@ async def messages(request: Request):
10791
10856
  """
10792
10857
  global last_session_id
10793
10858
 
10859
+ # Publish this request's disconnect probe for the whole call tree. Set before
10860
+ # ANY upstream work so an already-dead client is caught on the first call.
10861
+ _current_client_gone.set(request.is_disconnected)
10862
+
10794
10863
  body = await request.json()
10795
10864
  is_stream = body.get("stream", False)
10796
10865
  model = body.get("model", "default")
@@ -11067,6 +11136,8 @@ async def messages(request: Request):
11067
11136
  strict_body,
11068
11137
  {"Content-Type": "application/json"},
11069
11138
  )
11139
+ except ClientGoneError:
11140
+ raise # control flow: never swallow into a broad handler
11070
11141
  except Exception as exc:
11071
11142
  # Check if upstream is hung before returning error
11072
11143
  await _check_slot_hang(LLAMA_CPP_BASE.replace("/v1", "/slots"))
@@ -11099,6 +11170,8 @@ async def messages(request: Request):
11099
11170
  strict_body,
11100
11171
  {"Content-Type": "application/json"},
11101
11172
  )
11173
+ except ClientGoneError:
11174
+ raise # control flow: never swallow into a broad handler
11102
11175
  except Exception:
11103
11176
  pass # fall through to next handler
11104
11177
  if strict_resp.status_code != 200:
@@ -11116,6 +11189,8 @@ async def messages(request: Request):
11116
11189
  strict_body,
11117
11190
  {"Content-Type": "application/json"},
11118
11191
  )
11192
+ except ClientGoneError:
11193
+ raise # control flow: never swallow into a broad handler
11119
11194
  except Exception as exc:
11120
11195
  return Response(
11121
11196
  content=json.dumps(
@@ -11242,6 +11317,8 @@ async def messages(request: Request):
11242
11317
  openai_resp = retry_data
11243
11318
  else:
11244
11319
  logger.info("DEGENERATE RETRY: retry insufficient, using truncated original")
11320
+ except ClientGoneError:
11321
+ raise # control flow: never swallow into a broad handler
11245
11322
  except Exception as exc:
11246
11323
  logger.warning("DEGENERATE RETRY: failed: %s", exc)
11247
11324
  anthropic_resp = openai_to_anthropic_response(
@@ -11535,6 +11612,8 @@ async def messages(request: Request):
11535
11612
  openai_body,
11536
11613
  {"Content-Type": "application/json"},
11537
11614
  )
11615
+ except ClientGoneError:
11616
+ raise # control flow: never swallow into a broad handler
11538
11617
  except Exception as exc:
11539
11618
  return Response(
11540
11619
  content=json.dumps(
@@ -11565,6 +11644,8 @@ async def messages(request: Request):
11565
11644
  openai_body,
11566
11645
  {"Content-Type": "application/json"},
11567
11646
  )
11647
+ except ClientGoneError:
11648
+ raise # control flow: never swallow into a broad handler
11568
11649
  except Exception:
11569
11650
  pass # fall through
11570
11651
  if resp.status_code != 200:
@@ -11582,6 +11663,8 @@ async def messages(request: Request):
11582
11663
  openai_body,
11583
11664
  {"Content-Type": "application/json"},
11584
11665
  )
11666
+ except ClientGoneError:
11667
+ raise # control flow: never swallow into a broad handler
11585
11668
  except Exception as exc:
11586
11669
  return Response(
11587
11670
  content=json.dumps(
@@ -11699,6 +11782,8 @@ async def messages(request: Request):
11699
11782
  openai_resp = retry_data
11700
11783
  else:
11701
11784
  logger.info("DEGENERATE RETRY (stream): no tool call, using truncated")
11785
+ except ClientGoneError:
11786
+ raise # control flow: never swallow into a broad handler
11702
11787
  except Exception as exc:
11703
11788
  logger.warning("DEGENERATE RETRY (stream): failed: %s", exc)
11704
11789
  anthropic_resp = openai_to_anthropic_response(
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """The proxy must abandon a turn when the downstream client disconnects.
3
+
4
+ Nothing used to ask whether the caller was still there. Once a turn was
5
+ accepted, the guardrail loops kept retrying and re-POSTing upstream on their
6
+ own — so when the client died the work carried on regardless. Observed live
7
+ (2026-07-29): two `uap deliver` runs were killed, no socket remained on :4000,
8
+ and the proxy still drove llama for minutes, generating ~32k tokens per
9
+ orphaned turn that nobody would ever read. Only a proxy restart stopped it.
10
+ """
11
+
12
+ import asyncio
13
+ import importlib.util
14
+ import unittest
15
+ from pathlib import Path
16
+
17
+
18
+ def _load_proxy():
19
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
20
+ spec = importlib.util.spec_from_file_location("anthropic_proxy_disc", p)
21
+ assert spec is not None and spec.loader is not None
22
+ m = importlib.util.module_from_spec(spec)
23
+ spec.loader.exec_module(m)
24
+ return m
25
+
26
+
27
+ proxy = _load_proxy()
28
+
29
+
30
+ class ClientGoneProbeTest(unittest.TestCase):
31
+ def test_absent_probe_means_client_is_present(self):
32
+ """No probe set (background task, test harness) must not read as gone —
33
+ that would abandon perfectly live turns."""
34
+ proxy._current_client_gone.set(None)
35
+ self.assertFalse(asyncio.run(proxy._client_gone()))
36
+
37
+ def test_reports_disconnect(self):
38
+ async def gone() -> bool:
39
+ return True
40
+
41
+ proxy._current_client_gone.set(gone)
42
+ self.assertTrue(asyncio.run(proxy._client_gone()))
43
+
44
+ def test_reports_connected(self):
45
+ async def here() -> bool:
46
+ return False
47
+
48
+ proxy._current_client_gone.set(here)
49
+ self.assertFalse(asyncio.run(proxy._client_gone()))
50
+
51
+ def test_a_failing_probe_never_breaks_the_request(self):
52
+ """This check sits in front of every upstream call. A probe that can
53
+ raise would be worse than no probe, so failure means 'still there'."""
54
+ async def broken() -> bool:
55
+ raise RuntimeError("receive channel exploded")
56
+
57
+ proxy._current_client_gone.set(broken)
58
+ self.assertFalse(asyncio.run(proxy._client_gone()))
59
+
60
+
61
+ class ClientGoneIsNotSwallowedTest(unittest.TestCase):
62
+ """The unwind only works if no broad handler eats it on the way out.
63
+
64
+ `messages()` has eight `except Exception` blocks. Any one of them catching
65
+ ClientGoneError would turn "client left" into a logged error and let the
66
+ turn continue — silently reinstating the bug while every other test passed.
67
+ """
68
+
69
+ def test_every_broad_except_in_messages_reraises_first(self):
70
+ src = (
71
+ Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
72
+ ).read_text().split("\n")
73
+ start = next(i for i, l in enumerate(src) if l.startswith("async def messages(request: Request)"))
74
+ end = next(
75
+ (i for i in range(start + 1, len(src)) if src[i].startswith("async def ") or src[i].startswith("@app.")),
76
+ len(src),
77
+ )
78
+
79
+ unguarded = []
80
+ for i in range(start, end):
81
+ stripped = src[i].lstrip()
82
+ if not stripped.startswith("except Exception") and not stripped.startswith("except BaseException"):
83
+ continue
84
+ # The two lines above must be the ClientGoneError re-raise.
85
+ prev = [src[i - 2].strip(), src[i - 1].strip()] if i >= 2 else []
86
+ if prev[:1] != ["except ClientGoneError:"] or prev[1:] and not prev[1].startswith("raise"):
87
+ unguarded.append((i + 1, stripped[:60]))
88
+
89
+ self.assertEqual(unguarded, [], f"broad handlers that would swallow the disconnect: {unguarded}")
90
+
91
+ def test_client_gone_is_an_exception_so_fastapi_can_handle_it(self):
92
+ """Deliberately NOT a BaseException: Starlette's middleware only
93
+ dispatches Exception subclasses, so a BaseException would sail past the
94
+ registered handler and take down the worker instead of answering 499."""
95
+ self.assertTrue(issubclass(proxy.ClientGoneError, Exception))
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()