Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync";
import { useBoundedMessageWindows } from "@/features/messages/useBoundedMessageWindows";
import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
Expand Down Expand Up @@ -213,6 +214,8 @@ function CommunitySwitchGate() {
function CommunityQueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createBuzzQueryClient);

useBoundedMessageWindows(queryClient);

useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]);

useEffect(() => {
Expand Down
70 changes: 70 additions & 0 deletions desktop/src/features/agents/channelActivitySummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ObserverEvent } from "./ui/agentSessionTypes";
import { normalizePubkey } from "@/shared/lib/pubkey";

// Per-agent channel-activity summary: normalized pubkey → (channelId → latest
// activity ms). One number per distinct channel the agent has been seen active
// in, recorded from every live event as it arrives — so it is NOT subject to
// the UNPINNED_AGENT_EVENT_TAIL cap that bounds `eventsByAgent`. It exists to
// keep the profile activity feed's channel scope intact after an unpinned
// agent's event window is truncated to its newest tail: the tail retains the
// newest events (so the *preferred* channel is safe), but a channel whose last
// event fell out of the tail would vanish from the derived channel set without
// this durable summary. Compact by construction — bounded by the (small) count
// of distinct channels one agent works in, not by event volume.
const channelActivityByAgent = new Map<string, Map<string, number>>();

const EMPTY_CHANNEL_ACTIVITY: Record<string, number> = {};

/**
* Record one live event into the per-agent channel-activity summary. Keeps the
* latest activity ms per channel so the profile feed's channel scope survives
* the tail-truncation of `eventsByAgent`. A channelId-less event (agent-general
* frame) carries no channel scope and is skipped; a frame with an unparseable
* timestamp cannot advance a channel's recency and is skipped. `key` is already
* a normalized pubkey (the caller normalized it).
*/
export function recordChannelActivity(key: string, event: ObserverEvent): void {
if (!event.channelId) {
return;
}
const millis = Date.parse(event.timestamp);
if (Number.isNaN(millis)) {
return;
}
let byChannel = channelActivityByAgent.get(key);
if (!byChannel) {
byChannel = new Map();
channelActivityByAgent.set(key, byChannel);
}
const previous = byChannel.get(event.channelId);
if (previous === undefined || millis > previous) {
byChannel.set(event.channelId, millis);
}
}

/**
* Latest observed activity ms per channel for one agent, from the durable
* channel-activity summary. Unlike `getAgentObserverSnapshot().events`, this is
* NOT truncated to the unpinned tail — it retains one entry per distinct
* channel the agent has been active in for the store's lifetime. The profile
* activity feed folds this into its channel scope so an unpinned agent's older
* channels do not vanish from the switcher after its event window is trimmed.
* Returns a shared empty object (stable identity) for an unknown agent.
*/
export function getAgentChannelActivity(
agentPubkey?: string | null,
): Record<string, number> {
if (!agentPubkey) {
return EMPTY_CHANNEL_ACTIVITY;
}
const byChannel = channelActivityByAgent.get(normalizePubkey(agentPubkey));
if (!byChannel || byChannel.size === 0) {
return EMPTY_CHANNEL_ACTIVITY;
}
return Object.fromEntries(byChannel);
}

/** Clear the summary; called from resetAgentObserverStore. */
export function clearChannelActivity(): void {
channelActivityByAgent.clear();
}
131 changes: 131 additions & 0 deletions desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1187,3 +1187,134 @@ describe("raw-event-level merge: stateful aggregates across live/archive boundar
assert.equal(archived[0].seq, 31);
});
});

describe("ingestArchivedObserverEvents — atomic commit under staleness gate", () => {
beforeEach(() => {
resetAgentObserverStore();
_testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]);
});

/**
* The commit is atomic against a channel switch or panel unmount that lands
* WHILE the page is decrypting. Phase 1 (decrypt into staging) is the only
* async work; phase 2 evaluates `isStale()` once, with no await before the
* appends. Here the caller's gate flips to stale after the page's decrypt is
* released — modeling the user switching channels (A→B, or A→B→A) or the
* panel unmounting mid-decrypt — so the whole page must be dropped and the
* archive must stay empty. Without the gate, a late page resurrects an
* evicted/switched-away channel as most-recently-loaded.
*/
it("test_page_going_stale_during_decrypt_commits_nothing", async () => {
let releaseDecrypt;
const decryptGate = new Promise((resolve) => {
releaseDecrypt = resolve;
});
let stale = false;

const ingest = ingestArchivedObserverEvents(
[makeRawEvent(), makeRawEvent({ id: "f".repeat(64) })],
async () => {
await decryptGate;
return makeObserverEvent({ seq: 1, channelId: "chan-1" });
},
() => stale,
);

// The channel is switched away (or the panel unmounts) while decrypt is in
// flight: the caller's gate now reads stale.
stale = true;
releaseDecrypt();
await ingest;

assert.deepEqual(
_testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-1"),
[],
"a page that went stale mid-decrypt must commit nothing",
);
});

