diff --git a/amplifier_web/__init__.py b/amplifier_web/__init__.py index 6d1b0205e..c3ab2eff3 100644 --- a/amplifier_web/__init__.py +++ b/amplifier_web/__init__.py @@ -1,2 +1,2 @@ """Amplifier's local web host.""" -__version__ = "0.96.3" +__version__ = "0.96.4" diff --git a/amplifier_web/collaboration.py b/amplifier_web/collaboration.py index 13a93dbb7..3b08e6e55 100644 --- a/amplifier_web/collaboration.py +++ b/amplifier_web/collaboration.py @@ -557,9 +557,19 @@ def current(self, sid): WHERE json_extract(receipt,'$.commandAction')='coordination.grant' AND EXISTS (SELECT 1 FROM json_each(json_extract(receipt,'$.result.participants')) WHERE value=?) ORDER BY rowid DESC LIMIT 32""", (sid,)).fetchall()] + # Each indexed side contributes at most 32 candidates. An older row + # cannot enter the combined newest 32; UNION deduplicates self-sends. requests = [json.loads(row[0]) for row in self.service.db.execute("""SELECT receipt FROM commands - WHERE json_extract(receipt,'$.commandAction')='coordination.send' - AND (json_extract(receipt,'$.senderSessionId')=? OR json_extract(receipt,'$.target.sessionId')=?) + WHERE rowid IN ( + SELECT rowid FROM (SELECT rowid FROM commands + WHERE json_extract(receipt,'$.commandAction')='coordination.send' + AND json_extract(receipt,'$.senderSessionId')=? + ORDER BY rowid DESC LIMIT 32) + UNION + SELECT rowid FROM (SELECT rowid FROM commands + WHERE json_extract(receipt,'$.commandAction')='coordination.send' + AND json_extract(receipt,'$.target.sessionId')=? + ORDER BY rowid DESC LIMIT 32)) ORDER BY rowid DESC LIMIT 32""", (sid, sid)).fetchall()] from .request_cancellation import status as cancellation_status for request in requests: diff --git a/amplifier_web/release-notes.json b/amplifier_web/release-notes.json index 5725e90f7..97126a5c6 100644 --- a/amplifier_web/release-notes.json +++ b/amplifier_web/release-notes.json @@ -1,6 +1,7 @@ { "schemaVersion": 1, "releases": [ + {"version":"0.96.4","title":"Keep busy conversations responsive as coordination history grows","changes":["Recent coordination updates now read only the newest matching requests, reducing repeated work while managers and workers exchange messages. Saved request order, cancellation status and history remain unchanged."],"notices":[]}, {"version":"0.96.3","title":"Keep busy workspaces responsive","changes":["Browser updates share one notice-counting pass, reducing repeated database work while chats and tools are active. Unread counts, approvals, errors and saved conversations retain their existing behavior."],"notices":[]}, {"version":"0.96.2","title":"Keep context-limit recovery connected to the original request","changes":["Preserve a model provider's context-limit feedback when the host adds the selected model and current observations to a request, so the existing recovery path can use the feedback instead of losing it between request wrappers.","Keep recovery tied to the exact failed request and its options. Changed requests, feedback already consumed, and errors after streamed output do not start another recovery attempt; Stop and completed-tool receipts remain intact.","Add regression coverage for cold Fable 5.2 counting, recovery during a long tool turn, resumed signed history, a second context-limit refusal and Stop. The upstream cold-model fix is selected in development checks; installed provider components still update through the normal component-update path.","Package workspace registration metadata from its published 0.1.0 component, including its source and license records. The runtime code and workspace behavior are unchanged."],"notices":[]}, {"version":"0.96.1","title":"Published operations component, unchanged runtime behavior","changes":["Unified now selects the original published operations 0.1.0 artifact and verifies its runtime bytes offline. Independent component authoring moves to components/operations, while the selected top-level runtime keeps the existing editable and isolated-worker imports. Unified remains the only installed distribution owner; operation behavior, durable receipts, cursors, authority and Stop controls are unchanged. The app package adds the selected artifact's provenance and license records; it does not republish the component.","Component release tooling now supports the independent workspace-metadata publication path, without changing Unified's selected runtime or coinstalling a standalone package. MCP guidance documents the released error-fidelity adapter in its explicit opt-in profile."],"notices":[]}, diff --git a/docs/validation/performance.md b/docs/validation/performance.md index b4e260e3b..49da6e01f 100644 --- a/docs/validation/performance.md +++ b/docs/validation/performance.md @@ -575,6 +575,15 @@ checks retain global positions, negative indices, private-state filtering and stale-revision refusal. Their SQL budgets measure query work, not constant-time global ordering or a guaranteed live-host latency. +`tests/test_coordination_current_cost.py` grows unrelated, incoming and outgoing +command history while holding the current 32-request page fixed. Select bounded +candidate IDs through the existing sender/target indexes before fetching bodies; +the final page keeps insertion order and deduplicates requests involving both +sides. Reads stay on the caller's transaction and observe uncommitted receipt +changes and rollback. Cancellation remains a projection of the saved receipt, +not a rewritten outcome. The measured query bound does not cover initial index +creation, grants, pending proposals or other coordination operations. + The additive Extended browser registration belongs to the nightly/manual lane. The separate `Client performance smoke` workflow selects affected PR paths and supports manual runs; it is not a required status or a publication dependency. Run it locally in diff --git a/pyproject.toml b/pyproject.toml index 341cf49f1..9f8cdca9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amplifier-unified" -version = "0.96.3" +version = "0.96.4" description = "A local voice-first Amplifier workspace with a shared user and agent interface" readme = "README.md" requires-python = ">=3.13" diff --git a/tests/test_coordination_current_cost.py b/tests/test_coordination_current_cost.py new file mode 100644 index 000000000..e84741a0b --- /dev/null +++ b/tests/test_coordination_current_cost.py @@ -0,0 +1,102 @@ +"""Bound current receipt lookup before decoding retained request bodies.""" +import json +import time + +import pytest + +from test_coordination import app # noqa: F401 -- actual host/index fixture + + +def request(identity, sender, target, **changes): + return {"requestId": identity, "commandAction": "coordination.send", + "senderSessionId": sender, "target": {"sessionId": target}, + "delivery": "accepted", "acceptedAt": 0, **changes} + + +def insert(db, rows): + db.executemany("INSERT INTO commands(id,fingerprint,receipt) VALUES(?,?,?)", + ((row["requestId"], row["requestId"], json.dumps(row)) for row in rows)) + + +@pytest.mark.parametrize("retained_kind", ["unrelated", "outgoing", "incoming"]) +async def test_current_receipt_work_is_bounded_by_the_page(app, retained_kind): + sid, peer = (row["id"] for row in app.state["sessions"]) + sender = sid if retained_kind == "outgoing" else "elsewhere" + target = sid if retained_kind == "incoming" else "another-peer" + measurements = [] + for count in (50, 5000): + app.db.execute("DELETE FROM commands") + insert(app.db, (request(f"old-{i}", sender, target, text="retained" * 256) + for i in range(count))) + recent = [request(f"recent-{i}", sid if i % 2 else peer, + peer if i % 2 else sid) for i in range(48)] + insert(app.db, recent) + app.db.commit() + steps = 0 + + def progress(): + nonlocal steps + steps += 100 + return 0 + + app.db.set_progress_handler(progress, 100) + start = time.thread_time_ns() + try: + actual = app.collaboration.current(sid)["requests"] + finally: + app.db.set_progress_handler(None, 0) + measurements.append({"retained": count, "vm": steps, + "cpuMs": (time.thread_time_ns() - start) / 1e6}) + assert actual == list(reversed(recent))[:32] + print(json.dumps({"currentReceipts": retained_kind, "measurements": measurements})) + # A fixed current page must not parse/filter every retained peer request, + # including older requests involving this same conversation. Count actual + # SQLite work, not just the 32 returned/decoded rows or elapsed time. + assert measurements[1]["vm"] < 5000 + assert measurements[1]["vm"] <= measurements[0]["vm"] + 500 + + +async def test_current_receipts_keep_order_deduplication_and_saved_bodies(app): + sid, peer = (row["id"] for row in app.state["sessions"]) + rows = [request(f"request-{i}", sid if i % 2 else peer, + sid if i % 3 else peer, acceptedAt=1000-i, + text=f"Saved body {i}: \u2603", detail={"nested": [i]}) + for i in range(90)] + # Sender-only and target-only records are still selected. A same-owner + # request appears once, including at a page boundary; dates do not reorder. + rows[-1].pop("target") + rows[-2].pop("senderSessionId") + rows[-3].update(senderSessionId=sid, target={"sessionId": sid}, + cancellation={"status": "requested", "revision": 2}) + rows[-4]["commandAction"] = "unrelated.action" + insert(app.db, rows) + expected = [row for row in reversed(rows) + if row["commandAction"] == "coordination.send" + and (row.get("senderSessionId") == sid + or row.get("target", {}).get("sessionId") == sid)][:32] + actual = app.collaboration.current(sid)["requests"] + assert actual == expected + assert len({row["requestId"] for row in actual}) == 32 + actual[0]["detail"]["nested"].append("borrowed edit") + assert app.collaboration.current(sid)["requests"] == expected + + +async def test_current_receipts_observe_updates_and_transaction_rollback(app): + sid, peer = (row["id"] for row in app.state["sessions"]) + original = request("original", peer, sid, cancellation={"status": "requested"}) + insert(app.db, [original]) + app.db.commit() + app.db.execute("SAVEPOINT receipt_change") + changed = {**original, "delivery": "held", "text": "not admitted"} + app.collaboration.save("original", changed) + insert(app.db, [request("new", sid, peer)]) + value = app.collaboration.current(sid)["requests"] + assert [row["requestId"] for row in value] == ["new", "original"] + assert value[1]["cancellation"]["status"] == "withdrawn" + # The read projection never rewrites the historical cancellation receipt. + assert app.collaboration.receipt("original")["cancellation"] == {"status": "requested"} + app.db.execute("ROLLBACK TO receipt_change") + app.db.execute("RELEASE receipt_change") + assert app.collaboration.current(sid)["requests"] == [original] + app.db.execute("DELETE FROM commands WHERE id='original'") + assert app.collaboration.current(sid)["requests"] == [] diff --git a/uv.lock b/uv.lock index d3277323b..64653d657 100644 --- a/uv.lock +++ b/uv.lock @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "amplifier-unified" -version = "0.96.3" +version = "0.96.4" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" },