feat(desktop): surface managed-agent leadership and cooperative steal

Phase 3a emits a `leadership_status` observer frame per window-instance
every 5s and handles a `claim_leadership` control frame. The desktop had
no consumer. This adds the owner-side surface: a per-agent leader badge
and a per-instance "Make leader" steal action.

The frames already land in `eventsByAgent` via the owner-wide observer
subscription, so leadership is a cached derivation rather than a new
store. `getAgentLeadership` stays a stable map lookup (required by
`useSyncExternalStore`); the `leadershipByAgent` array is rebuilt only
when a leadership frame appends. Staleness stays out of the store — the
row filters against a 5s clock so a crashed leader's badge drops within
15s without a new frame. The steal ack is non-authoritative: the UI
converges off the stream, never optimistically flipping the badge.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-24 17:27:15 -04:00
co-authored by Will Pfleger
parent 2822fcf070
commit e8dc2ebf0f
7 changed files with 482 additions and 7 deletions
@@ -16,6 +16,11 @@ import {
createEmptyTranscriptState,
processTranscriptEvent,
} from "./ui/agentSessionTranscript";
import {
type InstanceLeadership,
LEADERSHIP_EVENT_KIND,
buildLeadership,
} from "./ui/leadershipHelpers";
const MAX_OBSERVER_EVENTS = 800;
@@ -32,11 +37,13 @@ const IDLE_SNAPSHOT: ObserverSnapshot = {
};
const EMPTY_TRANSCRIPT: TranscriptItem[] = [];
const EMPTY_LEADERSHIP: InstanceLeadership[] = [];
const listeners = new Set<() => void>();
const eventsByAgent = new Map<string, ObserverEvent[]>();
const transcriptByAgent = new Map<string, TranscriptState>();
const snapshotByAgent = new Map<string, ObserverSnapshot>();
const leadershipByAgent = new Map<string, InstanceLeadership[]>();
// Normalized pubkeys of agents we are actively managing. Only events whose
// "agent" tag matches an entry here will be decrypted (defense-in-depth).
@@ -109,6 +116,14 @@ function appendAgentEvent(agentPubkey: string, event: ObserverEvent) {
transcriptByAgent.set(key, buildTranscriptState(final));
}
// Rebuild the cached leadership array only when a leadership frame lands, so
// `getAgentLeadership` stays a stable map lookup (referential stability is
// required by `useSyncExternalStore`). The rebuild walks the trimmed window,
// so instances whose latest frame aged out are pruned automatically.
if (event.kind === LEADERSHIP_EVENT_KIND) {
leadershipByAgent.set(key, buildLeadership(final));
}
// Invalidate cached snapshot for this agent
invalidateSnapshot(key);
@@ -272,6 +287,20 @@ export function getAgentTranscript(
return state?.items ?? EMPTY_TRANSCRIPT;
}
export type { InstanceLeadership };
export function getAgentLeadership(
agentPubkey?: string | null,
enabled?: boolean,
): InstanceLeadership[] {
if (!enabled || !agentPubkey) {
return EMPTY_LEADERSHIP;
}
return (
leadershipByAgent.get(normalizePubkey(agentPubkey)) ?? EMPTY_LEADERSHIP
);
}
export function useManagedAgentObserverBridge(
agents: readonly Pick<ManagedAgent, "pubkey" | "status">[],
) {
@@ -308,6 +337,7 @@ export function resetAgentObserverStore() {
eventsByAgent.clear();
transcriptByAgent.clear();
snapshotByAgent.clear();
leadershipByAgent.clear();
knownAgentPubkeys.clear();
connectionState = "idle";
errorMessage = null;
@@ -4,6 +4,7 @@ import {
ChevronDown,
ChevronRight,
Clipboard,
Crown,
Ellipsis,
FileText,
Pencil,
@@ -33,13 +34,23 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { EditAgentDialog } from "./EditAgentDialog";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { ModelPicker } from "./ModelPicker";
import { truncatePubkey } from "./agentUi";
import { truncateInstanceId, truncatePubkey } from "./agentUi";
import { useAgentLeadership } from "./useObserverEvents";
import {
type InstanceLeadership,
filterStaleInstances,
selectFreshestLeader,
} from "./leadershipHelpers";
import { claimManagedAgentLeadership } from "@/shared/api/agentControl";
export function ManagedAgentRow({
agent,
@@ -115,6 +126,21 @@ export function ManagedAgentRow({
// crash. Generic exits stay verbatim so we don't lie about other failures.
const friendlyError = friendlyAgentLastError(agent.lastError);
// Leadership frames flow into the owner-wide observer store regardless of
// session-panel state, so this is enabled on row visibility (gated only on a
// pubkey). The 5s clock drives stale eviction without a new frame arriving —
// a crashed leader's last frame ages out and the badge drops within 15s.
const leadership = useAgentLeadership(true, agent.pubkey);
const leadershipNow = useNow(5000);
const liveInstances = React.useMemo(
() => filterStaleInstances(leadership, leadershipNow),
[leadership, leadershipNow],
);
const leaderInstanceId = React.useMemo(
() => selectFreshestLeader(liveInstances)?.instanceId ?? null,
[liveInstances],
);
return (
<div
className={cn(
@@ -146,6 +172,7 @@ export function ManagedAgentRow({
<StatusBlock
friendlyError={friendlyError}
isWorking={isWorking}
leaderInstanceId={leaderInstanceId}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
@@ -169,6 +196,7 @@ export function ManagedAgentRow({
<StatusBlock
friendlyError={friendlyError}
isWorking={isWorking}
leaderInstanceId={leaderInstanceId}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
processDetail={processDetail}
@@ -183,8 +211,10 @@ export function ManagedAgentRow({
<ModelPicker agent={agent} />
<AgentActionsMenu
agent={agent}
instances={liveInstances}
isActionPending={isActionPending}
isActive={isActive}
leaderInstanceId={leaderInstanceId}
onAddToChannel={onAddToChannel}
onDelete={onDelete}
onOpenLogs={(pubkey) => onSelectLogAgent(pubkey)}
@@ -335,6 +365,7 @@ function WorkingBadge({
function StatusBlock({
friendlyError,
isWorking,
leaderInstanceId,
presenceLoaded,
presenceStatus,
processDetail,
@@ -342,6 +373,7 @@ function StatusBlock({
}: {
friendlyError: ReturnType<typeof friendlyAgentLastError>;
isWorking: boolean;
leaderInstanceId: string | null;
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
processDetail: string;
@@ -352,12 +384,20 @@ function StatusBlock({
<p className="text-2xs font-semibold uppercase tracking-[0.16em] text-muted-foreground lg:hidden">
Status
</p>
<AgentStatusBadge
isWorking={isWorking}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
status={status}
/>
<div className="flex flex-wrap items-center gap-1.5">
<AgentStatusBadge
isWorking={isWorking}
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
status={status}
/>
{leaderInstanceId ? (
<Badge className="gap-1" variant="outline">
<Crown className="h-3 w-3" />
Leader
</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">{processDetail}</p>
{friendlyError ? (
<p
@@ -403,8 +443,10 @@ function RuntimeBlock({
function AgentActionsMenu({
agent,
instances,
isActionPending,
isActive,
leaderInstanceId,
onAddToChannel,
onDelete,
onOpenLogs,
@@ -413,8 +455,10 @@ function AgentActionsMenu({
onToggleStartOnAppLaunch,
}: {
agent: ManagedAgent;
instances: InstanceLeadership[];
isActionPending: boolean;
isActive: boolean;
leaderInstanceId: string | null;
onAddToChannel: (agent: ManagedAgent) => void;
onDelete: (pubkey: string) => void;
onOpenLogs: (pubkey: string) => void;
@@ -423,6 +467,8 @@ function AgentActionsMenu({
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
}) {
const [editOpen, setEditOpen] = React.useState(false);
// Nothing to steal unless at least two instances are racing.
const showLeadershipSubmenu = instances.length > 1;
return (
<>
@@ -522,6 +568,57 @@ function AgentActionsMenu({
</DropdownMenuItem>
) : null}
{showLeadershipSubmenu ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Crown className="h-4 w-4" />
Leadership
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{instances.map((instance) => {
const isLeader = instance.instanceId === leaderInstanceId;
return (
<DropdownMenuItem
disabled={isLeader}
key={instance.instanceId}
onClick={async () => {
try {
await claimManagedAgentLeadership(
agent.pubkey,
instance.instanceId,
);
toast.success(
`Leadership request sent to ${agent.name}.`,
);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: `Failed to send leadership request to ${agent.name}.`,
);
}
}}
>
{isLeader ? (
<Crown className="h-4 w-4" />
) : (
<span className="h-4 w-4" />
)}
<span className="font-mono">
{truncateInstanceId(instance.instanceId)}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{isLeader
? "Leader"
: `${formatElapsed(Date.now() - instance.lastSeen)} ago`}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
+12
View File
@@ -2,6 +2,18 @@ export function truncatePubkey(pubkey: string) {
return `${pubkey.slice(0, 8)}${pubkey.slice(-6)}`;
}
/**
* Election instance ids are `{pid}-{launch-nanos}` (minted per harness window).
* The pid prefix identifies the process; the long nanos tail only disambiguates
* relaunches, so keep the prefix and a short suffix. Short ids pass through.
*/
export function truncateInstanceId(instanceId: string) {
if (instanceId.length <= 16) {
return instanceId;
}
return `${instanceId.slice(0, 8)}${instanceId.slice(-4)}`;
}
function commandLooksLikePath(command: string) {
const trimmed = command.trim();
return (
@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
LEADERSHIP_STALE_MS,
buildLeadership,
filterStaleInstances,
parseLeadershipPayload,
selectFreshestLeader,
} from "./leadershipHelpers.ts";
// `Date.parse` consumes RFC3339 strings (what the harness emits via
// chrono::Utc::now().to_rfc3339()), so tests build timestamps the same way.
const iso = (epochMs) => new Date(epochMs).toISOString();
function leadershipEvent({ seq, instanceId, isLeader, at, kind, payload }) {
return {
seq,
timestamp: iso(at),
kind: kind ?? "leadership_status",
agentIndex: null,
channelId: null,
sessionId: null,
turnId: null,
payload: payload ?? { type: "leadership_status", instanceId, isLeader },
};
}
// --- parseLeadershipPayload ---
test("parseLeadershipPayload accepts a well-formed payload", () => {
const result = parseLeadershipPayload({
type: "leadership_status",
instanceId: "123-456",
isLeader: true,
});
assert.deepEqual(result, { instanceId: "123-456", isLeader: true });
});
test("parseLeadershipPayload rejects non-object payloads", () => {
for (const bad of [null, undefined, "string", 42, true, []]) {
// Arrays are objects but lack the required string/boolean fields, so they
// must also be rejected.
assert.equal(parseLeadershipPayload(bad), null);
}
});
test("parseLeadershipPayload rejects a missing or non-string instanceId", () => {
assert.equal(parseLeadershipPayload({ isLeader: true }), null);
assert.equal(parseLeadershipPayload({ instanceId: 5, isLeader: true }), null);
});
test("parseLeadershipPayload rejects a non-boolean isLeader", () => {
assert.equal(
parseLeadershipPayload({ instanceId: "a", isLeader: "yes" }),
null,
);
assert.equal(parseLeadershipPayload({ instanceId: "a" }), null);
});
// --- buildLeadership ---
test("buildLeadership keeps the latest frame per instanceId", () => {
const events = [
leadershipEvent({ seq: 1, instanceId: "A", isLeader: true, at: 1000 }),
leadershipEvent({ seq: 2, instanceId: "B", isLeader: false, at: 1500 }),
leadershipEvent({ seq: 3, instanceId: "A", isLeader: false, at: 2000 }),
];
const result = buildLeadership(events);
assert.equal(result.length, 2);
const a = result.find((i) => i.instanceId === "A");
assert.deepEqual(a, { instanceId: "A", isLeader: false, lastSeen: 2000 });
});
test("buildLeadership ignores non-leadership events", () => {
const events = [
leadershipEvent({ seq: 1, kind: "turn_started", payload: {}, at: 500 }),
leadershipEvent({ seq: 2, instanceId: "A", isLeader: true, at: 1000 }),
];
const result = buildLeadership(events);
assert.deepEqual(result, [
{ instanceId: "A", isLeader: true, lastSeen: 1000 },
]);
});
test("buildLeadership drops frames that fail the payload guard", () => {
const events = [
leadershipEvent({
seq: 1,
payload: { instanceId: 5, isLeader: true },
at: 1000,
}),
leadershipEvent({ seq: 2, instanceId: "A", isLeader: true, at: 1500 }),
];
const result = buildLeadership(events);
assert.deepEqual(result, [
{ instanceId: "A", isLeader: true, lastSeen: 1500 },
]);
});
test("buildLeadership drops frames with an unparseable timestamp", () => {
const bad = leadershipEvent({
seq: 1,
instanceId: "A",
isLeader: true,
at: 1000,
});
bad.timestamp = "not-a-date";
const good = leadershipEvent({
seq: 2,
instanceId: "B",
isLeader: false,
at: 1500,
});
const result = buildLeadership([bad, good]);
assert.deepEqual(result, [
{ instanceId: "B", isLeader: false, lastSeen: 1500 },
]);
});
test("buildLeadership returns an empty array for no leadership frames", () => {
assert.deepEqual(buildLeadership([]), []);
});
test("buildLeadership prunes a zombie instance whose frame aged out of the window", () => {
// Simulates the trimmed event window: the dead instance's frame is gone, so
// only the survivor's frame remains in the input. The reduction therefore
// never re-surfaces the zombie instanceId.
const events = [
leadershipEvent({
seq: 9,
instanceId: "survivor",
isLeader: true,
at: 5000,
}),
];
const result = buildLeadership(events);
assert.deepEqual(
result.map((i) => i.instanceId),
["survivor"],
);
});
// --- filterStaleInstances ---
test("filterStaleInstances drops instances past the stale threshold", () => {
const now = 100_000;
const fresh = { instanceId: "fresh", isLeader: true, lastSeen: now - 1000 };
const stale = {
instanceId: "stale",
isLeader: false,
lastSeen: now - LEADERSHIP_STALE_MS - 1,
};
const result = filterStaleInstances([fresh, stale], now);
assert.deepEqual(result, [fresh]);
});
test("filterStaleInstances keeps an instance exactly at the threshold", () => {
const now = 100_000;
const boundary = {
instanceId: "boundary",
isLeader: true,
lastSeen: now - LEADERSHIP_STALE_MS,
};
assert.deepEqual(filterStaleInstances([boundary], now), [boundary]);
});
test("filterStaleInstances treats a NaN lastSeen as stale", () => {
const now = 100_000;
const nan = { instanceId: "nan", isLeader: true, lastSeen: Number.NaN };
// now - NaN === NaN, and `NaN <= threshold` is false, so it is excluded.
assert.deepEqual(filterStaleInstances([nan], now), []);
});
// --- selectFreshestLeader ---
test("selectFreshestLeader returns null when no instance leads", () => {
const instances = [
{ instanceId: "A", isLeader: false, lastSeen: 1000 },
{ instanceId: "B", isLeader: false, lastSeen: 2000 },
];
assert.equal(selectFreshestLeader(instances), null);
});
test("selectFreshestLeader picks the freshest among multiple leaders", () => {
// The transient two-leader window after a crash: the dead leader's stale
// isLeader:true and the survivor's fresh one coexist. Freshest wins.
const dead = { instanceId: "dead", isLeader: true, lastSeen: 1000 };
const survivor = { instanceId: "survivor", isLeader: true, lastSeen: 9000 };
assert.equal(selectFreshestLeader([dead, survivor]), survivor);
});
test("selectFreshestLeader ignores non-leaders even if fresher", () => {
const leader = { instanceId: "leader", isLeader: true, lastSeen: 1000 };
const followerFresher = {
instanceId: "follower",
isLeader: false,
lastSeen: 9000,
};
assert.equal(selectFreshestLeader([leader, followerFresher]), leader);
});
test("selectFreshestLeader returns null for an empty list", () => {
assert.equal(selectFreshestLeader([]), null);
});
@@ -0,0 +1,102 @@
import type { ObserverEvent } from "./agentSessionTypes";
/** Per-window-instance leadership state derived from `leadership_status` frames. */
export type InstanceLeadership = {
instanceId: string;
isLeader: boolean;
lastSeen: number; // epoch ms — Date.parse(event.timestamp)
};
export const LEADERSHIP_EVENT_KIND = "leadership_status";
/**
* An instance is stale once it has missed 3 consecutive 5s emit ticks. A
* surviving instance re-emits within 5s, so 15s tolerates a single dropped
* relay frame without the badge flickering.
*/
export const LEADERSHIP_STALE_MS = 15_000;
/**
* Narrows the untrusted `unknown` payload of a `leadership_status` frame.
* Harness emits arbitrary JSON (`observer.rs`), so the contents are validated
* here at the boundary; malformed frames are dropped rather than producing
* `undefined`/`NaN` entries.
*/
export function parseLeadershipPayload(
payload: unknown,
): { instanceId: string; isLeader: boolean } | null {
if (typeof payload !== "object" || payload === null) {
return null;
}
const record = payload as Record<string, unknown>;
if (
typeof record.instanceId !== "string" ||
typeof record.isLeader !== "boolean"
) {
return null;
}
return { instanceId: record.instanceId, isLeader: record.isLeader };
}
/**
* Reduces an agent's observer events to the latest `leadership_status` frame
* per `instanceId`. `events` must be sorted ascending (the store keeps
* `eventsByAgent` sorted by `compareObserverEvents`), so a simple
* last-write-wins walk in iteration order is correct no comparator needed.
*
* Instances whose latest frame fell out of the trimmed event window are
* naturally absent, so this also prunes zombie instanceIds. Frames that fail
* the payload guard or carry an unparseable timestamp are dropped.
*/
export function buildLeadership(
events: readonly ObserverEvent[],
): InstanceLeadership[] {
const latestByInstance = new Map<string, InstanceLeadership>();
for (const event of events) {
if (event.kind !== LEADERSHIP_EVENT_KIND) {
continue;
}
const parsed = parseLeadershipPayload(event.payload);
if (!parsed) {
continue;
}
const lastSeen = Date.parse(event.timestamp);
if (Number.isNaN(lastSeen)) {
continue;
}
latestByInstance.set(parsed.instanceId, { ...parsed, lastSeen });
}
return [...latestByInstance.values()];
}
/** Drops instances whose last frame is older than the stale threshold. */
export function filterStaleInstances(
instances: readonly InstanceLeadership[],
now: number,
): InstanceLeadership[] {
return instances.filter(
(instance) => now - instance.lastSeen <= LEADERSHIP_STALE_MS,
);
}
/**
* The instance to surface as leader: the freshest (`max(lastSeen)`) among
* those reporting `isLeader`. After a leader window crashes, the survivor's
* `isLeader: true` and the dead window's stale `isLeader: true` coexist for up
* to one stale window; picking the freshest converges to the survivor without
* a "contested" UI state. Returns null when no instance currently leads.
*/
export function selectFreshestLeader(
instances: readonly InstanceLeadership[],
): InstanceLeadership | null {
let leader: InstanceLeadership | null = null;
for (const instance of instances) {
if (!instance.isLeader) {
continue;
}
if (!leader || instance.lastSeen > leader.lastSeen) {
leader = instance;
}
}
return leader;
}
@@ -2,10 +2,12 @@ import * as React from "react";
import {
ensureRelayObserverSubscription,
getAgentLeadership,
getAgentObserverSnapshot,
getAgentTranscript,
subscribeAgentObserverStore,
} from "@/features/agents/observerRelayStore";
import type { InstanceLeadership } from "@/features/agents/observerRelayStore";
import type { TranscriptItem } from "./agentSessionTypes";
// Stable subscribe reference shared by all useSyncExternalStore hooks.
@@ -45,3 +47,15 @@ export function useAgentTranscript(
return React.useSyncExternalStore(subscribeToStore, getSnapshot);
}
export function useAgentLeadership(
enabled: boolean,
agentPubkey?: string | null,
): InstanceLeadership[] {
const getSnapshot = React.useCallback(
() => getAgentLeadership(agentPubkey, enabled),
[agentPubkey, enabled],
);
return React.useSyncExternalStore(subscribeToStore, getSnapshot);
}
+15
View File
@@ -11,3 +11,18 @@ export async function cancelManagedAgentTurn(
});
return { status: "sent" };
}
// Best-effort cooperative-steal request. The harness gates its `control_result`
// ack on a successful lock acquire, so this ack only means "frame sent" — the
// `leadership_status` stream remains the source of truth for who actually
// leads. The UI must not optimistically flip on this return value.
export async function claimManagedAgentLeadership(
pubkey: string,
targetInstanceId: string,
): Promise<{ status: "sent" }> {
await sendAgentObserverControl(pubkey, {
type: "claim_leadership",
targetInstanceId,
});
return { status: "sent" };
}