switchroom 0.19.2 → 0.19.4
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/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +109 -7
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -33,11 +33,17 @@ def strip_channel_envelope(content: str) -> str:
|
|
|
33
33
|
This is the Claude Code equivalent of Openclaw's stripMetadataEnvelopes().
|
|
34
34
|
Extracts the inner text, preserving the actual user message while removing
|
|
35
35
|
transport metadata that Hindsight doesn't need.
|
|
36
|
+
|
|
37
|
+
A single prompt may carry MORE THAN ONE envelope (e.g. a coalesced
|
|
38
|
+
burst where several inbound messages were concatenated). Since this now
|
|
39
|
+
sits on the live recall-query path (hindsight-leverage PR 1, review
|
|
40
|
+
finding 6), coalesce EVERY envelope's inner text rather than keeping only
|
|
41
|
+
the first and silently dropping everything after the first ``</channel>``.
|
|
36
42
|
"""
|
|
37
|
-
# Match <channel ...>content</channel> — extract inner
|
|
38
|
-
|
|
39
|
-
if
|
|
40
|
-
return
|
|
43
|
+
# Match every <channel ...>content</channel> — extract & join inner texts.
|
|
44
|
+
matches = re.findall(r"<channel\b[^>]*>([\s\S]*?)</channel>", content)
|
|
45
|
+
if matches:
|
|
46
|
+
return "\n".join(m.strip() for m in matches if m.strip()).strip()
|
|
41
47
|
return content
|
|
42
48
|
|
|
43
49
|
|
|
@@ -79,7 +85,14 @@ def compose_recall_query(
|
|
|
79
85
|
|
|
80
86
|
<latest query>
|
|
81
87
|
"""
|
|
82
|
-
|
|
88
|
+
# Switchroom A1 (hindsight-leverage PR 1) — strip the <channel> envelope
|
|
89
|
+
# from the latest query INSIDE the helper as well, so any caller (present
|
|
90
|
+
# or future) gets an envelope-free composed query. The recall.py caller
|
|
91
|
+
# already strips before calling, but keeping the strip here is defence in
|
|
92
|
+
# depth: it guarantees the trailing latest-query segment appended below
|
|
93
|
+
# (and returned on the turns<=1 short-circuit) never carries the raw
|
|
94
|
+
# chat_id/ts/user XML noise into the embedding or the char cap.
|
|
95
|
+
latest = strip_channel_envelope(latest_query).strip()
|
|
83
96
|
if recall_context_turns <= 1 or not isinstance(messages, list) or not messages:
|
|
84
97
|
return latest
|
|
85
98
|
|
|
@@ -236,6 +249,43 @@ def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
|
|
|
236
249
|
return messages[start_index:]
|
|
237
250
|
|
|
238
251
|
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Sidechain (sub-agent transcript) detection
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def transcript_first_line_is_sidechain(path: str) -> bool:
|
|
258
|
+
"""True when the first JSON line of ``path`` carries ``isSidechain: true``.
|
|
259
|
+
|
|
260
|
+
Switchroom hindsight-leverage PR5. Claude Code writes sub-agent (Task-tool)
|
|
261
|
+
transcripts as separate ``.jsonl`` files under
|
|
262
|
+
``<project>/<session>/subagents/agent-<agent_id>.jsonl`` whose every line
|
|
263
|
+
carries ``isSidechain: true``. This shared predicate lets BOTH the
|
|
264
|
+
SubagentStop retain (which resolves + retains these deliberately, tagged
|
|
265
|
+
``sidechain`` + volume-gated) AND the boot reconciler / any transcript
|
|
266
|
+
sweeper (which must NOT treat a sidechain as a pseudo-session and re-retain
|
|
267
|
+
it untagged, at full recall weight, bypassing the volume gate) recognise a
|
|
268
|
+
sidechain file from its first line alone — a cheap single-line read. Any
|
|
269
|
+
read/parse error is treated as "not a sidechain" (fail-open: a
|
|
270
|
+
genuinely-unreadable file is skipped elsewhere by its empty transcript).
|
|
271
|
+
"""
|
|
272
|
+
import json
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
with open(path, encoding="utf-8") as f:
|
|
276
|
+
for line in f:
|
|
277
|
+
line = line.strip()
|
|
278
|
+
if not line:
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
return json.loads(line).get("isSidechain") is True
|
|
282
|
+
except json.JSONDecodeError:
|
|
283
|
+
return False
|
|
284
|
+
except OSError:
|
|
285
|
+
return False
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
239
289
|
# ---------------------------------------------------------------------------
|
|
240
290
|
# Memory formatting (recall results → context string)
|
|
241
291
|
# ---------------------------------------------------------------------------
|
|
@@ -18,8 +18,11 @@ recall path; a directive-fetch failure must not kill the recall block.
|
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
20
|
import sys
|
|
21
|
+
import time
|
|
21
22
|
from typing import Optional
|
|
22
23
|
|
|
24
|
+
from .state import list_state_names, read_state, remove_state, write_state
|
|
25
|
+
|
|
23
26
|
# Sanity cap on how many directives we ever inject into the prompt. Banks
|
|
24
27
|
# with more active directives than this are pathological; truncate with a
|
|
25
28
|
# footer so the agent knows there are more.
|
|
@@ -29,25 +32,56 @@ MAX_DIRECTIVES = 15
|
|
|
29
32
|
# UserPromptSubmit critical path — we cannot block it for long.
|
|
30
33
|
DIRECTIVES_TIMEOUT_SECONDS = 2
|
|
31
34
|
|
|
35
|
+
# --- Directives-list cache (switchroom hindsight-leverage A4) -----------------
|
|
36
|
+
#
|
|
37
|
+
# `list_directives` runs on the recall (UserPromptSubmit) critical path every
|
|
38
|
+
# non-skipped turn — a fresh 2s-timeout HTTP round-trip whose result changes
|
|
39
|
+
# only when a directive is created/updated/deleted (rare). We cache the fetched
|
|
40
|
+
# list in the plugin state dir with a short TTL so the common no-write turn
|
|
41
|
+
# skips the round-trip, while bounding staleness:
|
|
42
|
+
# * In-session writes: directive_verify.py (Stop hook) deletes the cache when
|
|
43
|
+
# the just-ended turn contains a create/update/delete_directive tool_use, so
|
|
44
|
+
# the very next recall re-fetches — the new state is visible in turn N+1.
|
|
45
|
+
# * Cross-process writes (another session, operator CLI) have no invalidation
|
|
46
|
+
# channel and rely on TTL alone → at most TTL seconds stale.
|
|
47
|
+
#
|
|
48
|
+
# Invalidation blind spots (both fall back to TTL, ≤ TTL stale — acceptable):
|
|
49
|
+
# * Sub-agent / sidechain directive writes: the create_directive tool_use is
|
|
50
|
+
# in the SIDECHAIN transcript, not the parent's, so the parent's Stop hook
|
|
51
|
+
# (which reads the parent transcript) never sees it. PR 5 (SubagentStop)
|
|
52
|
+
# is where sidechain awareness lands.
|
|
53
|
+
# * Bash / operator-CLI directive writes (`switchroom` or a curl) produce no
|
|
54
|
+
# tool_use in any transcript, so there is nothing for the Stop hook to
|
|
55
|
+
# detect.
|
|
56
|
+
#
|
|
57
|
+
# Rollback: set the TTL to 0 (HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS=0) to
|
|
58
|
+
# disable the cache entirely — every turn fetches live, as before A4.
|
|
59
|
+
DIRECTIVES_CACHE_TTL_SECONDS = 120
|
|
32
60
|
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
# All directive cache files share this basename prefix so they can be
|
|
62
|
+
# enumerated for bulk (bank-agnostic) invalidation.
|
|
63
|
+
_CACHE_PREFIX = "directives_cache."
|
|
35
64
|
|
|
36
|
-
Args:
|
|
37
|
-
client: A HindsightClient instance with a list_directives method.
|
|
38
|
-
bank_id: The bank to fetch directives from.
|
|
39
|
-
timeout: HTTP timeout in seconds.
|
|
40
65
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
66
|
+
def _cache_name(bank_id: str) -> str:
|
|
67
|
+
"""State-file name for a bank's cached directive list."""
|
|
68
|
+
return f"{_CACHE_PREFIX}{bank_id}.json"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _fetch_directives_with_status(client, bank_id: str, timeout: int) -> tuple:
|
|
72
|
+
"""Fetch + normalize active directives, reporting fetch success.
|
|
73
|
+
|
|
74
|
+
Returns ``(ok, directives)``. ``ok`` is False only on a genuine fetch
|
|
75
|
+
FAILURE (HTTP error, non-dict response) — a bank that simply has no
|
|
76
|
+
directives returns ``(True, [])``. Callers use ``ok`` to avoid caching a
|
|
77
|
+
transient failure's empty result (which would mask real directives for a
|
|
78
|
+
whole TTL window). Never raises; logs a single warn line on failure.
|
|
45
79
|
"""
|
|
46
80
|
try:
|
|
47
81
|
response = client.list_directives(bank_id=bank_id, active_only=True, timeout=timeout)
|
|
48
82
|
except Exception as e:
|
|
49
83
|
print(f"[Hindsight] list_directives failed for bank '{bank_id}': {e}", file=sys.stderr)
|
|
50
|
-
return []
|
|
84
|
+
return False, []
|
|
51
85
|
|
|
52
86
|
if not isinstance(response, dict):
|
|
53
87
|
print(
|
|
@@ -55,20 +89,123 @@ def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIME
|
|
|
55
89
|
f"{type(response).__name__}",
|
|
56
90
|
file=sys.stderr,
|
|
57
91
|
)
|
|
58
|
-
return []
|
|
92
|
+
return False, []
|
|
59
93
|
|
|
60
94
|
items = response.get("items")
|
|
61
95
|
if not isinstance(items, list):
|
|
62
96
|
# Empty / malformed response — quiet success, no warn (banks with
|
|
63
|
-
# no directives are normal).
|
|
64
|
-
return []
|
|
97
|
+
# no directives are normal). Cacheable.
|
|
98
|
+
return True, []
|
|
65
99
|
|
|
66
100
|
# Filter to dicts only, then sort by priority descending. Treat missing
|
|
67
101
|
# priority as 0 so malformed entries sink to the bottom rather than
|
|
68
102
|
# crashing.
|
|
69
103
|
valid = [d for d in items if isinstance(d, dict)]
|
|
70
104
|
valid.sort(key=lambda d: d.get("priority", 0), reverse=True)
|
|
71
|
-
return valid
|
|
105
|
+
return True, valid
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIMEOUT_SECONDS) -> list:
|
|
109
|
+
"""Fetch active directives for a bank, sorted by priority (highest first).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
client: A HindsightClient instance with a list_directives method.
|
|
113
|
+
bank_id: The bank to fetch directives from.
|
|
114
|
+
timeout: HTTP timeout in seconds.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
A list of directive dicts (each with id, name, content, priority,
|
|
118
|
+
tags, ...), sorted by priority descending. On any failure returns
|
|
119
|
+
an empty list and logs a single warn line to stderr — never raises.
|
|
120
|
+
"""
|
|
121
|
+
_ok, directives = _fetch_directives_with_status(client, bank_id, timeout)
|
|
122
|
+
return directives
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_cache(bank_id: str) -> Optional[dict]:
|
|
126
|
+
"""Read + validate a bank's cache envelope. Returns None on miss/corruption.
|
|
127
|
+
|
|
128
|
+
A corrupted or wrong-shaped cache (bad JSON handled by read_state; here we
|
|
129
|
+
additionally reject a non-dict envelope, a non-numeric timestamp, or a
|
|
130
|
+
non-list directive payload) is treated as a MISS so the caller falls back to
|
|
131
|
+
a live fetch rather than injecting garbage.
|
|
132
|
+
|
|
133
|
+
Also rejects an envelope whose stored ``bank_id`` does not match the
|
|
134
|
+
requested one: ``_safe_filename`` can collapse two distinct bank ids onto a
|
|
135
|
+
single cache file, and serving bank A's directives for bank B would leak
|
|
136
|
+
rules across banks. The embedded ``bank_id`` is the authoritative key.
|
|
137
|
+
"""
|
|
138
|
+
raw = read_state(_cache_name(bank_id), None)
|
|
139
|
+
if not isinstance(raw, dict):
|
|
140
|
+
return None
|
|
141
|
+
if raw.get("bank_id") != bank_id:
|
|
142
|
+
return None
|
|
143
|
+
ts = raw.get("ts")
|
|
144
|
+
directives = raw.get("directives")
|
|
145
|
+
if not isinstance(ts, (int, float)) or isinstance(ts, bool):
|
|
146
|
+
return None
|
|
147
|
+
if not isinstance(directives, list):
|
|
148
|
+
return None
|
|
149
|
+
return raw
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def fetch_active_directives_cached(
|
|
153
|
+
client,
|
|
154
|
+
bank_id: str,
|
|
155
|
+
ttl_seconds: int = DIRECTIVES_CACHE_TTL_SECONDS,
|
|
156
|
+
timeout: int = DIRECTIVES_TIMEOUT_SECONDS,
|
|
157
|
+
now: Optional[float] = None,
|
|
158
|
+
) -> list:
|
|
159
|
+
"""Cached wrapper around :func:`fetch_active_directives`.
|
|
160
|
+
|
|
161
|
+
On a fresh cache hit (age < ``ttl_seconds``) returns the cached list WITHOUT
|
|
162
|
+
an HTTP call. On a miss / expiry / corrupted cache, fetches live and, when
|
|
163
|
+
the fetch SUCCEEDED, writes the cache. A failed fetch is never cached, so a
|
|
164
|
+
transient error can't mask real directives for a TTL window.
|
|
165
|
+
|
|
166
|
+
``ttl_seconds <= 0`` disables the cache (always live-fetch, never write) —
|
|
167
|
+
the A4 rollback lever.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
now: Injectable current epoch seconds, for deterministic tests.
|
|
171
|
+
"""
|
|
172
|
+
if now is None:
|
|
173
|
+
now = time.time()
|
|
174
|
+
|
|
175
|
+
caching = isinstance(ttl_seconds, (int, float)) and ttl_seconds > 0
|
|
176
|
+
|
|
177
|
+
if caching:
|
|
178
|
+
cached = _read_cache(bank_id)
|
|
179
|
+
if cached is not None:
|
|
180
|
+
age = now - cached["ts"]
|
|
181
|
+
# A negative age means the stored timestamp is in the FUTURE
|
|
182
|
+
# (wall-clock step-back / a doctored envelope) — treat it as
|
|
183
|
+
# expired rather than "fresh forever", so a clock correction can't
|
|
184
|
+
# pin a stale cache.
|
|
185
|
+
if 0 <= age < ttl_seconds:
|
|
186
|
+
return cached["directives"]
|
|
187
|
+
|
|
188
|
+
ok, directives = _fetch_directives_with_status(client, bank_id, timeout)
|
|
189
|
+
|
|
190
|
+
if caching and ok:
|
|
191
|
+
write_state(_cache_name(bank_id), {"ts": now, "bank_id": bank_id, "directives": directives})
|
|
192
|
+
|
|
193
|
+
return directives
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def invalidate_directives_cache(bank_id: Optional[str] = None) -> None:
|
|
197
|
+
"""Delete the directives cache so the next recall re-fetches live.
|
|
198
|
+
|
|
199
|
+
With ``bank_id`` set, removes just that bank's cache file. With no argument
|
|
200
|
+
(the Stop-hook invalidation path, which does not resolve the bank), removes
|
|
201
|
+
EVERY directive cache file — directive writes are rare, so a bank-agnostic
|
|
202
|
+
sweep is cheap and robust. Best-effort; never raises.
|
|
203
|
+
"""
|
|
204
|
+
if bank_id is not None:
|
|
205
|
+
remove_state(_cache_name(bank_id))
|
|
206
|
+
return
|
|
207
|
+
for name in list_state_names(_CACHE_PREFIX):
|
|
208
|
+
remove_state(name)
|
|
72
209
|
|
|
73
210
|
|
|
74
211
|
def format_active_directives_block(directives: list, max_directives: int = MAX_DIRECTIVES) -> Optional[str]:
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Deadline-bounded parallel fan-out for the recall critical path.
|
|
2
|
+
|
|
3
|
+
Switchroom hindsight-leverage A3 (parallel multi-bank recall). The recall
|
|
4
|
+
hook (``recall.py``) queries the agent's own bank, every additional bank
|
|
5
|
+
(profile / shared / sender), and the active-directives list. Serially, the
|
|
6
|
+
critical-path latency is the SUM of those round-trips — a heavy agent with two
|
|
7
|
+
extra banks plus directives can serialise four 2-8s calls and breach the 12s
|
|
8
|
+
UserPromptSubmit hook ceiling, which drops recall entirely for that turn.
|
|
9
|
+
|
|
10
|
+
This runs each labelled task in its OWN daemon thread and waits only until a
|
|
11
|
+
single SHARED deadline (the hook ceiling minus a headroom margin). Key
|
|
12
|
+
properties, all enforced by mechanism rather than convention:
|
|
13
|
+
|
|
14
|
+
* **Daemon threads.** Every worker is a daemon, so a thread still blocked on
|
|
15
|
+
a socket read when the deadline elapses can NEVER keep the interpreter (and
|
|
16
|
+
therefore the hook) alive past the ceiling — the process exits and the
|
|
17
|
+
kernel reaps the socket. ``recall.py``'s ``__main__`` additionally calls
|
|
18
|
+
``os._exit(0)`` after flushing stdout as a belt-and-suspenders against any
|
|
19
|
+
non-daemon thread a client library might spawn.
|
|
20
|
+
|
|
21
|
+
* **One shared deadline.** Total wait is bounded by ``deadline_seconds`` from
|
|
22
|
+
the moment ``run_parallel`` is entered — not per-task — so N slow banks
|
|
23
|
+
cost the deadline ONCE, not N times.
|
|
24
|
+
|
|
25
|
+
* **Completion is observed, not assumed.** Each slot records whether its
|
|
26
|
+
thread finished before we stopped waiting (``completed``), its return value
|
|
27
|
+
or exception, and its wall-clock ``elapsed_ms``. A slot still running at the
|
|
28
|
+
deadline is ``completed=False`` with ``elapsed_ms`` pinned to the deadline —
|
|
29
|
+
the caller maps that to ``timed_out`` for telemetry.
|
|
30
|
+
|
|
31
|
+
Stdlib-only (threading + time); no third-party deps, importable under the
|
|
32
|
+
plugin's ``python3 -m unittest`` harness.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SlotResult:
|
|
40
|
+
"""Outcome of one labelled task run under the shared deadline.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
label: the task's key in the ``tasks`` mapping.
|
|
44
|
+
value: the callable's return value, or None if it raised / did not
|
|
45
|
+
finish before the deadline.
|
|
46
|
+
error: the exception the callable raised, or None. A slot that hit
|
|
47
|
+
the deadline mid-flight has ``error=None`` and
|
|
48
|
+
``completed=False`` (it never got to raise or return).
|
|
49
|
+
completed: True iff the worker thread finished (returned or raised)
|
|
50
|
+
before ``run_parallel`` stopped waiting on it. False means
|
|
51
|
+
the shared deadline elapsed first.
|
|
52
|
+
elapsed_ms: wall-clock ms the slot took. For a completed slot this is
|
|
53
|
+
its real duration; for a deadline-abandoned slot it is the
|
|
54
|
+
time from fan-out start to the deadline.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("label", "value", "error", "completed", "elapsed_ms")
|
|
58
|
+
|
|
59
|
+
def __init__(self, label):
|
|
60
|
+
self.label = label
|
|
61
|
+
self.value = None
|
|
62
|
+
self.error = None
|
|
63
|
+
self.completed = False
|
|
64
|
+
self.elapsed_ms = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _runner(slot, fn, start):
|
|
68
|
+
"""Worker body: run the task, capturing value/exception and duration.
|
|
69
|
+
|
|
70
|
+
Catches ``BaseException`` deliberately for slot isolation: a slot runs in
|
|
71
|
+
its own daemon thread, and a task failure of ANY kind — including
|
|
72
|
+
non-``Exception`` subclasses like ``KeyboardInterrupt`` /
|
|
73
|
+
``SystemExit`` — must be captured into ``slot.error`` rather than escaping
|
|
74
|
+
the thread. An escaped exception would let the slot die silently and the
|
|
75
|
+
deadline join would then see no value AND no recorded error. (``socket.timeout``
|
|
76
|
+
is itself just an ``OSError`` subclass, i.e. an ordinary ``Exception``; the
|
|
77
|
+
broad catch is about never letting any slot failure leak out of the thread.)
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
slot.value = fn()
|
|
81
|
+
except BaseException as e: # noqa: BLE001 - deliberate: isolate the slot
|
|
82
|
+
slot.error = e
|
|
83
|
+
finally:
|
|
84
|
+
slot.elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_parallel(tasks, deadline_seconds):
|
|
88
|
+
"""Run ``{label: callable}`` concurrently under one shared deadline.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
tasks: mapping of label -> zero-arg callable. Each is invoked once in
|
|
92
|
+
its own daemon thread. Insertion order is preserved in the returned
|
|
93
|
+
mapping (Python dicts are ordered) so the caller can emit per-slot
|
|
94
|
+
telemetry deterministically regardless of completion order.
|
|
95
|
+
deadline_seconds: total wall-clock budget for ALL tasks combined,
|
|
96
|
+
measured from entry. Non-positive values run every task with a
|
|
97
|
+
zero join (each slot is recorded as not-completed unless it was
|
|
98
|
+
already instantaneous) — a degenerate "give up immediately" mode.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
``dict[label] -> SlotResult`` in the same order as ``tasks``. Never
|
|
102
|
+
raises for a task failure — a raising task surfaces via
|
|
103
|
+
``SlotResult.error``.
|
|
104
|
+
"""
|
|
105
|
+
start = time.monotonic()
|
|
106
|
+
pairs = [] # (SlotResult, Thread) in task order
|
|
107
|
+
for label, fn in tasks.items():
|
|
108
|
+
slot = SlotResult(label)
|
|
109
|
+
thread = threading.Thread(
|
|
110
|
+
target=_runner,
|
|
111
|
+
args=(slot, fn, start),
|
|
112
|
+
daemon=True,
|
|
113
|
+
name=f"recall-slot-{label}",
|
|
114
|
+
)
|
|
115
|
+
pairs.append((slot, thread))
|
|
116
|
+
|
|
117
|
+
for _slot, thread in pairs:
|
|
118
|
+
thread.start()
|
|
119
|
+
|
|
120
|
+
# Join each thread, but never wait past the SHARED deadline. Because the
|
|
121
|
+
# remaining budget is recomputed from ``start`` on every iteration, the
|
|
122
|
+
# total time spent here is bounded by ``deadline_seconds`` even with many
|
|
123
|
+
# slow slots — a fast early slot leaves more budget for later ones, and
|
|
124
|
+
# once the budget is exhausted every subsequent join is a non-blocking
|
|
125
|
+
# ``is_alive`` check.
|
|
126
|
+
for _slot, thread in pairs:
|
|
127
|
+
remaining = deadline_seconds - (time.monotonic() - start)
|
|
128
|
+
if remaining > 0:
|
|
129
|
+
thread.join(remaining)
|
|
130
|
+
|
|
131
|
+
# Classify each slot as completed (thread finished) or deadline-abandoned.
|
|
132
|
+
now = time.monotonic()
|
|
133
|
+
results = {}
|
|
134
|
+
for slot, thread in pairs:
|
|
135
|
+
if thread.is_alive():
|
|
136
|
+
slot.completed = False
|
|
137
|
+
if slot.elapsed_ms is None:
|
|
138
|
+
slot.elapsed_ms = int((now - start) * 1000)
|
|
139
|
+
else:
|
|
140
|
+
slot.completed = True
|
|
141
|
+
results[slot.label] = slot
|
|
142
|
+
return results
|
|
@@ -82,6 +82,37 @@ def write_state(name: str, data):
|
|
|
82
82
|
pass
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def remove_state(name: str) -> None:
|
|
86
|
+
"""Delete a state file if it exists. Best-effort; never raises.
|
|
87
|
+
|
|
88
|
+
Name is sanitized through the same path-traversal guard as read/write, so
|
|
89
|
+
callers cannot escape the state directory.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
path = _state_file(name)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
os.remove(path)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def list_state_names(prefix: str = "") -> list:
|
|
102
|
+
"""List state-file basenames in the state dir, optionally by prefix.
|
|
103
|
+
|
|
104
|
+
Returns [] on any error. Used to enumerate a family of cache files (e.g.
|
|
105
|
+
per-bank directive caches) for bulk invalidation.
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
names = os.listdir(_state_dir())
|
|
109
|
+
except OSError:
|
|
110
|
+
return []
|
|
111
|
+
if prefix:
|
|
112
|
+
return [n for n in names if n.startswith(prefix)]
|
|
113
|
+
return names
|
|
114
|
+
|
|
115
|
+
|
|
85
116
|
def get_turn_count(session_id: str) -> int:
|
|
86
117
|
"""Get the current turn count for a session."""
|
|
87
118
|
turns = read_state("turns.json", {})
|