switchroom 0.18.24 → 0.18.26
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/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -72,7 +72,18 @@ def _budget_seconds() -> float:
|
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def _retry_one(entry: dict, timeout: int) -> None:
|
|
75
|
-
"""POST a single queued retain. Raises on failure.
|
|
75
|
+
"""POST a single queued retain. Raises on failure.
|
|
76
|
+
|
|
77
|
+
Posts ``async_processing=False`` (commit-before-ack, switchroom #3244 §1.1):
|
|
78
|
+
the drain is a DURABILITY path — it deletes the pending entry on a 200, so
|
|
79
|
+
the 200 must prove durable persistence, not merely ack-of-receipt. A bare
|
|
80
|
+
async 200 followed by a dropped extraction would delete the queue entry
|
|
81
|
+
while the content never lands, and (for boot-reconcile remainders whose
|
|
82
|
+
watermark already advanced) there is no reconcile backstop — silent loss
|
|
83
|
+
(the #3244 bug). All drained entries — Stop-hook A2 failures, SessionEnd
|
|
84
|
+
failures, and reconcile-remainder deferrals — are durability retries, so
|
|
85
|
+
sync is correct for every one.
|
|
86
|
+
"""
|
|
76
87
|
client = HindsightClient(entry["api_url"], entry.get("api_token"))
|
|
77
88
|
client.retain(
|
|
78
89
|
bank_id=entry["bank_id"],
|
|
@@ -82,6 +93,7 @@ def _retry_one(entry: dict, timeout: int) -> None:
|
|
|
82
93
|
metadata=entry.get("metadata") or {},
|
|
83
94
|
tags=entry.get("tags"),
|
|
84
95
|
timeout=timeout,
|
|
96
|
+
async_processing=False,
|
|
85
97
|
)
|
|
86
98
|
|
|
87
99
|
|
|
@@ -173,12 +173,22 @@ class HindsightClient:
|
|
|
173
173
|
metadata: Optional[dict] = None,
|
|
174
174
|
tags: Optional[list] = None,
|
|
175
175
|
timeout: int = 15,
|
|
176
|
+
async_processing: bool = True,
|
|
176
177
|
) -> dict:
|
|
177
178
|
"""Retain content into a bank's memory.
|
|
178
179
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
By default posts with ``async=true`` so the server processes extraction
|
|
181
|
+
in the background (a 200 is an ack-of-receipt, not proof of durable
|
|
182
|
+
persistence). The context field helps Hindsight cluster memories by
|
|
183
|
+
provenance (e.g. "claude-code" vs manual retains).
|
|
184
|
+
|
|
185
|
+
``async_processing=False`` (switchroom #3244 §1.1) posts ``async=false``
|
|
186
|
+
so the 200 is returned only after the daemon has durably committed the
|
|
187
|
+
item — commit-before-ack. Durability paths whose success advances the
|
|
188
|
+
retain watermark (the Stop-hook durability retain and boot
|
|
189
|
+
reconciliation) MUST use this so a bare async 200 can never falsely mark
|
|
190
|
+
unpersisted work as committed. (Merge precondition: the daemon honours
|
|
191
|
+
``async=false`` as commit-before-ack — verified by the §1.1 probe.)
|
|
182
192
|
"""
|
|
183
193
|
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
|
|
184
194
|
item = {
|
|
@@ -192,7 +202,7 @@ class HindsightClient:
|
|
|
192
202
|
item["tags"] = tags
|
|
193
203
|
body = {
|
|
194
204
|
"items": [item],
|
|
195
|
-
"async":
|
|
205
|
+
"async": bool(async_processing),
|
|
196
206
|
}
|
|
197
207
|
return self._request("POST", path, body, timeout=timeout)
|
|
198
208
|
|
|
@@ -95,6 +95,12 @@ DEFAULTS = {
|
|
|
95
95
|
"retainContext": "claude-code",
|
|
96
96
|
"retainTags": [],
|
|
97
97
|
"retainMetadata": {},
|
|
98
|
+
# Switchroom #3244 — boot reconciliation of un-committed transcript tails.
|
|
99
|
+
# On by default; the load-bearing recovery for work an abrupt session death
|
|
100
|
+
# (SIGKILL/OOM/watchdog) skipped. Disable per-agent via
|
|
101
|
+
# HINDSIGHT_RECONCILE_ON_START=false. reconcile_tail.py also honours the
|
|
102
|
+
# HINDSIGHT_RECONCILE_{LOOKBACK_H,MAX_TURNS,BUDGET_S} bounds (read directly).
|
|
103
|
+
"reconcileOnStart": True,
|
|
98
104
|
"recallAdditionalBanks": [],
|
|
99
105
|
# Connection
|
|
100
106
|
"hindsightApiUrl": None,
|
|
@@ -134,6 +140,8 @@ ENV_OVERRIDES = {
|
|
|
134
140
|
"HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
|
|
135
141
|
"HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
|
|
136
142
|
"HINDSIGHT_RETAIN_MODE": ("retainMode", str),
|
|
143
|
+
# Switchroom #3244 — boot reconciliation on/off (default on).
|
|
144
|
+
"HINDSIGHT_RECONCILE_ON_START": ("reconcileOnStart", bool),
|
|
137
145
|
"HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
|
|
138
146
|
"HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int),
|
|
139
147
|
# Switchroom-local: count cap. Set by start.sh from
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Shared fleet pacing lock for Hindsight retain POSTs (switchroom #3244 §1.5).
|
|
2
|
+
|
|
3
|
+
A single per-agent advisory flock, ``$HOME/.hindsight/retain-inflight.lock``,
|
|
4
|
+
serialises the durability-path retain POSTs so at most ONE is in flight for an
|
|
5
|
+
agent at a time — covering the live Stop-hook retain, boot reconciliation, and
|
|
6
|
+
(PR2) the one-time backfill collectively. This is the "never storm the daemon"
|
|
7
|
+
guard.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from lib.pacing import inflight_lock
|
|
12
|
+
|
|
13
|
+
with inflight_lock(blocking=False) as acquired:
|
|
14
|
+
if not acquired:
|
|
15
|
+
... # someone else holds it — enqueue and return, do NOT wait
|
|
16
|
+
client.retain(...)
|
|
17
|
+
|
|
18
|
+
* Boot reconcile / backfill acquire it **blocking** (they are batch work and
|
|
19
|
+
should wait their turn).
|
|
20
|
+
* The live Stop hook acquires it **non-blocking** (``blocking=False``): if a
|
|
21
|
+
reconcile/backfill holds it the live retain must NOT wait (turn latency must
|
|
22
|
+
not regress) — the caller enqueues its payload to ``pending-retains/`` and
|
|
23
|
+
returns, and the next boot drain delivers it.
|
|
24
|
+
|
|
25
|
+
The context manager yields ``True`` when the lock was acquired (and releases it
|
|
26
|
+
on exit) and ``False`` when a non-blocking acquire failed (nothing to release).
|
|
27
|
+
It never raises on lock-infrastructure errors: if flock is unavailable it
|
|
28
|
+
fails OPEN (yields ``True``) so durability is never blocked by the lock itself.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import contextlib
|
|
34
|
+
import os
|
|
35
|
+
import sys
|
|
36
|
+
from typing import Iterator
|
|
37
|
+
|
|
38
|
+
if sys.platform != "win32":
|
|
39
|
+
import fcntl
|
|
40
|
+
else: # pragma: no cover - switchroom agents are Linux only
|
|
41
|
+
fcntl = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lock_path() -> str:
|
|
45
|
+
"""Path of the shared inflight lock. Override with ``HINDSIGHT_INFLIGHT_LOCK``."""
|
|
46
|
+
override = os.environ.get("HINDSIGHT_INFLIGHT_LOCK")
|
|
47
|
+
if override:
|
|
48
|
+
return override
|
|
49
|
+
return os.path.join(os.path.expanduser("~"), ".hindsight", "retain-inflight.lock")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@contextlib.contextmanager
|
|
53
|
+
def inflight_lock(blocking: bool = True) -> Iterator[bool]:
|
|
54
|
+
"""Acquire the shared inflight lock.
|
|
55
|
+
|
|
56
|
+
Yields ``True`` if acquired (released on exit), ``False`` if a non-blocking
|
|
57
|
+
acquire could not get it. Fails OPEN (yields ``True``, no real lock) when
|
|
58
|
+
flock is unavailable so the durability path is never blocked by lock
|
|
59
|
+
infrastructure.
|
|
60
|
+
"""
|
|
61
|
+
if fcntl is None:
|
|
62
|
+
yield True
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
path = lock_path()
|
|
66
|
+
d = os.path.dirname(path)
|
|
67
|
+
try:
|
|
68
|
+
if d and not os.path.isdir(d):
|
|
69
|
+
os.makedirs(d, mode=0o700, exist_ok=True)
|
|
70
|
+
except OSError:
|
|
71
|
+
# Can't even create the dir — fail open rather than block durability.
|
|
72
|
+
yield True
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
lock_fd = None
|
|
76
|
+
acquired = False
|
|
77
|
+
try:
|
|
78
|
+
lock_fd = open(path, "w")
|
|
79
|
+
flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
|
|
80
|
+
try:
|
|
81
|
+
fcntl.flock(lock_fd, flags)
|
|
82
|
+
acquired = True
|
|
83
|
+
except OSError:
|
|
84
|
+
if blocking:
|
|
85
|
+
# A blocking acquire that errored (not a would-block) — fail
|
|
86
|
+
# open so batch work still makes progress.
|
|
87
|
+
acquired = True
|
|
88
|
+
else:
|
|
89
|
+
acquired = False
|
|
90
|
+
yield acquired
|
|
91
|
+
except OSError:
|
|
92
|
+
# Could not open the lock file at all — fail open.
|
|
93
|
+
yield True
|
|
94
|
+
return
|
|
95
|
+
finally:
|
|
96
|
+
if lock_fd is not None:
|
|
97
|
+
try:
|
|
98
|
+
if acquired:
|
|
99
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
lock_fd.close()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Durable per-session retain watermark (switchroom #3244).
|
|
2
|
+
|
|
3
|
+
Records, per ``(agent, session_id)``, the identity of the last transcript
|
|
4
|
+
entry that has been *confirmed* committed to the Hindsight bank. This is the
|
|
5
|
+
fact-on-disk that boot reconciliation (``reconcile_tail.py``) diffs against
|
|
6
|
+
the surviving ``.jsonl`` transcript to recover work an abrupt session death
|
|
7
|
+
(SIGKILL / OOM / watchdog) would otherwise silently drop.
|
|
8
|
+
|
|
9
|
+
Design (design-20260715.md §1.1):
|
|
10
|
+
|
|
11
|
+
* **Key = the message ``uuid`` of the last committed transcript entry.** Not
|
|
12
|
+
a turn ordinal (compaction re-indexes) and not a byte offset (shifts on
|
|
13
|
+
compaction); the ``uuid`` Claude Code stamps on every ``.jsonl`` entry is
|
|
14
|
+
stable across compaction and unambiguous. A ``byte_offset_hint`` is stored
|
|
15
|
+
as a fast-path only and never trusted alone.
|
|
16
|
+
* **Advance ONLY on confirmed persistence.** ``commit()`` is called only after
|
|
17
|
+
a retain POST the caller made with commit-before-ack semantics
|
|
18
|
+
(``async_processing=False``) returned ok. A watermark that is *behind*
|
|
19
|
+
reality is safe (the reconciler re-POSTs, which upserts idempotently on the
|
|
20
|
+
deterministic ``document_id``); a watermark *ahead* of reality would skip
|
|
21
|
+
real work, so we never write it speculatively.
|
|
22
|
+
* **Never moves backward.** ``commit()`` compares the incoming ``last_uuid``'s
|
|
23
|
+
position against the stored one *in the current transcript*; a stored uuid
|
|
24
|
+
that compaction removed is treated as stale (accept the incoming commit) —
|
|
25
|
+
a lost anchor can only cause a safe re-upsert, never a skip.
|
|
26
|
+
* **Atomic + flock.** ``write tmp -> fsync -> os.replace`` under an exclusive
|
|
27
|
+
flock (mirrors ``lib/state.py`` / ``lib/pending.py``) so a concurrent async
|
|
28
|
+
Stop hook and a SessionEnd force-retain writing the same session serialize
|
|
29
|
+
and never observe a torn file.
|
|
30
|
+
|
|
31
|
+
Location: ``$HOME/.hindsight/retained/<session_id>.json`` — same
|
|
32
|
+
container-lifetime persistence semantics as ``pending-retains/``. Losing it on
|
|
33
|
+
container recreate is safe: no watermark => the reconciler treats the bounded
|
|
34
|
+
transcript tail as the gap and re-upserts.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
from typing import Optional
|
|
44
|
+
|
|
45
|
+
if sys.platform != "win32":
|
|
46
|
+
import fcntl
|
|
47
|
+
else: # pragma: no cover - switchroom agents are Linux only
|
|
48
|
+
fcntl = None
|
|
49
|
+
|
|
50
|
+
SCHEMA = 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def watermark_dir() -> str:
|
|
54
|
+
"""Return the retained-watermark directory.
|
|
55
|
+
|
|
56
|
+
Override with ``HINDSIGHT_RETAINED_DIR`` (tests). Default:
|
|
57
|
+
``$HOME/.hindsight/retained/``.
|
|
58
|
+
"""
|
|
59
|
+
override = os.environ.get("HINDSIGHT_RETAINED_DIR")
|
|
60
|
+
if override:
|
|
61
|
+
return override
|
|
62
|
+
return os.path.join(os.path.expanduser("~"), ".hindsight", "retained")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ensure_dir() -> str:
|
|
66
|
+
d = watermark_dir()
|
|
67
|
+
if not os.path.isdir(d):
|
|
68
|
+
os.makedirs(d, mode=0o700, exist_ok=True)
|
|
69
|
+
else:
|
|
70
|
+
try:
|
|
71
|
+
mode = os.stat(d).st_mode & 0o777
|
|
72
|
+
if mode != 0o700:
|
|
73
|
+
os.chmod(d, 0o700)
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
76
|
+
return d
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe_session(session_id: str) -> str:
|
|
80
|
+
"""Sanitise a session id for use as a filename (no path traversal)."""
|
|
81
|
+
keep = [c if (c.isalnum() or c in "-_.") else "_" for c in (session_id or "unknown")]
|
|
82
|
+
name = "".join(keep)[:200]
|
|
83
|
+
# Never allow a name that resolves to a traversal component.
|
|
84
|
+
return name.replace("..", "_") or "unknown"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _path(session_id: str) -> str:
|
|
88
|
+
return os.path.join(watermark_dir(), f"{_safe_session(session_id)}.json")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _lock_path(session_id: str) -> str:
|
|
92
|
+
return os.path.join(watermark_dir(), f"{_safe_session(session_id)}.lock")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load(session_id: str) -> Optional[dict]:
|
|
96
|
+
"""Return the stored watermark dict for ``session_id``, or ``None``."""
|
|
97
|
+
p = _path(session_id)
|
|
98
|
+
if not os.path.isfile(p):
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
with open(p, encoding="utf-8") as f:
|
|
102
|
+
return json.load(f)
|
|
103
|
+
except (OSError, json.JSONDecodeError):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _atomic_write(session_id: str, entry: dict) -> None:
|
|
108
|
+
d = _ensure_dir()
|
|
109
|
+
final = os.path.join(d, f"{_safe_session(session_id)}.json")
|
|
110
|
+
tmp = final + ".tmp"
|
|
111
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
112
|
+
json.dump(entry, f, ensure_ascii=False)
|
|
113
|
+
f.flush()
|
|
114
|
+
os.fsync(f.fileno())
|
|
115
|
+
os.chmod(tmp, 0o600)
|
|
116
|
+
os.replace(tmp, final)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _would_regress(stored: Optional[dict], last_uuid: str, ordered_uuids) -> bool:
|
|
120
|
+
"""True if committing ``last_uuid`` would move the watermark BACKWARD.
|
|
121
|
+
|
|
122
|
+
Uses the position of each uuid *in the current transcript* (``ordered_uuids``,
|
|
123
|
+
transcript order). Explicit stale-anchor branch (design §1.1): if the stored
|
|
124
|
+
``last_uuid`` is not present in the current transcript it was compacted away
|
|
125
|
+
and cannot be positionally compared — treat the stored watermark as stale and
|
|
126
|
+
accept the incoming commit (never a skip, at worst a safe re-upsert).
|
|
127
|
+
"""
|
|
128
|
+
if not stored:
|
|
129
|
+
return False
|
|
130
|
+
stored_uuid = stored.get("last_uuid")
|
|
131
|
+
if not stored_uuid or stored_uuid == last_uuid:
|
|
132
|
+
# No stored anchor, or an idempotent re-commit of the same anchor —
|
|
133
|
+
# neither is a backward move.
|
|
134
|
+
return False
|
|
135
|
+
if not ordered_uuids:
|
|
136
|
+
# Without transcript ordering we cannot prove regression; accept
|
|
137
|
+
# (safety leans toward re-upsert, never skip).
|
|
138
|
+
return False
|
|
139
|
+
try:
|
|
140
|
+
stored_idx = ordered_uuids.index(stored_uuid)
|
|
141
|
+
except ValueError:
|
|
142
|
+
# Stored anchor compacted away -> stale -> accept incoming.
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
incoming_idx = ordered_uuids.index(last_uuid)
|
|
146
|
+
except ValueError:
|
|
147
|
+
# Incoming uuid not in transcript (shouldn't happen); accept.
|
|
148
|
+
return False
|
|
149
|
+
# Regress only if the stored anchor is strictly AHEAD of the incoming one.
|
|
150
|
+
return stored_idx > incoming_idx
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def commit(
|
|
154
|
+
session_id: str,
|
|
155
|
+
last_uuid: str,
|
|
156
|
+
document_id: str,
|
|
157
|
+
transcript_path: str = "",
|
|
158
|
+
ordered_uuids=None,
|
|
159
|
+
byte_offset_hint: Optional[int] = None,
|
|
160
|
+
) -> Optional[dict]:
|
|
161
|
+
"""Advance the watermark for ``session_id`` to ``last_uuid``.
|
|
162
|
+
|
|
163
|
+
Refuses to move backward (see ``_would_regress``). Returns the persisted
|
|
164
|
+
watermark dict, or the existing one if the commit was a no-op (regression /
|
|
165
|
+
idempotent). Best-effort: a write failure returns ``None`` and never raises
|
|
166
|
+
— a successful retain must never be failed by a watermark write.
|
|
167
|
+
|
|
168
|
+
``ordered_uuids`` is the list of transcript entry uuids in transcript order,
|
|
169
|
+
used for the monotonic comparison; the caller (retain / reconcile) has the
|
|
170
|
+
transcript in hand.
|
|
171
|
+
"""
|
|
172
|
+
if not last_uuid:
|
|
173
|
+
# Nothing to anchor on (legacy/flat transcript with no uuids). We do
|
|
174
|
+
# not persist a uuid-less watermark: the reconciler falls back to
|
|
175
|
+
# treating the bounded tail as the gap, which upserts idempotently.
|
|
176
|
+
return None
|
|
177
|
+
lock_path = _lock_path(session_id)
|
|
178
|
+
entry = {
|
|
179
|
+
"schema": SCHEMA,
|
|
180
|
+
"session_id": session_id,
|
|
181
|
+
"last_uuid": last_uuid,
|
|
182
|
+
"last_document_id": document_id,
|
|
183
|
+
"committed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
184
|
+
"transcript_path": transcript_path,
|
|
185
|
+
}
|
|
186
|
+
if byte_offset_hint is not None:
|
|
187
|
+
entry["byte_offset_hint"] = int(byte_offset_hint)
|
|
188
|
+
|
|
189
|
+
def _do() -> Optional[dict]:
|
|
190
|
+
stored = load(session_id)
|
|
191
|
+
if _would_regress(stored, last_uuid, ordered_uuids):
|
|
192
|
+
return stored
|
|
193
|
+
_atomic_write(session_id, entry)
|
|
194
|
+
return entry
|
|
195
|
+
|
|
196
|
+
if fcntl is not None:
|
|
197
|
+
try:
|
|
198
|
+
_ensure_dir()
|
|
199
|
+
lock_fd = open(lock_path, "w")
|
|
200
|
+
try:
|
|
201
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
202
|
+
return _do()
|
|
203
|
+
finally:
|
|
204
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
205
|
+
lock_fd.close()
|
|
206
|
+
except OSError:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
# Fallback without lock (best-effort).
|
|
210
|
+
try:
|
|
211
|
+
return _do()
|
|
212
|
+
except OSError:
|
|
213
|
+
return None
|