@ponythewhite/base-context 1.0.14 → 1.0.16
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.
- package/CHANGELOG.md +18 -0
- package/dist/base-context-runtime/pyproject.toml +1 -1
- package/dist/base-context-runtime/src/rlm/bash.py +19 -0
- package/dist/base-context-runtime/src/rlm/job_watch.py +156 -0
- package/dist/base-context-runtime/src/rlm/repl.md +28 -6
- package/dist/base-context-runtime/src/rlm/repl.py +6 -1
- package/dist/base-context-runtime/test/test_bash.py +4 -4
- package/dist/base-context-runtime/test/test_job_watch.py +67 -0
- package/dist/base-context-runtime/uv.lock +1 -1
- package/dist/build-info.json +1 -1
- package/dist/bundle/{bundled-modules-UODO7ZR7.js → bundled-modules-2DMTK7VK.js} +2 -2
- package/dist/bundle/{chunk-SX7CAXWU.js → chunk-MSH6QRP5.js} +1730 -867
- package/dist/bundle/{chunk-NLRWERWA.js → chunk-T2OK7SRV.js} +53 -21
- package/dist/bundle/{cli-main-6GXXIMQQ.js → cli-main-WL2L5IEQ.js} +2 -2
- package/dist/bundle/cli.js +1 -1
- package/dist/bundle/{main-HF64B4HM.js → main-XPIEIEQJ.js} +2 -2
- package/dist/core/agent-session.d.ts +2 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +191 -33
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/compaction/compaction.d.ts +1 -1
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +11 -9
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/history-index-worker.d.ts.map +1 -1
- package/dist/core/history-index-worker.js +22 -1
- package/dist/core/history-index-worker.js.map +1 -1
- package/dist/core/history-index.d.ts +2 -0
- package/dist/core/history-index.d.ts.map +1 -1
- package/dist/core/history-index.js.map +1 -1
- package/dist/core/inference-coordinator.d.ts.map +1 -1
- package/dist/core/inference-coordinator.js +13 -2
- package/dist/core/inference-coordinator.js.map +1 -1
- package/dist/core/job-watch.d.ts +120 -0
- package/dist/core/job-watch.d.ts.map +1 -0
- package/dist/core/job-watch.js +648 -0
- package/dist/core/job-watch.js.map +1 -0
- package/dist/core/kernel/repl-manager.d.ts +5 -1
- package/dist/core/kernel/repl-manager.d.ts.map +1 -1
- package/dist/core/kernel/repl-manager.js +57 -0
- package/dist/core/kernel/repl-manager.js.map +1 -1
- package/dist/core/kernel/shared.d.ts +12 -0
- package/dist/core/kernel/shared.d.ts.map +1 -1
- package/dist/core/kernel/shared.js.map +1 -1
- package/dist/core/prompts/rlm.d.ts.map +1 -1
- package/dist/core/prompts/rlm.js +49 -39
- package/dist/core/prompts/rlm.js.map +1 -1
- package/dist/core/session-manager.d.ts +10 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +66 -1
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/tools/prime-context.d.ts.map +1 -1
- package/dist/core/tools/prime-context.js +1 -1
- package/dist/core/tools/prime-context.js.map +1 -1
- package/dist/modes/daemon/daemon-mode.d.ts.map +1 -1
- package/dist/modes/daemon/daemon-mode.js +17 -17
- package/dist/modes/daemon/daemon-mode.js.map +1 -1
- package/dist/modes/daemon/daemon-worker-client.d.ts +1 -0
- package/dist/modes/daemon/daemon-worker-client.d.ts.map +1 -1
- package/dist/modes/daemon/daemon-worker-client.js +8 -0
- package/dist/modes/daemon/daemon-worker-client.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +2 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +31 -5
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/skills/agent-message/SKILL.md +8 -2
- package/dist/skills/bounded-inspect/SKILL.md +78 -0
- package/dist/skills/bounded-inspect/pyproject.toml +16 -0
- package/dist/skills/bounded-inspect/src/bounded_inspect/__init__.py +313 -0
- package/dist/skills/bounded-inspect/src/bounded_inspect/__main__.py +3 -0
- package/dist/skills/job-watch/SKILL.md +43 -0
- package/dist/skills/job-watch/pyproject.toml +13 -0
- package/dist/skills/job-watch/src/job_watch/__init__.py +61 -0
- package/dist/skills/rlm-heartbeat/SKILL.md +10 -6
- package/dist/utils/file-lines.d.ts.map +1 -1
- package/dist/utils/file-lines.js +6 -2
- package/dist/utils/file-lines.js.map +1 -1
- package/docs/rlm-runtime.md +4 -1
- package/docs/skills.md +18 -26
- package/docs/upstream-1.0.15.md +83 -0
- package/package.json +4 -4
- package/skills/agent-message/SKILL.md +8 -2
- package/skills/bounded-inspect/SKILL.md +78 -0
- package/skills/bounded-inspect/pyproject.toml +16 -0
- package/skills/bounded-inspect/src/bounded_inspect/__init__.py +313 -0
- package/skills/bounded-inspect/src/bounded_inspect/__main__.py +3 -0
- package/skills/job-watch/SKILL.md +43 -0
- package/skills/job-watch/pyproject.toml +13 -0
- package/skills/job-watch/src/job_watch/__init__.py +61 -0
- package/skills/rlm-heartbeat/SKILL.md +10 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.16] - 2026-09-28
|
|
4
|
+
|
|
5
|
+
- Fixed background child-usage updates incorrectly stopping an active goal during request preparation.
|
|
6
|
+
- Kept failed goals visible in the bottom-right status tray instead of hiding them.
|
|
7
|
+
- Fixed temporary memory retention when reading a final multi-chunk line without a trailing newline.
|
|
8
|
+
- Added `job-watch` for native job monitoring, prepared reports, and scoped waiting in goal and autonomous modes.
|
|
9
|
+
- Added `bounded-inspect` for selected text and JSON evidence under shared source and output budgets.
|
|
10
|
+
- Improved skill recovery, child-result capsule guidance, and reuse of small project helpers.
|
|
11
|
+
|
|
12
|
+
## [1.0.15] - 2026-09-28
|
|
13
|
+
|
|
14
|
+
- Fixed compaction of long-lived sessions by selecting the active canonical context before source hydration. This covers manual, agent-requested, threshold, and overflow-recovery compaction without increasing source limits.
|
|
15
|
+
- Fixed Codex HTTP retries losing native context acknowledgement after retryable failures. Only an unchanged prepared request can reuse its acknowledged epoch.
|
|
16
|
+
- Changed `/goal stop` and `/goal clear` to acknowledge the goal that was cleared instead of reporting only the resulting idle state.
|
|
17
|
+
- Fixed daemon worker connection retries retaining failed socket and channel references.
|
|
18
|
+
- Handled Python kernel stderr stream errors through the existing kernel error path.
|
|
19
|
+
- Fixed the goal status display being overwritten by delayed state reads and streamed catch-up snapshots.
|
|
20
|
+
|
|
3
21
|
## [1.0.14] - 2026-09-23
|
|
4
22
|
|
|
5
23
|
- Fixed compaction recovery after failed or interrupted partial tool calls, including resuming affected saved sessions without exposing failed replies to the provider.
|
|
@@ -594,6 +594,25 @@ class BashHandle:
|
|
|
594
594
|
return
|
|
595
595
|
callback()
|
|
596
596
|
|
|
597
|
+
def add_done_callback(self, callback: Callable[[BashResult], None]) -> Callable[[], None]:
|
|
598
|
+
"""Observe completion without taking await ownership; return an unsubscribe function.
|
|
599
|
+
|
|
600
|
+
The callback may run on a worker thread or immediately for a finished handle.
|
|
601
|
+
Removing it never signals the process. A callback already running may finish.
|
|
602
|
+
"""
|
|
603
|
+
def invoke() -> None:
|
|
604
|
+
assert self._result is not None
|
|
605
|
+
callback(self._result)
|
|
606
|
+
|
|
607
|
+
self._add_done_callback(invoke)
|
|
608
|
+
|
|
609
|
+
def remove() -> None:
|
|
610
|
+
with self._callback_lock:
|
|
611
|
+
if invoke in self._callbacks:
|
|
612
|
+
self._callbacks.remove(invoke)
|
|
613
|
+
|
|
614
|
+
return remove
|
|
615
|
+
|
|
597
616
|
async def _wait(self) -> BashResult:
|
|
598
617
|
# Asyncio-native wakeup: no executor thread is parked for the command's
|
|
599
618
|
# duration, so many concurrent awaits cannot exhaust the default pool.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Contained job-watch probes and completion callbacks. No notebook evaluation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import uuid
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
import weakref
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from .bash import BashHandle, BashResult, bash
|
|
14
|
+
|
|
15
|
+
_identities: weakref.WeakKeyDictionary[BashHandle, str] = weakref.WeakKeyDictionary()
|
|
16
|
+
_handles: weakref.WeakValueDictionary[str, BashHandle] = weakref.WeakValueDictionary()
|
|
17
|
+
_results: weakref.WeakKeyDictionary[BashHandle, BashResult] = weakref.WeakKeyDictionary()
|
|
18
|
+
_callbacks: dict[tuple[str, str], Callable[[], None]] = {}
|
|
19
|
+
_probes: dict[str, asyncio.Task[None]] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def identity(handle: BashHandle) -> str:
|
|
23
|
+
if not isinstance(handle, BashHandle):
|
|
24
|
+
raise TypeError("job must be a live BashHandle")
|
|
25
|
+
resource = _identities.setdefault(handle, uuid.uuid4().hex)
|
|
26
|
+
_handles[resource] = handle
|
|
27
|
+
return resource
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _observed(job_id: str, result: BashResult | None) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"observed_at": datetime.now(timezone.utc).isoformat(),
|
|
33
|
+
"job_id": job_id,
|
|
34
|
+
"state": "running" if result is None else "succeeded" if result.exit_code == 0 else "failed",
|
|
35
|
+
"progress": {} if result is None else {"exit_code": result.exit_code, "duration_seconds": result.duration},
|
|
36
|
+
"attention": [], "evidence": [],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bind(handle: BashHandle, watch_id: str, generation: str, job_id: str) -> None:
|
|
41
|
+
from . import host_request
|
|
42
|
+
loop = asyncio.get_running_loop()
|
|
43
|
+
key = (watch_id, generation)
|
|
44
|
+
if key in _callbacks:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
async def deliver(observation: dict[str, Any]) -> None:
|
|
48
|
+
try:
|
|
49
|
+
await host_request("job_watch.observation", {
|
|
50
|
+
"id": watch_id, "generation": generation,
|
|
51
|
+
"result": {"source": "handle", "observation": observation},
|
|
52
|
+
})
|
|
53
|
+
except RuntimeError:
|
|
54
|
+
pass # Host teardown owns recovery; never restart a monitored process.
|
|
55
|
+
|
|
56
|
+
def completed(result: BashResult) -> None:
|
|
57
|
+
_results[handle] = result
|
|
58
|
+
observation = _observed(job_id, result)
|
|
59
|
+
try:
|
|
60
|
+
loop.call_soon_threadsafe(lambda: asyncio.create_task(deliver(observation)))
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
_callbacks[key] = handle.add_done_callback(completed)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unbind(watch_id: str) -> None:
|
|
68
|
+
for key in list(_callbacks):
|
|
69
|
+
if key[0] == watch_id:
|
|
70
|
+
_callbacks.pop(key)()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SAFE_INTEGER = 2**53 - 1
|
|
74
|
+
_NUMERIC_ERROR = "probe numeric precision is unavailable; encode exact identifiers and precision-sensitive values as strings"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _exact_integer(token: str) -> int:
|
|
78
|
+
try:
|
|
79
|
+
value = int(token)
|
|
80
|
+
except ValueError:
|
|
81
|
+
raise ValueError(_NUMERIC_ERROR) from None
|
|
82
|
+
if abs(value) > _SAFE_INTEGER or token == "-0":
|
|
83
|
+
raise ValueError(_NUMERIC_ERROR)
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _exact_decimal(token: str) -> float:
|
|
88
|
+
value = float(token)
|
|
89
|
+
if (not math.isfinite(value) or abs(value) > _SAFE_INTEGER
|
|
90
|
+
or (value == 0 and token.startswith("-")) or Decimal(str(value)) != Decimal(token)):
|
|
91
|
+
raise ValueError(_NUMERIC_ERROR)
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _probe(request: dict[str, Any]) -> None:
|
|
96
|
+
from . import host_request
|
|
97
|
+
request_id = request["id"]
|
|
98
|
+
try:
|
|
99
|
+
command = request.get("command")
|
|
100
|
+
handle = _handles.get(request["resourceId"])
|
|
101
|
+
if request.get("completionSource") == "handle" and handle is None:
|
|
102
|
+
result = {"source": "handle", "error": "handle unavailable after kernel restart"}
|
|
103
|
+
elif command is None:
|
|
104
|
+
result = ({"source": "handle", "observation": _observed(request["jobId"], _results.get(handle))}
|
|
105
|
+
if handle is not None else {"source": "handle", "error": "handle unavailable after kernel restart"})
|
|
106
|
+
else:
|
|
107
|
+
# The first await owns this new probe only. Cancellation never reaches the monitored job.
|
|
108
|
+
finished = await asyncio.wait_for(bash(command), timeout=request["timeoutMs"] / 1000)
|
|
109
|
+
output = finished.output
|
|
110
|
+
result = {"source": "probe", "output": output, "capture_complete": False}
|
|
111
|
+
if finished.exit_code != 0:
|
|
112
|
+
result["error"] = f"probe exited {finished.exit_code}"
|
|
113
|
+
elif len(output.encode("utf-8")) > 16384:
|
|
114
|
+
result["error"] = "probe JSON exceeds 16 KiB; use a project summary producer"
|
|
115
|
+
else:
|
|
116
|
+
try:
|
|
117
|
+
result["observation"] = json.loads(output, parse_int=_exact_integer,
|
|
118
|
+
parse_float=_exact_decimal, parse_constant=_exact_decimal)
|
|
119
|
+
except (ValueError, TypeError) as error:
|
|
120
|
+
result["error"] = (str(error) if isinstance(error, ValueError) and not isinstance(error, json.JSONDecodeError)
|
|
121
|
+
else "probe JSON unavailable or incomplete")
|
|
122
|
+
await host_request("job_watch.probe_result", {"request_id": request_id, "result": result})
|
|
123
|
+
except asyncio.CancelledError:
|
|
124
|
+
raise
|
|
125
|
+
except Exception as error:
|
|
126
|
+
try:
|
|
127
|
+
await host_request("job_watch.probe_result", {
|
|
128
|
+
"request_id": request_id, "result": {"source": "probe", "error": f"{type(error).__name__}: {error}"[:256]},
|
|
129
|
+
})
|
|
130
|
+
except RuntimeError:
|
|
131
|
+
pass
|
|
132
|
+
finally:
|
|
133
|
+
_probes.pop(request_id, None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def dispatch(request: dict[str, Any]) -> None:
|
|
137
|
+
"""Called only by the REPL protocol dispatcher, not by notebook eval."""
|
|
138
|
+
request_id = request.get("id")
|
|
139
|
+
if not isinstance(request_id, str):
|
|
140
|
+
raise ValueError("job_watch_probe requires an id")
|
|
141
|
+
if request.get("cancel"):
|
|
142
|
+
task = _probes.get(request_id)
|
|
143
|
+
if task:
|
|
144
|
+
task.cancel()
|
|
145
|
+
return
|
|
146
|
+
if request_id in _probes:
|
|
147
|
+
return
|
|
148
|
+
if not isinstance(request.get("jobId"), str) or not isinstance(request.get("resourceId"), str):
|
|
149
|
+
raise ValueError("job_watch_probe requires resource and job identity")
|
|
150
|
+
command = request.get("command")
|
|
151
|
+
if command is not None and (not isinstance(command, str) or not command or len(command.encode()) > 8192):
|
|
152
|
+
raise ValueError("invalid probe command")
|
|
153
|
+
timeout = request.get("timeoutMs")
|
|
154
|
+
if not isinstance(timeout, (int, float)) or not 1 <= timeout <= 300000:
|
|
155
|
+
raise ValueError("invalid probe timeout")
|
|
156
|
+
_probes[request_id] = asyncio.create_task(_probe(request))
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
`python -m rlm.repl` starts a CPython REPL runtime that executes code cells in
|
|
4
4
|
one persistent `__main__` namespace on a single asyncio event loop. The wire
|
|
5
5
|
format is newline-delimited JSON: one object per line, UTF-8, no other framing.
|
|
6
|
-
The current protocol version is `
|
|
6
|
+
The current protocol version is `4`; the runtime announces it in the `ready`
|
|
7
7
|
event.
|
|
8
8
|
|
|
9
9
|
## Channels
|
|
@@ -30,20 +30,23 @@ event.
|
|
|
30
30
|
| `execute` | `{"type":"execute","id":str,"code":str}` |
|
|
31
31
|
| `interrupt` | `{"type":"interrupt","id"?:str}` — no reply |
|
|
32
32
|
| `host_reply` | `{"type":"host_reply","id":str,"data":{"status":"ok","result":{...}}}` or an error envelope — no reply |
|
|
33
|
+
| `job_watch_probe` | Optional capability: `{"type":"job_watch_probe","id":str,"watchId":str,"generation":str,"resourceId":str,"jobId":str,"completionSource":"handle"|"probe","command":str|null,"timeoutMs":number}`; cancel with `{"type":"job_watch_probe","id":str,"cancel":true}` |
|
|
33
34
|
| `snapshot` | `{"type":"snapshot","id":str,"path":str,"manifest_path":str,"max_bytes"?:int,"max_variable_bytes"?:int,"prune_oversized"?:bool}` |
|
|
34
35
|
| `restore` | `{"type":"restore","id":str,"path":str}` |
|
|
35
36
|
| `list_names` | `{"type":"list_names","id":str}` |
|
|
36
37
|
| `shutdown` | `{"type":"shutdown","id"?:str}` |
|
|
37
38
|
|
|
38
|
-
Requests other than `interrupt
|
|
39
|
-
a time. A malformed line
|
|
39
|
+
Requests other than `interrupt`, `host_reply`, and the optional `job_watch_probe`
|
|
40
|
+
run strictly in order, one at a time. A malformed line
|
|
40
41
|
produces `{"event":"error","id":null,"ename":"ProtocolError",...}` and the
|
|
41
42
|
runtime keeps serving. Closing stdin is equivalent to `shutdown`.
|
|
42
43
|
|
|
43
44
|
## Events
|
|
44
45
|
|
|
45
|
-
- `{"event":"ready","protocol":
|
|
46
|
-
the handshake. No banner precedes it.
|
|
46
|
+
- `{"event":"ready","protocol":4,"python":"3.13.11"}` — sent once at startup;
|
|
47
|
+
the handshake. No banner precedes it. The optional `capabilities` array may
|
|
48
|
+
contain `"job_watch_probe_v1"`. Protocol version remains 4; old hosts ignore
|
|
49
|
+
optional metadata and new hosts refuse probes locally when the capability is absent.
|
|
47
50
|
- `{"event":"stdout"|"stderr","id":str|null,"text":str}` — captured output.
|
|
48
51
|
`id` is the cell whose Python execution context performed the write; asyncio
|
|
49
52
|
tasks inherit the spawning cell's id (even after that cell finished). `null`
|
|
@@ -62,7 +65,7 @@ runtime keeps serving. Closing stdin is equivalent to `shutdown`.
|
|
|
62
65
|
carrying the same id.
|
|
63
66
|
- `{"event":"error","id":str|null,"ename":str,"evalue":str,"traceback":[str,...]}`
|
|
64
67
|
- `{"event":"done","id":str,"status":"ok"|"error"}` — exactly one per id'd
|
|
65
|
-
request, always after all of that request's other events. A snapshot `done`
|
|
68
|
+
queued request, always after all of that request's other events. A snapshot `done`
|
|
66
69
|
adds `saved`, `skipped`, `pruned`, `bytes`; a restore `done` adds `restored`,
|
|
67
70
|
`failed`; a `list_names` `done` adds `names`; a failed snapshot/restore adds
|
|
68
71
|
`reason`. Restoring a missing file reports `status:"ok"` with empty
|
|
@@ -138,6 +141,25 @@ in-flight execute. Replies for unknown ids, or for a request whose awaiting
|
|
|
138
141
|
cell was cancelled, are dropped. `rlm.repl.is_active()` reports whether the
|
|
139
142
|
process is serving the protocol (importing the module does not count).
|
|
140
143
|
|
|
144
|
+
## Optional job-watch bridge
|
|
145
|
+
|
|
146
|
+
`job_watch_probe_v1` runs contained read-only probes outside the ordinary cell
|
|
147
|
+
FIFO, not notebook closures. Results use the existing host bridge with
|
|
148
|
+
`type:"job_watch.probe_result"`, the original `request_id`, and `result`; this
|
|
149
|
+
request does not produce an ordinary cell `done`. Local handle completion uses
|
|
150
|
+
`job_watch.observation` with watch identity/generation. The host owns comparison,
|
|
151
|
+
scheduling, prepared follow-ups, and scoped goal/autonomous parking.
|
|
152
|
+
|
|
153
|
+
Probe commands must name the intended working directory, interpreter, and paths;
|
|
154
|
+
no notebook environment snapshot is persisted.
|
|
155
|
+
|
|
156
|
+
`completionSource` distinguishes a local handle from an authoritative external
|
|
157
|
+
probe. A lost handle becomes unknown; neither a PID nor a successful detached
|
|
158
|
+
launcher is proof of remote completion. Cancelling a probe or unregistering a
|
|
159
|
+
watch does not kill the monitored job. The passive Bash callback bridge does
|
|
160
|
+
not change interrupted-await ownership. This adds no daemon wire version or
|
|
161
|
+
model tool and requires no extra startup service.
|
|
162
|
+
|
|
141
163
|
## Snapshot / restore
|
|
142
164
|
|
|
143
165
|
`snapshot` serializes the user namespace with `dill` (recurse mode), one name
|
|
@@ -986,6 +986,10 @@ def _handle_request_line(raw: bytes, queue: asyncio.Queue[dict[str, Any]]) -> No
|
|
|
986
986
|
return
|
|
987
987
|
_request_interrupt(req.get("id"))
|
|
988
988
|
return
|
|
989
|
+
if rtype == "job_watch_probe":
|
|
990
|
+
from .job_watch import dispatch
|
|
991
|
+
_loop.call_soon_threadsafe(dispatch, req)
|
|
992
|
+
return
|
|
989
993
|
if rtype == "host_reply":
|
|
990
994
|
# Bypass the FIFO queue: the awaiting cell IS the in-flight
|
|
991
995
|
# execute, so a queued reply would deadlock behind it.
|
|
@@ -1152,7 +1156,8 @@ def main() -> None:
|
|
|
1152
1156
|
signal.signal(signal.SIGINT, _sigint_handler)
|
|
1153
1157
|
threading.Thread(target=_read_requests, args=(stdin_fd, queue), daemon=True).start()
|
|
1154
1158
|
|
|
1155
|
-
_send({"event": "ready", "protocol": PROTOCOL_VERSION, "python": platform.python_version()
|
|
1159
|
+
_send({"event": "ready", "protocol": PROTOCOL_VERSION, "python": platform.python_version(),
|
|
1160
|
+
"capabilities": ["job_watch_probe_v1"]})
|
|
1156
1161
|
|
|
1157
1162
|
_serve_task = _loop.create_task(_serve(queue, user_module.__dict__))
|
|
1158
1163
|
# A KeyboardInterrupt escaping a cell or background task stops
|
|
@@ -1237,8 +1237,8 @@ class BashTest(unittest.IsolatedAsyncioTestCase):
|
|
|
1237
1237
|
with mock.patch.dict(
|
|
1238
1238
|
os.environ,
|
|
1239
1239
|
{
|
|
1240
|
-
"
|
|
1241
|
-
"
|
|
1240
|
+
"BASE_CONTEXT_INTERNAL_ORPHAN_PROCESS_JOURNAL": journal,
|
|
1241
|
+
"BASE_CONTEXT_KERNEL_OWNER_PID": "notanint",
|
|
1242
1242
|
},
|
|
1243
1243
|
):
|
|
1244
1244
|
with self.assertRaises(RuntimeError):
|
|
@@ -1250,8 +1250,8 @@ class BashTest(unittest.IsolatedAsyncioTestCase):
|
|
|
1250
1250
|
with mock.patch.dict(
|
|
1251
1251
|
os.environ,
|
|
1252
1252
|
{
|
|
1253
|
-
"
|
|
1254
|
-
"
|
|
1253
|
+
"BASE_CONTEXT_INTERNAL_ORPHAN_PROCESS_JOURNAL": journal,
|
|
1254
|
+
"BASE_CONTEXT_KERNEL_OWNER_PID": str(os.getpid()),
|
|
1255
1255
|
},
|
|
1256
1256
|
):
|
|
1257
1257
|
with mock.patch.object(bash_module, "_process_start_id", return_value=None):
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import unittest
|
|
3
|
+
from rlm.bash import bash
|
|
4
|
+
from rlm.job_watch import identity, _probe, _handles
|
|
5
|
+
from unittest.mock import AsyncMock, patch
|
|
6
|
+
import gc
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JobWatchCallbackTest(unittest.IsolatedAsyncioTestCase):
|
|
11
|
+
async def test_callback_and_identity_preserve_first_await_ownership(self):
|
|
12
|
+
handle = bash("python3 -c 'import time; time.sleep(10)'")
|
|
13
|
+
resource = identity(handle)
|
|
14
|
+
remove = handle.add_done_callback(lambda result: None)
|
|
15
|
+
with patch("rlm.host_request", new=AsyncMock()):
|
|
16
|
+
await _probe({"id": "probe", "resourceId": resource, "jobId": "job", "completionSource": "handle", "command": None})
|
|
17
|
+
self.assertFalse(handle._released)
|
|
18
|
+
async def wait():
|
|
19
|
+
return await handle
|
|
20
|
+
task = asyncio.create_task(wait())
|
|
21
|
+
await asyncio.sleep(0)
|
|
22
|
+
task.cancel()
|
|
23
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
24
|
+
await task
|
|
25
|
+
self.assertIsNotNone(handle.poll())
|
|
26
|
+
self.assertNotEqual(handle.poll().exit_code, 0)
|
|
27
|
+
remove()
|
|
28
|
+
|
|
29
|
+
async def test_background_await_stays_nonowning_and_unregister_never_kills(self):
|
|
30
|
+
handle = bash("python3 -c 'import time; time.sleep(10)'")
|
|
31
|
+
_ = handle.pid
|
|
32
|
+
remove = handle.add_done_callback(lambda result: None)
|
|
33
|
+
async def wait():
|
|
34
|
+
return await handle
|
|
35
|
+
task = asyncio.create_task(wait())
|
|
36
|
+
await asyncio.sleep(0)
|
|
37
|
+
task.cancel()
|
|
38
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
39
|
+
await task
|
|
40
|
+
remove()
|
|
41
|
+
self.assertIsNone(handle.poll())
|
|
42
|
+
handle.kill(grace=0)
|
|
43
|
+
await handle
|
|
44
|
+
|
|
45
|
+
async def test_completed_callback_is_immediate_and_idempotently_removable(self):
|
|
46
|
+
workers = []
|
|
47
|
+
real_thread = threading.Thread
|
|
48
|
+
def worker_thread(*args, **kwargs):
|
|
49
|
+
thread = real_thread(*args, **kwargs)
|
|
50
|
+
workers.append(thread)
|
|
51
|
+
return thread
|
|
52
|
+
with patch("rlm.bash.threading.Thread", side_effect=worker_thread):
|
|
53
|
+
handle = bash("printf done")
|
|
54
|
+
resource = identity(handle)
|
|
55
|
+
result = await handle
|
|
56
|
+
seen = []
|
|
57
|
+
remove = handle.add_done_callback(seen.append)
|
|
58
|
+
remove(); remove()
|
|
59
|
+
self.assertEqual(seen, [result])
|
|
60
|
+
# Foreground result delivery precedes containment-worker retirement.
|
|
61
|
+
# Their bound methods legitimately retain the handle until they exit.
|
|
62
|
+
for worker in workers:
|
|
63
|
+
await asyncio.to_thread(worker.join, 1)
|
|
64
|
+
self.assertFalse(worker.is_alive())
|
|
65
|
+
del remove, handle
|
|
66
|
+
gc.collect()
|
|
67
|
+
self.assertNotIn(resource, _handles)
|
package/dist/build-info.json
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
oauth_exports,
|
|
52
52
|
runPrintMode,
|
|
53
53
|
runRpcMode
|
|
54
|
-
} from "./chunk-
|
|
54
|
+
} from "./chunk-T2OK7SRV.js";
|
|
55
55
|
import {
|
|
56
56
|
AgentSession,
|
|
57
57
|
CURRENT_SESSION_VERSION,
|
|
@@ -143,7 +143,7 @@ import {
|
|
|
143
143
|
withFileMutationQueue,
|
|
144
144
|
wrapRegisteredTool,
|
|
145
145
|
wrapRegisteredTools
|
|
146
|
-
} from "./chunk-
|
|
146
|
+
} from "./chunk-MSH6QRP5.js";
|
|
147
147
|
import "./chunk-CFNASUYO.js";
|
|
148
148
|
import "./chunk-V6LOBH6X.js";
|
|
149
149
|
import {
|