Skip to content

Commit fb07b2d

Browse files
fix(memory): gate reads on configured auth, lock mutations, use shared routing authority
Memory reads (status/core surfaces, routing snapshot, lane statuses) now wait for configured authentication instead of a nonempty gateway URL, show honest waiting copy, and invalidate in-flight requests when the auth boundary changes. Runtime-default save, lane-policy save, and the two source syncs are protected by synchronous same-tick locks. The duplicate full-routing write path is removed: lane policy saves flow through the shared PR #113 People & Routing controller (fresh load, clone upsert, locked restore) and refuse while Front Desk holds unsaved routing edits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6d6dac3 commit fb07b2d

4 files changed

Lines changed: 322 additions & 27 deletions

File tree

apps/mission-control/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,8 @@ export default function App() {
659659
agents,
660660
enabled: memoryHubEnabled,
661661
preferredAgentId: assistantController.selectedAgentId,
662+
tokenConfigured,
663+
peopleRouting: peopleRoutingController,
662664
setNotice,
663665
});
664666
const connectorsController = useConnectorsController({

apps/mission-control/src/features/memory/MemoryPage.test.tsx

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import type {
1313
RuntimeConfigResponse,
1414
RuntimeRoutingConfigResponse,
1515
} from "../../types";
16+
import {
17+
usePeopleRoutingController,
18+
type PeopleRoutingController,
19+
} from "../peopleRouting/usePeopleRoutingController";
1620
import { MemoryPage } from "./MemoryPage";
1721
import { useMemoryController } from "./useMemoryController";
1822

@@ -483,6 +487,7 @@ function installDefaultMocks() {
483487
let root: Root | null = null;
484488
let container: HTMLDivElement;
485489
let latestController: ReturnType<typeof useMemoryController> | null = null;
490+
let latestPeopleRouting: PeopleRoutingController | null = null;
486491
const setNotice = vi.fn() as unknown as NotifyFn & ReturnType<typeof vi.fn>;
487492
const onOpenAssistant = vi.fn();
488493

@@ -499,6 +504,7 @@ afterEach(async () => {
499504
await act(async () => root?.unmount());
500505
root = null;
501506
latestController = null;
507+
latestPeopleRouting = null;
502508
container.remove();
503509
});
504510

@@ -507,22 +513,34 @@ function Harness({
507513
agents,
508514
enabled = true,
509515
preferredAgentId = null,
516+
tokenConfigured = true,
510517
}: {
511518
settings: RuntimeConnectionSettings;
512519
agents: Agent[];
513520
enabled?: boolean;
514521
preferredAgentId?: string | null;
522+
tokenConfigured?: boolean;
515523
}) {
524+
const peopleRouting = usePeopleRoutingController({
525+
settings,
526+
tokenConfigured,
527+
agents,
528+
});
516529
const controller = useMemoryController({
517530
settings,
518531
agents,
519532
enabled,
520533
preferredAgentId,
534+
tokenConfigured,
535+
peopleRouting,
521536
setNotice,
522537
});
523538
useEffect(() => {
524539
latestController = controller;
525540
}, [controller]);
541+
useEffect(() => {
542+
latestPeopleRouting = peopleRouting;
543+
}, [peopleRouting]);
526544
return <MemoryPage controller={controller} onOpenAssistant={onOpenAssistant} />;
527545
}
528546

@@ -538,7 +556,8 @@ async function render(
538556
settings: RuntimeConnectionSettings = SETTINGS,
539557
agents: Agent[] = AGENTS,
540558
enabled = true,
541-
preferredAgentId: string | null = null
559+
preferredAgentId: string | null = null,
560+
tokenConfigured = true
542561
) {
543562
await act(async () => {
544563
root ??= createRoot(container);
@@ -548,6 +567,7 @@ async function render(
548567
agents={agents}
549568
enabled={enabled}
550569
preferredAgentId={preferredAgentId}
570+
tokenConfigured={tokenConfigured}
551571
/>
552572
);
553573
});
@@ -1307,6 +1327,185 @@ describe("MemoryPage runtime defaults, lane policy, and sync mutations", () => {
13071327
});
13081328
});
13091329

1330+
describe("MemoryPage configured-auth gating", () => {
1331+
it("waits for configured authentication before reading anything", async () => {
1332+
await render(SETTINGS, AGENTS, true, null, false);
1333+
1334+
expect(apiMocks.getAgentMemoryStatus).not.toHaveBeenCalled();
1335+
expect(apiMocks.getRuntimeConfig).not.toHaveBeenCalled();
1336+
expect(apiMocks.getAgentMemoryLaneStatuses).not.toHaveBeenCalled();
1337+
expect(container.textContent).toContain("Waiting for the gateway connection.");
1338+
});
1339+
1340+
it("starts reading only when authentication becomes configured", async () => {
1341+
await render(SETTINGS, AGENTS, true, null, false);
1342+
expect(apiMocks.getAgentMemoryStatus).not.toHaveBeenCalled();
1343+
1344+
await render(SETTINGS, AGENTS, true, null, true);
1345+
1346+
expect(apiMocks.getAgentMemoryStatus).toHaveBeenCalledWith(SETTINGS, "lyra");
1347+
await clickButton("Library");
1348+
expect(container.textContent).toContain("Lyra fact one");
1349+
});
1350+
1351+
it("discards an in-flight read when authentication is torn down", async () => {
1352+
const slowStatus = deferred<AgentMemoryStatusResponse>();
1353+
apiMocks.getAgentMemoryStatus.mockImplementation(async () => slowStatus.promise);
1354+
await render(SETTINGS, AGENTS, true, null, true);
1355+
1356+
await render(SETTINGS, AGENTS, true, null, false);
1357+
slowStatus.resolve(makeStatus("lyra"));
1358+
await flush();
1359+
1360+
expect(container.textContent).toContain("Waiting for the gateway connection.");
1361+
expect(apiMocks.listAgentMemoryCards).not.toHaveBeenCalled();
1362+
});
1363+
1364+
it("refuses every mutation without configured authentication", async () => {
1365+
await render(SETTINGS, AGENTS, true, null, false);
1366+
1367+
expect(latestController).toBeTruthy();
1368+
let saved = true;
1369+
await act(async () => {
1370+
saved = await latestController!.saveRuntimeMemoryDefaults("mno_primary", []);
1371+
});
1372+
expect(saved).toBe(false);
1373+
let laneSaved = true;
1374+
await act(async () => {
1375+
laneSaved = await latestController!.saveLaneMemoryPolicy(
1376+
"local-operator",
1377+
"lyra",
1378+
"local_only"
1379+
);
1380+
});
1381+
expect(laneSaved).toBe(false);
1382+
let synced = true;
1383+
await act(async () => {
1384+
synced = await latestController!.syncRuntimeMemoryDefaults();
1385+
});
1386+
expect(synced).toBe(false);
1387+
let laneSynced = true;
1388+
await act(async () => {
1389+
laneSynced = await latestController!.syncLaneMemorySources(
1390+
"local-operator",
1391+
"lyra"
1392+
);
1393+
});
1394+
expect(laneSynced).toBe(false);
1395+
1396+
expect(apiMocks.updateRuntimeConfig).not.toHaveBeenCalled();
1397+
expect(apiMocks.syncMemorySources).not.toHaveBeenCalled();
1398+
});
1399+
});
1400+
1401+
describe("MemoryPage same-tick mutation locks", () => {
1402+
it("lets one synchronous lock own a same-tick double runtime-defaults save", async () => {
1403+
await render();
1404+
1405+
let results: boolean[] = [];
1406+
await act(async () => {
1407+
results = await Promise.all([
1408+
latestController!.saveRuntimeMemoryDefaults("mno_primary", ["a.md"]),
1409+
latestController!.saveRuntimeMemoryDefaults("mno_primary", ["a.md"]),
1410+
]);
1411+
});
1412+
await flush();
1413+
1414+
expect(apiMocks.updateRuntimeConfig).toHaveBeenCalledTimes(1);
1415+
expect(results.filter(Boolean)).toHaveLength(1);
1416+
});
1417+
1418+
it("lets one synchronous lock own a same-tick double lane-policy save", async () => {
1419+
await render();
1420+
apiMocks.updateRuntimeConfig.mockClear();
1421+
1422+
let results: boolean[] = [];
1423+
await act(async () => {
1424+
results = await Promise.all([
1425+
latestController!.saveLaneMemoryPolicy("local-operator", "lyra", "local_only"),
1426+
latestController!.saveLaneMemoryPolicy("local-operator", "lyra", "local_only"),
1427+
]);
1428+
});
1429+
await flush();
1430+
1431+
expect(apiMocks.updateRuntimeConfig).toHaveBeenCalledTimes(1);
1432+
expect(results.filter(Boolean)).toHaveLength(1);
1433+
});
1434+
1435+
it("lets one synchronous lock own a same-tick double runtime sync", async () => {
1436+
await render();
1437+
1438+
await act(async () => {
1439+
await Promise.all([
1440+
latestController!.syncRuntimeMemoryDefaults(),
1441+
latestController!.syncRuntimeMemoryDefaults(),
1442+
]);
1443+
});
1444+
await flush();
1445+
1446+
expect(apiMocks.syncMemorySources).toHaveBeenCalledTimes(1);
1447+
});
1448+
1449+
it("lets one synchronous lock own a same-tick double lane sync", async () => {
1450+
await render();
1451+
1452+
await act(async () => {
1453+
await Promise.all([
1454+
latestController!.syncLaneMemorySources("local-operator", "lyra"),
1455+
latestController!.syncLaneMemorySources("local-operator", "lyra"),
1456+
]);
1457+
});
1458+
await flush();
1459+
1460+
expect(apiMocks.syncMemorySources).toHaveBeenCalledTimes(1);
1461+
});
1462+
});
1463+
1464+
describe("MemoryPage shared People & Routing authority", () => {
1465+
it("lands the lane policy save inside the shared routing authority state", async () => {
1466+
await render();
1467+
1468+
await setLabeledSelect("Memory mode", "local_only");
1469+
await clickButton("Save lane settings");
1470+
1471+
// The one shared People & Routing controller now holds the saved policy,
1472+
// proving the write flowed through it instead of a second writer.
1473+
const sharedPolicy = latestPeopleRouting?.routingConfig?.lane_memory_policies.find(
1474+
(policy) =>
1475+
policy.human_identity_id === "local-operator" &&
1476+
policy.assistant_agent_id === "lyra"
1477+
);
1478+
expect(sharedPolicy?.memory_mode).toBe("local_only");
1479+
});
1480+
1481+
it("refuses a lane policy save while Front Desk holds unsaved routing edits", async () => {
1482+
await render();
1483+
apiMocks.updateRuntimeConfig.mockClear();
1484+
1485+
await act(async () => {
1486+
latestPeopleRouting!.patchRoutingDraft((draft) => {
1487+
draft.human_identities[0]!.display_name = "Edited At The Front Desk";
1488+
});
1489+
});
1490+
await flush();
1491+
1492+
let saved = true;
1493+
await act(async () => {
1494+
saved = await latestController!.saveLaneMemoryPolicy(
1495+
"local-operator",
1496+
"lyra",
1497+
"local_only"
1498+
);
1499+
});
1500+
expect(saved).toBe(false);
1501+
expect(apiMocks.updateRuntimeConfig).not.toHaveBeenCalled();
1502+
expect(setNotice).toHaveBeenCalledWith({
1503+
tone: "error",
1504+
message: expect.stringContaining("unsaved"),
1505+
});
1506+
});
1507+
});
1508+
13101509
describe("MemoryPage refresh", () => {
13111510
it("reloads memory, routing, and lane statuses on refresh", async () => {
13121511
await render();

apps/mission-control/src/features/memory/MemoryPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ export function MemoryPage({ controller, onOpenAssistant }: MemoryPageProps) {
420420
return (
421421
<MemoryStatePanel
422422
title="Loading Memory"
423-
detail="Checking agent memory status..."
423+
detail={controller.availabilityMessage ?? "Checking agent memory status..."}
424424
/>
425425
);
426426
}

0 commit comments

Comments
 (0)