/**
* The complement: a page whose gate stays current commits every staged
* frame. Proves the gate is not simply dropping all writes — the same
* multi-frame page that would be dropped when stale lands whole when live.
*/
it("test_page_staying_current_commits_the_whole_page", async () => {
const events = [
makeObserverEvent({ seq: 1, timestamp: "2026-01-01T00:00:01.000Z" }),
makeObserverEvent({ seq: 2, timestamp: "2026-01-01T00:00:02.000Z" }),
];
let idx = 0;

await ingestArchivedObserverEvents(
[makeRawEvent(), makeRawEvent({ id: "f".repeat(64) })],
() => Promise.resolve(events[idx++]),
() => false,
);

const archived = _testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-1");
assert.equal(
archived.length,
2,
"a current page must commit all its staged frames",
);
assert.deepEqual(
archived.map((event) => event.seq).sort(),
[1, 2],
"both frames of the current page are present",
);
});

/**
* The gate is evaluated ONCE, before the first append, against the state at
* commit time — not per frame. A page that was current when it started
* decrypting but is evaluated as stale at commit is dropped WHOLE: the store
* can never hold a torn half-page (frame 1 committed, frame 2 dropped),
* which would leave a resurrected channel with a partial timeline.
*/
it("test_stale_gate_drops_the_whole_page_never_a_partial", async () => {
let released = 0;
const gates = [0, 1].map(() => {
let release;
const promise = new Promise((resolve) => {
release = resolve;
});
return { promise, release };
});
let stale = false;

const ingest = ingestArchivedObserverEvents(
[makeRawEvent(), makeRawEvent({ id: "f".repeat(64) })],
async () => {
const gate = gates[released++];
await gate.promise;
return makeObserverEvent({ seq: released, channelId: "chan-1" });
},
() => stale,
);

// Release the first frame's decrypt and drain microtasks until the ingest
// loop has fully consumed frame 1 (staged it) and re-entered _decryptFn for
// frame 2 — released reaches 2 only after frame 1's decrypt resolved and the
// loop resumed. This is the discriminating setup: frame 1 was current at the
// moment it was decrypted and staged. A torn design that commits frames per
// their decrypt-time currentness would keep frame 1.
gates[0].release();
while (released < 2) {
await Promise.resolve();
}

// The switch lands now — after frame 1 is staged-while-current, before
// frame 2 finishes. The single phase-2 gate is evaluated after ALL decrypts,
// so the whole page — including the frame that was current when staged —
// must be dropped.
stale = true;
gates[1].release();
await ingest;

assert.deepEqual(
_testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-1"),
[],
"no torn half-page: a stale gate at commit drops every frame, including ones decrypted while the channel was still current",
);
});
});
148 changes: 148 additions & 0 deletions desktop/src/features/agents/lib/observerArchiveEviction.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Unit tests for selectArchiveEvictionKeys — the pure LRU-with-pins policy that
* bounds the channel-scoped observer archive.
*
* The policy evicts at channel granularity (all agent entries for a channel go
* together) while respecting pins (a pinned channel is never evicted). These
* tests exercise the selection logic directly, with no React/Tauri runtime.
*/

import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { selectArchiveEvictionKeys } from "./observerArchiveEviction.ts";

/** Build one archive entry meta. */
function meta(agent, channelId, accessSeq) {
return { key: `${agent}:${channelId}`, channelId, accessSeq };
}

const NO_PINS = new Set();

