@hicaru/pi-rlm 0.2.1 → 0.2.2
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/README.md +12 -35
- package/README.ru.md +18 -23
- package/README.zh-CN.md +17 -28
- package/package.json +1 -1
- package/src/bridge/library.ts +61 -26
- package/src/bridge/subcall-handlers.ts +63 -17
- package/src/commands/rlm-config.ts +47 -18
- package/src/commands/rlm.ts +3 -152
- package/src/config/defaults.ts +6 -17
- package/src/config/settings.ts +8 -32
- package/src/context/library-context.ts +90 -17
- package/src/core/engine.ts +55 -335
- package/src/core/history.ts +1 -1
- package/src/core/limits.ts +5 -12
- package/src/core/resource-limits.ts +0 -2
- package/src/core/types.ts +3 -36
- package/src/index.ts +23 -12
- package/src/mode/llm-model.ts +54 -0
- package/src/mode/rlm-mode.ts +26 -57
- package/src/prompts/glossary.ts +287 -0
- package/src/prompts/native.ts +127 -0
- package/src/prompts/system.ts +14 -407
- package/src/sandbox/context-file.ts +154 -0
- package/src/sandbox/interrupts.ts +145 -0
- package/src/sandbox/protocol.ts +8 -69
- package/src/sandbox/py/guards.py +150 -0
- package/src/sandbox/py/retrieval.py +265 -0
- package/src/sandbox/py/tasks.py +116 -0
- package/src/sandbox/{worker.py → py/worker.py} +76 -696
- package/src/sandbox/sandbox-manager.ts +13 -0
- package/src/sandbox/sandbox.ts +99 -193
- package/src/text/tokens.ts +29 -3
- package/src/tool/repl-details.ts +2 -2
- package/src/tool/repl-render.ts +58 -0
- package/src/tool/repl-result.ts +70 -0
- package/src/tool/repl-tool.ts +37 -159
- package/src/tool/rlm-aggregator.ts +2 -10
- package/src/tool/rlm-details.ts +0 -2
- package/src/tool/rlm-events.ts +0 -14
- package/src/tool/rlm-tool.ts +1 -12
- package/src/ui/config-panel.ts +4 -16
- package/src/ui/intro.ts +1 -2
- package/src/ui/model-picker.ts +34 -10
- package/src/ui/status.ts +3 -7
- package/src/util/concurrency.ts +9 -5
- package/src/bridge/fallback-todo.ts +0 -148
- package/src/bridge/interactive.ts +0 -65
- package/src/bridge/pi-interactive.ts +0 -41
- package/src/core/artifacts.ts +0 -89
- package/src/core/critique.ts +0 -92
- package/src/core/gates.ts +0 -301
- package/src/core/pipeline-handlers.ts +0 -319
- package/src/core/pipeline.ts +0 -268
- package/src/prompts/phases.ts +0 -104
- package/src/state/index.ts +0 -24
- package/src/state/internal.ts +0 -46
- package/src/state/paths.ts +0 -44
- package/src/state/reads.ts +0 -133
- package/src/state/resume.ts +0 -173
- package/src/state/rows.ts +0 -123
- package/src/state/writes.ts +0 -58
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Deterministic retrieval over `context` — free: no sub-LLM call, no root tokens.
|
|
2
|
+
|
|
3
|
+
`search` / `grep_context` / `outline` are what the prompt tells the model to reach for BEFORE
|
|
4
|
+
delegating anything, so they carry no worker state: each takes the already-materialised
|
|
5
|
+
`entries` list and returns pointers, never file bodies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import heapq
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_CHUNK_HEADER_OVERHEAD = 64
|
|
18
|
+
_MAX_CHUNK_BATCH = 20 # fan-out per llm_query_batched call (matches prompt guidance)
|
|
19
|
+
_MAX_CHUNKS = 500 # ceiling: above this, force pre-filtering in Python
|
|
20
|
+
_NUDGE_CHARS = 500_000 # str/bytes vars above this trigger a one-time stdout hint
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
24
|
+
"""Split text into <=chunk_chars pieces, preferring newline boundaries."""
|
|
25
|
+
chunks: list[str] = []
|
|
26
|
+
n = len(text)
|
|
27
|
+
start = 0
|
|
28
|
+
while start < n:
|
|
29
|
+
end = min(start + chunk_chars, n)
|
|
30
|
+
if end < n:
|
|
31
|
+
nl = text.rfind("\n", start, end)
|
|
32
|
+
if nl > start:
|
|
33
|
+
end = nl + 1
|
|
34
|
+
chunks.append(text[start:end])
|
|
35
|
+
start = end
|
|
36
|
+
return chunks
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---- deterministic retrieval over `context` -----------------------------------------------
|
|
40
|
+
#
|
|
41
|
+
# The RLM paper's trajectories retrieve by having the root model hand-write regex over the
|
|
42
|
+
# context (App. E.1). Frontier models do that well; small/fast models guess keywords badly and
|
|
43
|
+
# the first decomposition attempt disproportionately decides the outcome (paper §5, Fig. 4a).
|
|
44
|
+
# These primitives make retrieval deterministic and token-free: no sub-LLM call, no root tokens
|
|
45
|
+
# spent on printed file bodies — the model gets ranked pointers and decides what to delegate.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
49
|
+
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge load_library() cannot exhaust worker memory
|
|
50
|
+
_SNIPPET_CHARS = 400
|
|
51
|
+
_GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
|
|
52
|
+
_BM25_K1 = 1.2
|
|
53
|
+
_BM25_B = 0.75
|
|
54
|
+
|
|
55
|
+
_TOKEN_SPLIT = re.compile(r"[^0-9A-Za-z]+") # also splits snake_case and paths
|
|
56
|
+
_CAMEL_SPLIT = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
57
|
+
|
|
58
|
+
# Definition-ish lines across the languages this plugin is likely to meet. Deliberately
|
|
59
|
+
# lexical: an outline is an orientation aid, not a parse tree.
|
|
60
|
+
_OUTLINE_LINE = re.compile(
|
|
61
|
+
r"^\s*(?:"
|
|
62
|
+
r"(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|struct|impl|trait|namespace)\s+\w+"
|
|
63
|
+
r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*[:=]\s*(?:async\s*)?(?:function|\(|<)"
|
|
64
|
+
r"|(?:pub\s+)?(?:async\s+)?fn\s+\w+"
|
|
65
|
+
r"|def\s+\w+|class\s+\w+"
|
|
66
|
+
r"|func\s+\w+"
|
|
67
|
+
r"|#{1,4}\s+\S"
|
|
68
|
+
r")"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tokenize(text: str) -> list[str]:
|
|
73
|
+
"""Lowercased alphanumeric runs, plus camelCase parts so `resolveModelId` matches `model id`."""
|
|
74
|
+
out: list[str] = []
|
|
75
|
+
for raw in _TOKEN_SPLIT.split(text):
|
|
76
|
+
if not raw:
|
|
77
|
+
continue
|
|
78
|
+
lowered = raw.lower()
|
|
79
|
+
out.append(lowered)
|
|
80
|
+
if len(raw) > 3:
|
|
81
|
+
parts = _CAMEL_SPLIT.split(raw)
|
|
82
|
+
if len(parts) > 1:
|
|
83
|
+
for part in parts:
|
|
84
|
+
piece = part.lower()
|
|
85
|
+
if piece and piece != lowered:
|
|
86
|
+
out.append(piece)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _context_entries(context: Any) -> list[tuple[str, str]]:
|
|
91
|
+
"""(path, content) pairs for either context shape: list[dict] bundles or a raw string."""
|
|
92
|
+
if isinstance(context, str):
|
|
93
|
+
return [("<context>", context)]
|
|
94
|
+
if not isinstance(context, list):
|
|
95
|
+
return []
|
|
96
|
+
out: list[tuple[str, str]] = []
|
|
97
|
+
for i, item in enumerate(context):
|
|
98
|
+
if isinstance(item, dict):
|
|
99
|
+
content = item.get("content", "")
|
|
100
|
+
out.append((
|
|
101
|
+
str(item.get("path", f"<context[{i}]>")),
|
|
102
|
+
content if isinstance(content, str) else str(content),
|
|
103
|
+
))
|
|
104
|
+
elif isinstance(item, str):
|
|
105
|
+
out.append((f"<context[{i}]>", item))
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Bm25Index:
|
|
110
|
+
"""Okapi BM25 over fixed-line windows of `context`. Built lazily, discarded on change."""
|
|
111
|
+
|
|
112
|
+
__slots__ = ("paths", "starts", "texts", "postings", "doc_len", "avg_len", "truncated")
|
|
113
|
+
|
|
114
|
+
def __init__(self, entries: list[tuple[str, str]]) -> None:
|
|
115
|
+
self.paths: list[str] = []
|
|
116
|
+
self.starts: list[int] = []
|
|
117
|
+
self.texts: list[str] = []
|
|
118
|
+
self.postings: dict[str, list[tuple[int, int]]] = {}
|
|
119
|
+
self.doc_len: list[int] = []
|
|
120
|
+
self.truncated = False
|
|
121
|
+
|
|
122
|
+
for path, content in entries:
|
|
123
|
+
if not content:
|
|
124
|
+
continue
|
|
125
|
+
lines = content.split("\n")
|
|
126
|
+
for start in range(0, len(lines), _INDEX_WINDOW_LINES):
|
|
127
|
+
if len(self.texts) >= _INDEX_MAX_WINDOWS:
|
|
128
|
+
self.truncated = True
|
|
129
|
+
break
|
|
130
|
+
window = "\n".join(lines[start:start + _INDEX_WINDOW_LINES])
|
|
131
|
+
idx = len(self.texts)
|
|
132
|
+
self.paths.append(path)
|
|
133
|
+
self.starts.append(start + 1)
|
|
134
|
+
self.texts.append(window)
|
|
135
|
+
terms = _tokenize(window)
|
|
136
|
+
self.doc_len.append(len(terms))
|
|
137
|
+
freq: dict[str, int] = {}
|
|
138
|
+
for term in terms:
|
|
139
|
+
freq[term] = freq.get(term, 0) + 1
|
|
140
|
+
for term, tf in freq.items():
|
|
141
|
+
self.postings.setdefault(term, []).append((idx, tf))
|
|
142
|
+
if self.truncated:
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
total = len(self.doc_len)
|
|
146
|
+
self.avg_len = (sum(self.doc_len) / total) if total else 1.0
|
|
147
|
+
|
|
148
|
+
def query(self, terms: list[str], k: int, path_glob: str | None) -> list[dict[str, Any]]:
|
|
149
|
+
total = len(self.texts)
|
|
150
|
+
if total == 0:
|
|
151
|
+
return []
|
|
152
|
+
scores: dict[int, float] = {}
|
|
153
|
+
for term in set(terms):
|
|
154
|
+
posting = self.postings.get(term)
|
|
155
|
+
if not posting:
|
|
156
|
+
continue
|
|
157
|
+
df = len(posting)
|
|
158
|
+
idf = math.log(1.0 + (total - df + 0.5) / (df + 0.5))
|
|
159
|
+
for idx, tf in posting:
|
|
160
|
+
norm = _BM25_K1 * (1.0 - _BM25_B + _BM25_B * self.doc_len[idx] / self.avg_len)
|
|
161
|
+
scores[idx] = scores.get(idx, 0.0) + idf * (tf * (_BM25_K1 + 1.0)) / (tf + norm)
|
|
162
|
+
if path_glob:
|
|
163
|
+
scores = {i: s for i, s in scores.items() if fnmatch.fnmatch(self.paths[i], path_glob)}
|
|
164
|
+
if not scores:
|
|
165
|
+
return []
|
|
166
|
+
top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
|
|
167
|
+
out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
|
|
168
|
+
for i, (idx, score) in enumerate(top):
|
|
169
|
+
text = self.texts[idx]
|
|
170
|
+
out[i] = {
|
|
171
|
+
"path": self.paths[idx],
|
|
172
|
+
"line": self.starts[idx],
|
|
173
|
+
"score": round(score, 3),
|
|
174
|
+
"snippet": text[:_SNIPPET_CHARS],
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def search(entries: list[tuple[str, str]], index: _Bm25Index, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
|
|
180
|
+
"""Rank `context` windows against a natural-language query (BM25).
|
|
181
|
+
|
|
182
|
+
Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
|
|
183
|
+
named files out of `context` and delegating them to llm_query / map_files.
|
|
184
|
+
"""
|
|
185
|
+
terms = _tokenize(str(query))
|
|
186
|
+
if not terms:
|
|
187
|
+
return []
|
|
188
|
+
try:
|
|
189
|
+
limit = max(1, min(int(k), 100))
|
|
190
|
+
except (TypeError, ValueError):
|
|
191
|
+
limit = 10
|
|
192
|
+
return index.query(terms, limit, path_glob)
|
|
193
|
+
|
|
194
|
+
def grep_context(
|
|
195
|
+
entries: list[tuple[str, str]],
|
|
196
|
+
pattern: str,
|
|
197
|
+
k: int = 50,
|
|
198
|
+
path_glob: str | None = None,
|
|
199
|
+
before: int = 0,
|
|
200
|
+
after: int = 0,
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
"""Regex over `context`, capped and shaped.
|
|
203
|
+
|
|
204
|
+
Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
|
|
205
|
+
`counts` is complete even when `hits` is capped, so a wide pattern reports its shape
|
|
206
|
+
instead of flooding stdout.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
rx = re.compile(pattern)
|
|
210
|
+
except re.error as e:
|
|
211
|
+
return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
|
|
212
|
+
try:
|
|
213
|
+
limit = max(1, min(int(k), _GREP_HARD_CAP))
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
limit = 50
|
|
216
|
+
pad_before = max(0, min(int(before or 0), 10))
|
|
217
|
+
pad_after = max(0, min(int(after or 0), 10))
|
|
218
|
+
|
|
219
|
+
hits: list[dict[str, Any]] = []
|
|
220
|
+
counts: dict[str, int] = {}
|
|
221
|
+
total = 0
|
|
222
|
+
for path, content in entries:
|
|
223
|
+
if path_glob and not fnmatch.fnmatch(path, path_glob):
|
|
224
|
+
continue
|
|
225
|
+
if not rx.search(content):
|
|
226
|
+
continue
|
|
227
|
+
lines = content.split("\n")
|
|
228
|
+
for i, line in enumerate(lines):
|
|
229
|
+
if not rx.search(line):
|
|
230
|
+
continue
|
|
231
|
+
total += 1
|
|
232
|
+
counts[path] = counts.get(path, 0) + 1
|
|
233
|
+
if len(hits) >= limit:
|
|
234
|
+
continue
|
|
235
|
+
lo = max(0, i - pad_before)
|
|
236
|
+
hi = min(len(lines), i + pad_after + 1)
|
|
237
|
+
hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
|
|
238
|
+
return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
|
|
239
|
+
|
|
240
|
+
def outline(entries: list[tuple[str, str]], path: str) -> str:
|
|
241
|
+
"""Definition/heading skeleton of one context file — orient in ~200 chars, not 20K.
|
|
242
|
+
|
|
243
|
+
`path` matches exactly, then by suffix, then as a glob.
|
|
244
|
+
"""
|
|
245
|
+
target = str(path)
|
|
246
|
+
content: str | None = None
|
|
247
|
+
for p, c in entries:
|
|
248
|
+
if p == target:
|
|
249
|
+
content = c
|
|
250
|
+
break
|
|
251
|
+
if content is None:
|
|
252
|
+
for p, c in entries:
|
|
253
|
+
if p.endswith(target) or fnmatch.fnmatch(p, target):
|
|
254
|
+
content = c
|
|
255
|
+
target = p
|
|
256
|
+
break
|
|
257
|
+
if content is None:
|
|
258
|
+
return f"Error: no context file matching {path!r} — use search() or list paths from context"
|
|
259
|
+
out: list[str] = [f"# {target}"]
|
|
260
|
+
for i, line in enumerate(content.split("\n")):
|
|
261
|
+
if _OUTLINE_LINE.match(line):
|
|
262
|
+
out.append(f"{i + 1}: {line.strip()[:160]}")
|
|
263
|
+
if len(out) == 1:
|
|
264
|
+
return f"# {target}\n(no definition-like lines found)"
|
|
265
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Async sub-call plumbing: the `Task` handle, the spawn allow-list, and the reply reducers.
|
|
2
|
+
|
|
3
|
+
A request and its reply are decoupled — `_post` returns a rid without waiting and replies are
|
|
4
|
+
parked by rid — so a Task started in one exec can be awaited in a later one. Each kind of
|
|
5
|
+
sub-call knows how to fold its parked replies back into a result; that is what a reducer is.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _clean_paths(paths: Any) -> list[str] | None:
|
|
14
|
+
"""Normalize a `paths=` argument to a non-empty list of prefixes, or None.
|
|
15
|
+
|
|
16
|
+
Shared by the single and batched rlm_query builders so the accepted shapes stay identical.
|
|
17
|
+
A bare string is treated as one prefix — the most common typo, and harmless to allow.
|
|
18
|
+
"""
|
|
19
|
+
if paths is None:
|
|
20
|
+
return None
|
|
21
|
+
seq = [paths] if isinstance(paths, str) else list(paths)
|
|
22
|
+
out = [str(p).strip() for p in seq if str(p).strip()]
|
|
23
|
+
return out or None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reduce_one(replies: list[dict[str, Any]]) -> str:
|
|
27
|
+
r = replies[0]
|
|
28
|
+
return f"Error: {r['error']}" if r.get("error") else r.get("response", "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _reduce_batch(n: int):
|
|
32
|
+
"""Reducer for a single *_query_batched reply of n prompts."""
|
|
33
|
+
def reduce(replies: list[dict[str, Any]]) -> list[str]:
|
|
34
|
+
r = replies[0]
|
|
35
|
+
if r.get("error"):
|
|
36
|
+
return [f"Error: {r['error']}"] * n
|
|
37
|
+
out = r.get("responses")
|
|
38
|
+
if not isinstance(out, list) or len(out) != n:
|
|
39
|
+
return ["Error: malformed batched response"] * n
|
|
40
|
+
return [s if isinstance(s, str) else f"Error: {s}" for s in out]
|
|
41
|
+
return reduce
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _reduce_chunked(sizes: list[int]):
|
|
45
|
+
"""Concatenate several llm_query_batched replies back into one flat chunk list."""
|
|
46
|
+
per = [_reduce_batch(n) for n in sizes]
|
|
47
|
+
|
|
48
|
+
def reduce(replies: list[dict[str, Any]]) -> list[str]:
|
|
49
|
+
out: list[str] = []
|
|
50
|
+
for red, rep in zip(per, replies):
|
|
51
|
+
out.extend(red([rep]))
|
|
52
|
+
return out
|
|
53
|
+
return reduce
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _reduce_map_files(sizes: list[int], spans: list[tuple[str, int]]):
|
|
57
|
+
"""Flatten the batch replies (same as chunked), then regroup them per path.
|
|
58
|
+
|
|
59
|
+
A file larger than the per-prompt budget contributed several requests; its answers rejoin
|
|
60
|
+
in order, which is what makes map_files a {path: answer} dict rather than a flat list.
|
|
61
|
+
"""
|
|
62
|
+
flatten = _reduce_chunked(sizes)
|
|
63
|
+
|
|
64
|
+
def reduce(replies: list[dict[str, Any]]) -> dict[str, str]:
|
|
65
|
+
responses = flatten(replies)
|
|
66
|
+
out: dict[str, str] = {}
|
|
67
|
+
cursor = 0
|
|
68
|
+
for path, count in spans:
|
|
69
|
+
part = responses[cursor:cursor + count]
|
|
70
|
+
cursor += count
|
|
71
|
+
out[path] = part[0] if count == 1 and part else "\n\n".join(part)
|
|
72
|
+
return out
|
|
73
|
+
return reduce
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _spawnable(name: str):
|
|
77
|
+
"""Tag a sync scaffold fn with the request kind spawn() should route it to."""
|
|
78
|
+
def mark(fn):
|
|
79
|
+
fn._rlm_name = name
|
|
80
|
+
return fn
|
|
81
|
+
return mark
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Task:
|
|
85
|
+
"""Handle for parent-side work in flight, returned by spawn().
|
|
86
|
+
|
|
87
|
+
Opaque to model code apart from `done` and repr. A Task may be awaited in a later
|
|
88
|
+
```repl``` block than the one that created it.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
__slots__ = ("kind", "label", "_worker", "_rids", "_reduce", "_value", "_settled")
|
|
92
|
+
|
|
93
|
+
def __init__(self, worker: "Worker", kind: str, rids, reduce, label: str = ""):
|
|
94
|
+
self.kind = kind
|
|
95
|
+
self.label = label
|
|
96
|
+
self._worker = worker
|
|
97
|
+
self._rids = tuple(rids)
|
|
98
|
+
self._reduce = reduce
|
|
99
|
+
self._value: Any = None
|
|
100
|
+
self._settled = False
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def resolved(worker: "Worker", kind: str, value: Any, label: str = "") -> "Task":
|
|
104
|
+
"""A Task that never hit the wire — validation errors and empty inputs."""
|
|
105
|
+
task = Task(worker, kind, (), lambda _replies: value, label)
|
|
106
|
+
task._value = value
|
|
107
|
+
task._settled = True
|
|
108
|
+
return task
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def done(self) -> bool:
|
|
112
|
+
"""True once every reply has landed — awaiting will not block."""
|
|
113
|
+
return self._settled or all(r in self._worker.inbox for r in self._rids)
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
return f"<Task {self.kind} {'done' if self.done else 'running'} {self.label}>"
|