mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
fix(desktop): anchor active-turn badge to skew-corrected agent start (#1068)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -450,11 +450,14 @@ describe("activeAgentTurnsStore", () => {
|
|||||||
assert.equal(ref1, ref2, "should return cached array reference");
|
assert.equal(ref1, ref2, "should return cached array reference");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves a desktop-clock observedAt per channel", () => {
|
it("anchors a turn to its skew-corrected start, not the local insert clock", () => {
|
||||||
|
// The badge anchor must reflect the agent's true start translated into
|
||||||
|
// desktop time (startedAt + clock offset), so a turn whose event arrives
|
||||||
|
// with a stale timestamp does NOT reset to ~Date.now(). With a single
|
||||||
|
// event the offset is exactly Date.now() - startedAt, so the anchor lands
|
||||||
|
// on Date.now() here — the regression coverage for skew lives below.
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
syncAgentTurnsFromEvents(AGENT, [
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
// startedAt comes from the (stale) event timestamp; observedAt must
|
|
||||||
// instead anchor to the local clock at insert time.
|
|
||||||
makeEvent({
|
makeEvent({
|
||||||
seq: 1,
|
seq: 1,
|
||||||
turnId: "t1",
|
turnId: "t1",
|
||||||
@@ -466,69 +469,114 @@ describe("activeAgentTurnsStore", () => {
|
|||||||
const [summary] = getActiveTurnsForAgent(AGENT);
|
const [summary] = getActiveTurnsForAgent(AGENT);
|
||||||
assert.equal(summary.channelId, "c1");
|
assert.equal(summary.channelId, "c1");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
summary.observedAt >= before && summary.observedAt <= after,
|
summary.anchorAt >= before && summary.anchorAt <= after,
|
||||||
"observedAt must be the local clock at insert, not the event timestamp",
|
"anchorAt must be the skew-corrected start, here equal to the local clock",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapses two turns in one channel to the earliest observedAt", () => {
|
it("gives two turns with different startedAt different anchors (no lockstep)", () => {
|
||||||
|
// The lockstep bug: turns processed in the same JS tick were all anchored
|
||||||
|
// to one shared Date.now(), so their elapsed counters ticked in unison.
|
||||||
|
// Anchoring to startedAt + offset makes distinct agent-host starts produce
|
||||||
|
// distinct anchors. A single sampleClockOffset minimum is shared, so the
|
||||||
|
// anchor difference equals the startedAt difference.
|
||||||
syncAgentTurnsFromEvents(AGENT, [
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
|
makeEvent({
|
||||||
|
seq: 1,
|
||||||
|
turnId: "t1",
|
||||||
|
channelId: "c-early",
|
||||||
|
timestamp: "2024-01-01T00:00:00Z",
|
||||||
|
}),
|
||||||
|
makeEvent({
|
||||||
|
seq: 2,
|
||||||
|
turnId: "t2",
|
||||||
|
channelId: "c-late",
|
||||||
|
timestamp: "2024-01-01T00:05:00Z",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const firstObservedAt = getActiveTurnsForAgent(AGENT)[0].observedAt;
|
const byChannel = new Map(
|
||||||
|
getActiveTurnsForAgent(AGENT).map((s) => [s.channelId, s.anchorAt]),
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
byChannel.get("c-early"),
|
||||||
|
byChannel.get("c-late"),
|
||||||
|
"distinct startedAt must yield distinct anchors",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
byChannel.get("c-late") - byChannel.get("c-early"),
|
||||||
|
5 * 60_000,
|
||||||
|
"anchor spacing must equal the agent-host start spacing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Second turn in the same channel — its observedAt is >= the first
|
it("collapses two turns in one channel to the earliest anchor", () => {
|
||||||
// because the clock is monotonic, so the earliest must still win.
|
// Same agent-host start timestamp, distinct turns (seq bumped so the
|
||||||
|
// second passes the watermark). Identical timestamps mean the offset does
|
||||||
|
// not move, so the surfaced anchor is stable and the earliest wins.
|
||||||
syncAgentTurnsFromEvents(AGENT, [
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
makeEvent({ seq: 2, turnId: "t2", channelId: "c1" }),
|
makeEvent({
|
||||||
|
seq: 1,
|
||||||
|
turnId: "t1",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: "2024-01-01T00:00:00Z",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const firstAnchor = getActiveTurnsForAgent(AGENT)[0].anchorAt;
|
||||||
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
|
makeEvent({
|
||||||
|
seq: 2,
|
||||||
|
turnId: "t2",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: "2024-01-01T00:00:00Z",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const summaries = getActiveTurnsForAgent(AGENT);
|
const summaries = getActiveTurnsForAgent(AGENT);
|
||||||
assert.equal(summaries.length, 1, "same channel collapses to one entry");
|
assert.equal(summaries.length, 1, "same channel collapses to one entry");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
summaries[0].observedAt,
|
summaries[0].anchorAt,
|
||||||
firstObservedAt,
|
firstAnchor,
|
||||||
"earliest observedAt for the channel must be surfaced",
|
"earliest start's anchor must be surfaced",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("advances to the surviving turn's observedAt after the earliest ends", () => {
|
it("advances to the surviving turn's anchor after the earliest ends", () => {
|
||||||
// Two turns in one channel; the array must be rebuilt from the LIVE map
|
// Two turns in one channel; the array must be rebuilt from the LIVE map
|
||||||
// on every mutation, so ending the earliest-observed turn must surface
|
// on every mutation, so ending the earliest-started turn must surface the
|
||||||
// the survivor's observedAt — not a stale cached minimum.
|
// survivor's (later) anchor — not a stale cached minimum.
|
||||||
syncAgentTurnsFromEvents(AGENT, [
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
makeEvent({ seq: 1, turnId: "t-early", channelId: "c1" }),
|
makeEvent({
|
||||||
|
seq: 1,
|
||||||
|
turnId: "t-early",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: "2024-01-01T00:00:00Z",
|
||||||
|
}),
|
||||||
|
makeEvent({
|
||||||
|
seq: 2,
|
||||||
|
turnId: "t-later",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: "2024-01-01T00:02:00Z",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const tEarly = getActiveTurnsForAgent(AGENT)[0].observedAt;
|
const tEarly = getActiveTurnsForAgent(AGENT)[0].anchorAt;
|
||||||
|
|
||||||
// Force the second turn's observedAt strictly past the first so the
|
// End the earliest turn by its turnId. Reuse t-later's timestamp (seq
|
||||||
// advance is observable even when Date.now() would otherwise collide.
|
// bumped to pass the watermark) so the offset does not tighten and the
|
||||||
const spinUntil = Date.now() + 2;
|
// surviving anchor's advance is exactly the 2-minute start gap.
|
||||||
while (Date.now() < spinUntil) {
|
|
||||||
/* busy-wait one clock tick */
|
|
||||||
}
|
|
||||||
syncAgentTurnsFromEvents(AGENT, [
|
|
||||||
makeEvent({ seq: 2, turnId: "t-later", channelId: "c1" }),
|
|
||||||
]);
|
|
||||||
assert.equal(
|
|
||||||
getActiveTurnsForAgent(AGENT)[0].observedAt,
|
|
||||||
tEarly,
|
|
||||||
"earliest wins while both turns survive",
|
|
||||||
);
|
|
||||||
|
|
||||||
// End the earliest turn by its turnId.
|
|
||||||
syncAgentTurnsFromEvents(AGENT, [
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
makeEvent({
|
makeEvent({
|
||||||
seq: 3,
|
seq: 3,
|
||||||
kind: "turn_completed",
|
kind: "turn_completed",
|
||||||
turnId: "t-early",
|
turnId: "t-early",
|
||||||
channelId: "c1",
|
channelId: "c1",
|
||||||
|
timestamp: "2024-01-01T00:02:00Z",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const [survivor] = getActiveTurnsForAgent(AGENT);
|
const [survivor] = getActiveTurnsForAgent(AGENT);
|
||||||
assert.equal(survivor.channelId, "c1");
|
assert.equal(survivor.channelId, "c1");
|
||||||
assert.ok(
|
assert.equal(
|
||||||
survivor.observedAt > tEarly,
|
survivor.anchorAt - tEarly,
|
||||||
"surfaced observedAt must advance to the surviving turn after eviction",
|
2 * 60_000,
|
||||||
|
"surfaced anchor must advance to the surviving turn after eviction",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -642,6 +690,148 @@ describe("activeAgentTurnsStore", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("skew-corrected elapsed (real-time arrival)", () => {
|
||||||
|
// The clock offset estimate (running minimum of Date.now() - event time)
|
||||||
|
// is only meaningful when events arrive at distinct real times — exactly
|
||||||
|
// how the harness streams them. Faking Date lets us advance the desktop
|
||||||
|
// clock between events so an earlier event calibrates the offset before the
|
||||||
|
// measured turn starts. The fixed epoch is the desktop clock floor.
|
||||||
|
const EPOCH = Date.parse("2024-06-01T00:00:00Z");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.timers.enable({ apis: ["Date"], now: EPOCH });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.timers.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Agent-host clock = desktop clock + skew, as an ISO timestamp. */
|
||||||
|
const agentTs = (desktopMs, skew) =>
|
||||||
|
new Date(desktopMs + skew).toISOString();
|
||||||
|
|
||||||
|
it("shows a large elapsed for a turn that started well in the past", () => {
|
||||||
|
// Clocks synced (skew 0). An early event at the true present calibrates
|
||||||
|
// offset ≈ 0. Five true minutes pass. Then the desktop first observes a
|
||||||
|
// turn whose start timestamp is that 5-minutes-ago instant — the badge
|
||||||
|
// must read ~5 minutes, not reset to 0s on first sight.
|
||||||
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
|
makeEvent({
|
||||||
|
seq: 1,
|
||||||
|
kind: "turn_liveness",
|
||||||
|
turnId: "warm",
|
||||||
|
channelId: "c0",
|
||||||
|
timestamp: agentTs(EPOCH - 1_000, 0),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
mock.timers.tick(5 * 60_000); // 5 true minutes elapse
|
||||||
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
|
makeEvent({
|
||||||
|
seq: 2,
|
||||||
|
turnId: "t1",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: agentTs(EPOCH, 0), // started 5 minutes ago
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const summary = getActiveTurnsForAgent(AGENT).find(
|
||||||
|
(s) => s.channelId === "c1",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Date.now() - summary.anchorAt,
|
||||||
|
5 * 60_000 - 1_000,
|
||||||
|
"a 5-minute-old turn must show ~5 minutes elapsed, not 0s",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("corrects for agent-host clock skew so elapsed tracks true duration", () => {
|
||||||
|
// Agent host is 1 hour AHEAD of the desktop. A liveness event received at
|
||||||
|
// the true present (desktop EPOCH) carries a timestamp an hour in the
|
||||||
|
// future, calibrating offset ≈ -1h. The turn then starts 30s later in
|
||||||
|
// true time; its future-stamped start, corrected by the offset, anchors
|
||||||
|
// to the true start — without correction elapsed would be deeply negative.
|
||||||
|
const SKEW = 60 * 60_000;
|
||||||
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
|
makeEvent({
|
||||||
|
seq: 1,
|
||||||
|
kind: "turn_liveness",
|
||||||
|
turnId: "warm",
|
||||||
|
channelId: "c0",
|
||||||
|
timestamp: agentTs(EPOCH, SKEW),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
mock.timers.tick(30_000); // 30s of true time passes
|
||||||
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
|
makeEvent({
|
||||||
|
seq: 2,
|
||||||
|
turnId: "t1",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: agentTs(EPOCH + 30_000, SKEW),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const summary = getActiveTurnsForAgent(AGENT).find(
|
||||||
|
(s) => s.channelId === "c1",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Date.now() - summary.anchorAt,
|
||||||
|
0,
|
||||||
|
"a just-started turn under heavy skew must read ~0s, not a negative/huge value",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Let the turn run 45s; elapsed must track that true duration exactly.
|
||||||
|
mock.timers.tick(45_000);
|
||||||
|
const stillRunning = getActiveTurnsForAgent(AGENT).find(
|
||||||
|
(s) => s.channelId === "c1",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Date.now() - stillRunning.anchorAt,
|
||||||
|
45_000,
|
||||||
|
"skew-corrected elapsed must track true duration as the clock advances",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retroactively corrects a live turn's anchor when the offset tightens", () => {
|
||||||
|
// The design's load-bearing invariant: anchors are derived at READ time,
|
||||||
|
// so a later, tighter offset must shift an ALREADY-LIVE turn earlier.
|
||||||
|
// The turn first goes live under a loose offset (its start arrives with a
|
||||||
|
// +5s processing delay → offset +5000), then a delay-free liveness sample
|
||||||
|
// tightens the running minimum to 0. The live turn's surfaced anchor must
|
||||||
|
// move earlier by exactly that 5000ms delta. A regression that froze
|
||||||
|
// anchorAt at startTurn would leave the anchor at its loose value and
|
||||||
|
// fail this assertion.
|
||||||
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
|
makeEvent({
|
||||||
|
seq: 1,
|
||||||
|
turnId: "t1",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: agentTs(EPOCH - 5_000, 0), // observed 5s after its start
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const looseAnchor = getActiveTurnsForAgent(AGENT).find(
|
||||||
|
(s) => s.channelId === "c1",
|
||||||
|
).anchorAt;
|
||||||
|
|
||||||
|
mock.timers.tick(1_000); // 1s of true time so the liveness arrives later
|
||||||
|
syncAgentTurnsFromEvents(AGENT, [
|
||||||
|
makeEvent({
|
||||||
|
seq: 2,
|
||||||
|
kind: "turn_liveness",
|
||||||
|
turnId: "t1",
|
||||||
|
channelId: "c1",
|
||||||
|
timestamp: agentTs(EPOCH + 1_000, 0), // delay-free → offset tightens to 0
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const tightAnchor = getActiveTurnsForAgent(AGENT).find(
|
||||||
|
(s) => s.channelId === "c1",
|
||||||
|
).anchorAt;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
tightAnchor - looseAnchor,
|
||||||
|
-5_000,
|
||||||
|
"a tighter offset must shift the live turn's read-time anchor earlier by the tightening delta",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("formatElapsed", () => {
|
describe("formatElapsed", () => {
|
||||||
|
|||||||
@@ -25,20 +25,36 @@ type ActiveTurn = {
|
|||||||
turnId: string;
|
turnId: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
observedAt: number;
|
|
||||||
lastActivityAt: number;
|
lastActivityAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One working channel surfaced to the UI, anchored to the desktop clock. */
|
/** One working channel surfaced to the UI, anchored to the desktop clock. */
|
||||||
export type ActiveTurnSummary = {
|
export type ActiveTurnSummary = {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
observedAt: number;
|
anchorAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Module-level state: agentPubkey → turnId → ActiveTurn
|
// Module-level state: agentPubkey → turnId → ActiveTurn
|
||||||
const activeTurnsByAgent = new Map<string, Map<string, ActiveTurn>>();
|
const activeTurnsByAgent = new Map<string, Map<string, ActiveTurn>>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
// Per-agent clock offset: the desktop clock minus the agent-host clock, in
|
||||||
|
// milliseconds. Estimated as the running minimum of
|
||||||
|
// (Date.now() - Date.parse(event.timestamp)) across that agent's events. The
|
||||||
|
// minimum converges on true skew minus the smallest network/processing delay
|
||||||
|
// seen — a monotonically tightening estimate immune to per-event jitter. While
|
||||||
|
// true skew is constant or shrinking it is conservative: elapsed under-reports
|
||||||
|
// by the minimum delay and never inflates. The minimum never loosens, so under
|
||||||
|
// GROWING skew (an NTP step forward, or the host clock drifting further behind
|
||||||
|
// mid-session) the stored estimate goes stale-too-small and elapsed can over-
|
||||||
|
// report — bounded by how far the skew grows, sub-second over a session. A
|
||||||
|
// turn's badge anchor is startedAt + offset: the agent's own start, translated
|
||||||
|
// into desktop-clock terms. Anchors are derived at read time so a later, tighter
|
||||||
|
// offset retroactively corrects every live turn — distinct agent starts then
|
||||||
|
// yield distinct anchors (no lockstep) and a turn started long ago anchors into
|
||||||
|
// the past (large elapsed) instead of resetting to Date.now().
|
||||||
|
const clockOffsetByAgent = new Map<string, number>();
|
||||||
|
|
||||||
// Cached snapshots for useSyncExternalStore reference stability.
|
// Cached snapshots for useSyncExternalStore reference stability.
|
||||||
// Only regenerated when the underlying turn map for an agent actually changes.
|
// Only regenerated when the underlying turn map for an agent actually changes.
|
||||||
const cachedTurnSummaries = new Map<string, ActiveTurnSummary[]>();
|
const cachedTurnSummaries = new Map<string, ActiveTurnSummary[]>();
|
||||||
@@ -61,6 +77,23 @@ function notifyListeners() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refine this agent's clock-offset estimate from one observer event. Samples
|
||||||
|
* Date.now() - Date.parse(timestamp) and keeps the running minimum. When the
|
||||||
|
* minimum tightens, every live anchor for the agent shifts, so the cache is
|
||||||
|
* invalidated. Events with an unparseable timestamp contribute no sample.
|
||||||
|
* Returns true when the offset changed.
|
||||||
|
*/
|
||||||
|
function sampleClockOffset(agentKey: string, timestamp: string): boolean {
|
||||||
|
const sample = Date.now() - Date.parse(timestamp);
|
||||||
|
if (Number.isNaN(sample)) return false;
|
||||||
|
const prior = clockOffsetByAgent.get(agentKey);
|
||||||
|
if (prior !== undefined && sample >= prior) return false;
|
||||||
|
clockOffsetByAgent.set(agentKey, sample);
|
||||||
|
invalidateCache(agentKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function startTurn(
|
function startTurn(
|
||||||
agentPubkey: string,
|
agentPubkey: string,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
@@ -89,15 +122,12 @@ function startTurn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.parse(timestamp) || Date.now();
|
const startedAt = Date.parse(timestamp) || Date.now();
|
||||||
agentTurns.set(turnId, {
|
agentTurns.set(turnId, {
|
||||||
turnId,
|
turnId,
|
||||||
channelId,
|
channelId,
|
||||||
startedAt: now,
|
startedAt,
|
||||||
// Desktop-clock anchor for the live elapsed counter. Must NOT use startedAt
|
lastActivityAt: Date.now(),
|
||||||
// (agent-host clock) — ticking the desktop clock against it skews remote agents.
|
|
||||||
observedAt: Date.now(),
|
|
||||||
lastActivityAt: now,
|
|
||||||
});
|
});
|
||||||
invalidateCache(key);
|
invalidateCache(key);
|
||||||
}
|
}
|
||||||
@@ -179,6 +209,11 @@ function processEvent(agentPubkey: string, event: ObserverEvent) {
|
|||||||
}
|
}
|
||||||
lastProcessed.set(key, event);
|
lastProcessed.set(key, event);
|
||||||
|
|
||||||
|
// Refine the clock offset from every fresh event. A tighter offset shifts
|
||||||
|
// every live anchor for this agent, so a change must reach the UI even when
|
||||||
|
// the event itself surfaces no new turn.
|
||||||
|
const offsetChanged = sampleClockOffset(key, event.timestamp);
|
||||||
|
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case "turn_started":
|
case "turn_started":
|
||||||
if (event.channelId) {
|
if (event.channelId) {
|
||||||
@@ -189,6 +224,7 @@ function processEvent(agentPubkey: string, event: ObserverEvent) {
|
|||||||
event.timestamp,
|
event.timestamp,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "turn_completed":
|
case "turn_completed":
|
||||||
@@ -196,16 +232,20 @@ function processEvent(agentPubkey: string, event: ObserverEvent) {
|
|||||||
case "agent_panic":
|
case "agent_panic":
|
||||||
endTurn(agentPubkey, event.turnId ?? null, event.channelId ?? null);
|
endTurn(agentPubkey, event.turnId ?? null, event.channelId ?? null);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
break;
|
return;
|
||||||
case "acp_read":
|
case "acp_read":
|
||||||
case "acp_write":
|
case "acp_write":
|
||||||
// turn_liveness keeps a quiet-but-alive turn from being pruned; same
|
// turn_liveness keeps a quiet-but-alive turn from being pruned; same
|
||||||
// refresh-only path as stream activity — no surfaced summary change, so
|
// refresh-only path as stream activity — no surfaced summary change on its
|
||||||
// no notifyListeners().
|
// own, so it only notifies when the offset above actually moved.
|
||||||
case "turn_liveness":
|
case "turn_liveness":
|
||||||
recordActivity(agentPubkey, event.turnId ?? null);
|
recordActivity(agentPubkey, event.turnId ?? null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (offsetChanged) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensurePruneInterval() {
|
function ensurePruneInterval() {
|
||||||
@@ -237,7 +277,7 @@ export function subscribeActiveAgentTurns(listener: () => void) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the channels where the given agent has active turns, sorted by
|
* Returns the channels where the given agent has active turns, sorted by
|
||||||
* channelId, each anchored to the earliest `observedAt` for that channel.
|
* channelId, each anchored to the earliest `anchorAt` for that channel.
|
||||||
* The array reference is cached and stable until the turn map mutates — a
|
* The array reference is cached and stable until the turn map mutates — a
|
||||||
* requirement for `useSyncExternalStore`.
|
* requirement for `useSyncExternalStore`.
|
||||||
*/
|
*/
|
||||||
@@ -252,18 +292,24 @@ export function getActiveTurnsForAgent(
|
|||||||
const cached = cachedTurnSummaries.get(key);
|
const cached = cachedTurnSummaries.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
// Collapse multiple turns in one channel to the earliest observation —
|
const offset = clockOffsetByAgent.get(key) ?? 0;
|
||||||
// the badge should count from when the channel first went active.
|
|
||||||
|
// Collapse multiple turns in one channel to the earliest start — the badge
|
||||||
|
// should count from when the channel's oldest live turn began. Anchors are
|
||||||
|
// derived here (startedAt + offset) so the latest skew estimate applies.
|
||||||
const earliestByChannel = new Map<string, number>();
|
const earliestByChannel = new Map<string, number>();
|
||||||
for (const turn of agentTurns.values()) {
|
for (const turn of agentTurns.values()) {
|
||||||
const prior = earliestByChannel.get(turn.channelId);
|
const prior = earliestByChannel.get(turn.channelId);
|
||||||
if (prior === undefined || turn.observedAt < prior) {
|
if (prior === undefined || turn.startedAt < prior) {
|
||||||
earliestByChannel.set(turn.channelId, turn.observedAt);
|
earliestByChannel.set(turn.channelId, turn.startedAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = [...earliestByChannel.entries()]
|
const result = [...earliestByChannel.entries()]
|
||||||
.map(([channelId, observedAt]) => ({ channelId, observedAt }))
|
.map(([channelId, startedAt]) => ({
|
||||||
|
channelId,
|
||||||
|
anchorAt: startedAt + offset,
|
||||||
|
}))
|
||||||
.sort((a, b) => a.channelId.localeCompare(b.channelId));
|
.sort((a, b) => a.channelId.localeCompare(b.channelId));
|
||||||
cachedTurnSummaries.set(key, result);
|
cachedTurnSummaries.set(key, result);
|
||||||
return result;
|
return result;
|
||||||
@@ -286,7 +332,7 @@ export function syncAgentTurnsFromEvents(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook: returns the channels where the given agent is currently working, each
|
* Hook: returns the channels where the given agent is currently working, each
|
||||||
* with the desktop-clock `observedAt` to anchor a live elapsed counter.
|
* with the desktop-clock `anchorAt` to anchor a live elapsed counter.
|
||||||
* Re-renders when the set of channels changes — not when the clock ticks.
|
* Re-renders when the set of channels changes — not when the clock ticks.
|
||||||
*/
|
*/
|
||||||
export function useActiveAgentTurns(
|
export function useActiveAgentTurns(
|
||||||
@@ -324,6 +370,7 @@ export function useActiveAgentTurnsBridge(
|
|||||||
export function resetActiveAgentTurnsStore() {
|
export function resetActiveAgentTurnsStore() {
|
||||||
activeTurnsByAgent.clear();
|
activeTurnsByAgent.clear();
|
||||||
lastProcessed.clear();
|
lastProcessed.clear();
|
||||||
|
clockOffsetByAgent.clear();
|
||||||
cachedTurnSummaries.clear();
|
cachedTurnSummaries.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,10 +90,10 @@ export function ManagedAgentRow({
|
|||||||
const activeWorkingChannels = React.useMemo(
|
const activeWorkingChannels = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
activeTurns
|
activeTurns
|
||||||
.map(({ channelId, observedAt }) => ({
|
.map(({ channelId, anchorAt }) => ({
|
||||||
id: channelId,
|
id: channelId,
|
||||||
name: channelIdToName[channelId] ?? channelId,
|
name: channelIdToName[channelId] ?? channelId,
|
||||||
observedAt,
|
anchorAt,
|
||||||
}))
|
}))
|
||||||
.slice(0, 3),
|
.slice(0, 3),
|
||||||
[activeTurns, channelIdToName],
|
[activeTurns, channelIdToName],
|
||||||
@@ -222,7 +222,7 @@ function AgentSummary({
|
|||||||
personaLabel,
|
personaLabel,
|
||||||
presenceStatus,
|
presenceStatus,
|
||||||
}: {
|
}: {
|
||||||
activeWorkingChannels: { id: string; name: string; observedAt: number }[];
|
activeWorkingChannels: { id: string; name: string; anchorAt: number }[];
|
||||||
agent: ManagedAgent;
|
agent: ManagedAgent;
|
||||||
channelNames: { id: string; name: string }[];
|
channelNames: { id: string; name: string }[];
|
||||||
isExpandable: boolean;
|
isExpandable: boolean;
|
||||||
@@ -291,7 +291,7 @@ function AgentSummary({
|
|||||||
key={`working-${channel.id}`}
|
key={`working-${channel.id}`}
|
||||||
channelId={channel.id}
|
channelId={channel.id}
|
||||||
name={channel.name}
|
name={channel.name}
|
||||||
observedAt={channel.observedAt}
|
anchorAt={channel.anchorAt}
|
||||||
onNavigate={goChannel}
|
onNavigate={goChannel}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -306,12 +306,12 @@ function AgentSummary({
|
|||||||
function WorkingBadge({
|
function WorkingBadge({
|
||||||
channelId,
|
channelId,
|
||||||
name,
|
name,
|
||||||
observedAt,
|
anchorAt,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
name: string;
|
name: string;
|
||||||
observedAt: number;
|
anchorAt: number;
|
||||||
onNavigate: (channelId: string) => void;
|
onNavigate: (channelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
// The 1s tick lives here, at the leaf, so only visible working badges
|
// The 1s tick lives here, at the leaf, so only visible working badges
|
||||||
@@ -327,7 +327,7 @@ function WorkingBadge({
|
|||||||
onNavigate(channelId);
|
onNavigate(channelId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Working in #{name} · {formatElapsed(now - observedAt)}
|
Working in #{name} · {formatElapsed(now - anchorAt)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,12 +173,12 @@ export function ProfileSummaryView({
|
|||||||
|
|
||||||
{activeTurns.length > 0 ? (
|
{activeTurns.length > 0 ? (
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
{activeTurns.map(({ channelId, observedAt }) => (
|
{activeTurns.map(({ channelId, anchorAt }) => (
|
||||||
<ProfileWorkingBadge
|
<ProfileWorkingBadge
|
||||||
key={channelId}
|
key={channelId}
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
name={channelIdToName[channelId] ?? channelId}
|
name={channelIdToName[channelId] ?? channelId}
|
||||||
observedAt={observedAt}
|
anchorAt={anchorAt}
|
||||||
onNavigate={goChannel}
|
onNavigate={goChannel}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -238,12 +238,12 @@ export function ProfileSummaryView({
|
|||||||
function ProfileWorkingBadge({
|
function ProfileWorkingBadge({
|
||||||
channelId,
|
channelId,
|
||||||
name,
|
name,
|
||||||
observedAt,
|
anchorAt,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
name: string;
|
name: string;
|
||||||
observedAt: number;
|
anchorAt: number;
|
||||||
onNavigate: (channelId: string) => void;
|
onNavigate: (channelId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const now = useNow(1000);
|
const now = useNow(1000);
|
||||||
@@ -254,7 +254,7 @@ function ProfileWorkingBadge({
|
|||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => onNavigate(channelId)}
|
onClick={() => onNavigate(channelId)}
|
||||||
>
|
>
|
||||||
Working in #{name} · {formatElapsed(now - observedAt)}
|
Working in #{name} · {formatElapsed(now - anchorAt)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,11 +264,11 @@ export function UserProfilePopover({
|
|||||||
|
|
||||||
{activeTurns.length > 0 ? (
|
{activeTurns.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{activeTurns.map(({ channelId, observedAt }) => (
|
{activeTurns.map(({ channelId, anchorAt }) => (
|
||||||
<PopoverWorkingBadge
|
<PopoverWorkingBadge
|
||||||
key={channelId}
|
key={channelId}
|
||||||
name={channelIdToName[channelId] ?? channelId}
|
name={channelIdToName[channelId] ?? channelId}
|
||||||
observedAt={observedAt}
|
anchorAt={anchorAt}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -302,16 +302,16 @@ export function UserProfilePopover({
|
|||||||
|
|
||||||
function PopoverWorkingBadge({
|
function PopoverWorkingBadge({
|
||||||
name,
|
name,
|
||||||
observedAt,
|
anchorAt,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
observedAt: number;
|
anchorAt: number;
|
||||||
}) {
|
}) {
|
||||||
const now = useNow(1000);
|
const now = useNow(1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary motion-safe:animate-pulse">
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary motion-safe:animate-pulse">
|
||||||
Working in #{name} · {formatElapsed(now - observedAt)}
|
Working in #{name} · {formatElapsed(now - anchorAt)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user