describe("selectArchiveEvictionKeys", () => {
it("test_empty_entries_returns_no_eviction", () => {
assert.deepEqual(selectArchiveEvictionKeys([], NO_PINS, 8), []);
});

it("test_channel_count_below_cap_returns_no_eviction", () => {
const entries = [
meta("agentA", "chan-1", 1),
meta("agentA", "chan-2", 2),
meta("agentA", "chan-3", 3),
];
assert.deepEqual(selectArchiveEvictionKeys(entries, NO_PINS, 8), []);
});

it("test_channel_count_exactly_at_cap_returns_no_eviction", () => {
const entries = Array.from({ length: 8 }, (_, i) =>
meta("agentA", `chan-${i}`, i),
);
assert.deepEqual(selectArchiveEvictionKeys(entries, NO_PINS, 8), []);
});

it("test_over_cap_evicts_least_recently_accessed_channels", () => {
// 10 channels, cap 8 → evict the 2 lowest-recency (chan-0, chan-1).
const entries = Array.from({ length: 10 }, (_, i) =>
meta("agentA", `chan-${i}`, i),
);
const evicted = selectArchiveEvictionKeys(entries, NO_PINS, 8);
assert.deepEqual(evicted.sort(), ["agentA:chan-0", "agentA:chan-1"].sort());
});

it("test_channel_recency_is_max_access_across_its_agent_entries", () => {
// chan-old has two agent entries; its most-recent access (seq 100) keeps it
// alive even though its other entry (seq 0) is the global minimum.
const entries = [
meta("agentA", "chan-old", 0),
meta("agentB", "chan-old", 100),
meta("agentA", "chan-1", 1),
meta("agentA", "chan-2", 2),
];
// cap 2 → keep the 2 most-recent channels (chan-old@100, chan-2@2),
// evict chan-1@1.
const evicted = selectArchiveEvictionKeys(entries, NO_PINS, 2);
assert.deepEqual(evicted, ["agentA:chan-1"]);
});

it("test_evicting_a_channel_removes_all_its_agent_entries", () => {
// chan-evict has 3 agent entries; all three keys must be returned together.
const entries = [
meta("agentA", "chan-evict", 0),
meta("agentB", "chan-evict", 0),
meta("agentC", "chan-evict", 0),
meta("agentA", "chan-keep", 9),
];
const evicted = selectArchiveEvictionKeys(entries, NO_PINS, 1);
assert.deepEqual(
evicted.sort(),
["agentA:chan-evict", "agentB:chan-evict", "agentC:chan-evict"].sort(),
);
});

it("test_pinned_channel_is_never_evicted_even_when_least_recent", () => {
// chan-pinned is the least-recently accessed but pinned → must survive;
// chan-1 (next lowest) is evicted instead.
const entries = [
meta("agentA", "chan-pinned", 0),
meta("agentA", "chan-1", 1),
meta("agentA", "chan-2", 2),
meta("agentA", "chan-3", 3),
];
const evicted = selectArchiveEvictionKeys(
entries,
new Set(["chan-pinned"]),
3,
);
assert.deepEqual(evicted, ["agentA:chan-1"]);
});

it("test_pins_exceeding_cap_evict_all_unpinned_but_keep_all_pinned", () => {
// 3 pinned + 2 unpinned, cap 2. Pins alone exceed the cap, so the unpinned
// budget is 0 → both unpinned channels evicted, all pinned retained.
const entries = [
meta("agentA", "pin-1", 10),
meta("agentA", "pin-2", 11),
meta("agentA", "pin-3", 12),
meta("agentA", "free-1", 1),
meta("agentA", "free-2", 2),
];
const evicted = selectArchiveEvictionKeys(
entries,
new Set(["pin-1", "pin-2", "pin-3"]),
2,
);
assert.deepEqual(evicted.sort(), ["agentA:free-1", "agentA:free-2"].sort());
});

it("test_pinned_channels_count_against_the_cap_for_unpinned_budget", () => {
// 1 pinned + 3 unpinned, cap 2 → unpinned budget = 2 - 1 = 1. Keep the
// single most-recent unpinned channel (free-3), evict free-1 and free-2.
const entries = [
meta("agentA", "pin-1", 5),
meta("agentA", "free-1", 1),
meta("agentA", "free-2", 2),
meta("agentA", "free-3", 3),
];
const evicted = selectArchiveEvictionKeys(entries, new Set(["pin-1"]), 2);
assert.deepEqual(evicted.sort(), ["agentA:free-1", "agentA:free-2"].sort());
});

it("test_tie_broken_deterministically_by_channel_id", () => {
// Two channels share recency 1 at the eviction boundary. The keep-sort
// breaks ties by ascending channelId, so "chan-a" is retained and the
// higher "chan-b" is evicted — deterministic regardless of input order.
const entries = [
meta("agentA", "chan-b", 1),
meta("agentA", "chan-a", 1),
meta("agentA", "chan-keep", 9),
];
const evicted = selectArchiveEvictionKeys(entries, NO_PINS, 2);
assert.deepEqual(evicted, ["agentA:chan-b"]);
});

it("test_zero_cap_evicts_all_unpinned_channels", () => {
const entries = [meta("agentA", "chan-1", 1), meta("agentA", "chan-2", 2)];
const evicted = selectArchiveEvictionKeys(entries, NO_PINS, 0);
assert.deepEqual(evicted.sort(), ["agentA:chan-1", "agentA:chan-2"].sort());
});
});
Loading
Loading