fix: prod triage 2026-07-08 — MCP auth residue, gateway envelopes, verb-loop cap, A2A interjection, manual spawn UX (#334)

* fix(auth): pass agent UUID to CLI-arg MCP servers (optimal/docs/search)

The container token is HMAC-signed over the agent UUID (#314), but the
optimal/docs/search MCP servers received the slug as their CLI arg and
sent X-Agent-ID=<slug>, so every research/RAG/docs call 401ed with
signature mismatch under enforced auth. Pass the already-computed
agent_uuid in the three args lists instead.

* fix(gateway): include remediate in gateway.rejected audit details

Conventions-gate rejections carry the offending file:line listing only
in the envelope's remediate field, which the audit row dropped -- ops
logs showed just the violation count with no way to see what blocked.

* fix(gateway): return envelope on do/commit git failure

A GitError from the commit verb propagated to the generic middleware
handler, so agents got a raw error blob with no remediate/next. Catch
it and return an error envelope; 'no changes added to commit' with an
explicit files list now names the mismatch and the omit-files fallback.

* fix(agent-sdk): absolute rejection cap breaks slow-drip verb loops

The verb circuit breaker only counted rejections inside a 60s sliding
window, so an agent retrying i_am_done every 3-4 minutes looped for 30+
minutes without tripping it. Add a session-scoped cumulative per-(verb,
task) cap at 3x the windowed limit that trips regardless of pacing.

* feat(a2a): CEO chime-in interjects into the viewed conversation

Previously reply_as_ceo re-homed the message into a canonical CEO<->target
conversation with no panel surface, so a chime-in reported success but was
invisible and only opportunistically delivered. interject_as_ceo now inserts
the message into the conversation being viewed (from_agent=ceo, directed via
an @target content prefix), bumps that conversation's counters with the
unread ping keyed to the addressed participant, and both participants see it
in transcript and read_a2a.

* feat(panel): manual spawn carries task + message, surfaces refusals

The agent detail page spawned with no request body (task/message impossible),
the spawn button could double-fire (2.5ms double-POST seen live), and refusal
reasons never reached the UI: readiness refusals were generic 500s and the
already-running no-op looked like success. Detail page now uses
SpawnAgentDialog, a synchronous ref guard blocks re-entry, AgentReadinessError
maps to 409 with its reason shown, already_running is signalled and toasted,
and a task_id builds a task-aware prompt instructing the claim (task_id alone
never did), with the CEO's message appended as a note.

* test(panel): align a2a page test with the interjection footer copy

The chime-in rebuild changed the composer footer; the page-level test
asserting the old copy was outside the rebuild's scoped vitest run.

* fix(api): commit the request DB session before the response is sent

FastAPI unwinds yield-dependencies after the response bytes go out, so
get_db's post-yield commit raced the client's next request -- a verb
could return ok while its claim/status write was still uncommitted (the
e2e ok-without-effect flake family), and a failed commit was silently
lost behind an already-sent 200. DbCommitMiddleware (innermost, pure
ASGI) commits the session stashed by get_db_committed before forwarding
http.response.start; commit failure now surfaces as a 5xx. get_db is
untouched for its direct non-request callers.

* fix(db): invalidate, not rollback, the session on request cancellation

With the commit moved into the send path, the flow-verb timeout can
cancel mid-commit; rolling back then issues another command over an
asyncpg connection stranded mid-wire-protocol, and the poisoned
connection segfaults uvloop/asyncpg when a later checkout recycles it
(3/3 identical CI faulthandler dumps). On CancelledError discard the
connection via session.invalidate() -- SQLAlchemy's documented handling
for a timeout during commit -- and keep rollback for plain exceptions.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-08 10:41:02 +02:00
committed by GitHub
co-authored by Renn F
parent 60f571bc02
commit 312ec990dd
33 changed files with 1923 additions and 148 deletions
@@ -147,7 +147,7 @@ describe("A2APage", () => {
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
expect(
screen.getByText(
/direct A2A message from you to the selected participant/i,
/posts into this conversation — visible to both participants/i,
),
).toBeInTheDocument();
expect(screen.getByText("Live")).toBeInTheDocument();
@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
// Detail-page parity fix: the grid card's spawn affordance (SpawnAgentDialog,
// which collects task id + message) was already correct, but this page called
// spawnAgent.mutateAsync({ agentId }) directly from a bare button — no task,
// no message, no double-fire guard. Both bare buttons must now render the
// shared SpawnAgentDialog instead.
vi.mock("next/navigation", () => ({
useParams: () => ({ agentId: "fe-dev-2" }),
useRouter: () => ({ back: vi.fn() }),
}));
vi.mock("@/hooks/use-agents", () => ({
useAgentStatus: vi.fn(),
useAgentDefinition: vi.fn(() => ({ data: undefined })),
useStopAgent: vi.fn(() => ({ mutateAsync: vi.fn() })),
}));
vi.mock("@/components/agents", () => ({
AgentStatusCards: () => null,
ResolveWaitDialog: () => null,
AgentStreamViewer: () => null,
SpawnAgentDialog: ({
agentId,
agentName,
trigger,
}: {
agentId: string;
agentName: string;
trigger: React.ReactNode;
}) => (
<div
data-testid="spawn-agent-dialog"
data-agent-id={agentId}
data-agent-name={agentName}
>
{trigger}
</div>
),
}));
import { useAgentStatus } from "@/hooks/use-agents";
import AgentDetailPage from "../page";
describe("AgentDetailPage — spawn dialog parity", () => {
it("renders SpawnAgentDialog (not a bare button) in the error state", () => {
vi.mocked(useAgentStatus).mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("not found"),
refetch: vi.fn(),
} as unknown as ReturnType<typeof useAgentStatus>);
render(<AgentDetailPage />);
const dialog = screen.getByTestId("spawn-agent-dialog");
expect(dialog).toHaveAttribute("data-agent-id", "fe-dev-2");
expect(
screen.getByRole("button", { name: /Spawn Agent/i }),
).toBeInTheDocument();
});
it("renders SpawnAgentDialog in the header when the agent is not active", () => {
vi.mocked(useAgentStatus).mockReturnValue({
data: {
agent_id: "fe-dev-2",
state: "stopped",
task_id: null,
error_count: 0,
started_at: null,
waiting_for: null,
},
isLoading: false,
error: undefined,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useAgentStatus>);
render(<AgentDetailPage />);
const dialog = screen.getByTestId("spawn-agent-dialog");
expect(dialog).toHaveAttribute("data-agent-id", "fe-dev-2");
expect(screen.getByRole("button", { name: "Spawn" })).toBeInTheDocument();
// Active-state Stop buttons must not render alongside a down agent.
expect(
screen.queryByRole("button", { name: "Stop" }),
).not.toBeInTheDocument();
});
});
@@ -5,7 +5,6 @@ import { formatDistanceToNow } from "date-fns";
import {
useAgentStatus,
useStopAgent,
useSpawnAgent,
useAgentDefinition,
} from "@/hooks/use-agents";
import { Button } from "@/components/ui/button";
@@ -33,6 +32,7 @@ import {
AgentStatusCards,
ResolveWaitDialog,
AgentStreamViewer,
SpawnAgentDialog,
} from "@/components/agents";
// Role display labels
@@ -66,7 +66,6 @@ export default function AgentDetailPage() {
const { data: agent, isLoading, error, refetch } = useAgentStatus(agentId);
const { data: definition } = useAgentDefinition(agentId);
const stopAgent = useStopAgent();
const spawnAgent = useSpawnAgent();
// Get display values from definition or fallback
const displayName = definition?.name || agentId;
@@ -88,15 +87,6 @@ export default function AgentDetailPage() {
}
};
const handleSpawn = async () => {
try {
await spawnAgent.mutateAsync({ agentId });
toast.success("Agent spawned successfully");
} catch {
toast.error("Failed to spawn agent");
}
};
if (error) {
return (
<div className="space-y-6">
@@ -113,10 +103,16 @@ export default function AgentDetailPage() {
<p className="text-muted-foreground mt-2">
The agent may not be running or the ID is invalid.
</p>
<Button className="mt-4" onClick={handleSpawn}>
<Play className="h-4 w-4 mr-2" />
Spawn Agent
</Button>
<SpawnAgentDialog
agentId={agentId}
agentName={displayName}
trigger={
<Button className="mt-4">
<Play className="h-4 w-4 mr-2" />
Spawn Agent
</Button>
}
/>
</CardContent>
</Card>
</div>
@@ -178,10 +174,16 @@ export default function AgentDetailPage() {
</Button>
</>
) : (
<Button onClick={handleSpawn}>
<Play className="h-4 w-4 mr-2" />
Spawn
</Button>
<SpawnAgentDialog
agentId={agentId}
agentName={displayName}
trigger={
<Button>
<Play className="h-4 w-4 mr-2" />
Spawn
</Button>
}
/>
)}
</div>
</div>
@@ -119,14 +119,50 @@ describe("A2AReplyComposer", () => {
);
});
it("states the pairwise seam honestly in the helper text", () => {
it("states the interjection semantics honestly in the helper text", () => {
renderComposer();
// Guard the honesty note: the reply is a DIRECT CEO->participant message,
// not an injection into the watched transcript.
// Guard the honesty note: the message posts into THIS conversation,
// visible to both participants — not a re-homed CEO<->target DM.
expect(
screen.getByText(
/direct A2A message from you to the selected participant/i,
),
screen.getByText(/posts into this conversation/i),
).toBeInTheDocument();
expect(
screen.getByText(/visible to both participants/i),
).toBeInTheDocument();
});
});
describe("A2AReplyComposer when one participant is the CEO", () => {
beforeEach(() => {
mutate.mockReset();
});
function renderCeoComposer(lastSender: string | null = "be-dev-1") {
return render(
<A2AReplyComposer
conversationId="conv-ceo"
agentA="ceo"
agentB="be-dev-1"
lastSender={lastSender}
/>,
);
}
it("never offers the CEO itself as a recipient", () => {
const { container } = renderCeoComposer();
expect(container.querySelector('[data-value="ceo"]')).toBeNull();
expect(container.querySelector('[data-value="be-dev-1"]')).not.toBeNull();
});
it("always sends to the other participant, even when they spoke last as agent_a", () => {
renderCeoComposer("ceo");
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
target: { value: "Following up" },
});
fireEvent.click(screen.getByRole("button", { name: /send/i }));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ to_agent: "be-dev-1" }),
expect.anything(),
);
});
});
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { lastSenderOf, pickDefaultRecipient } from "../a2a-utils";
import {
lastSenderOf,
pickDefaultRecipient,
recipientOptions,
} from "../a2a-utils";
describe("lastSenderOf", () => {
it("returns null for an empty transcript", () => {
@@ -31,3 +35,20 @@ describe("pickDefaultRecipient", () => {
);
});
});
describe("recipientOptions", () => {
it("returns both participants when neither is the CEO", () => {
expect(recipientOptions("be-dev-1", "be-qa")).toEqual([
"be-dev-1",
"be-qa",
]);
});
it("excludes the CEO when it's agent_a", () => {
expect(recipientOptions("ceo", "be-dev-1")).toEqual(["be-dev-1"]);
});
it("excludes the CEO when it's agent_b", () => {
expect(recipientOptions("be-dev-1", "ceo")).toEqual(["be-dev-1"]);
});
});
@@ -15,7 +15,7 @@ import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { getErrorMessage } from "@/lib/api/client";
import { useReplyAsCeo } from "@/hooks/use-a2a-live";
import { pickDefaultRecipient } from "./a2a-utils";
import { pickDefaultRecipient, recipientOptions } from "./a2a-utils";
interface A2AReplyComposerProps {
conversationId: string;
@@ -38,8 +38,14 @@ export function A2AReplyComposer({
const [chosenRecipient, setChosenRecipient] = useState<string | null>(null);
const reply = useReplyAsCeo();
// Excludes "ceo" from the options: in the CEO's own conversation with an
// agent, one of {agentA, agentB} is "ceo" itself, and it must never be a
// selectable/default reply target.
const options = recipientOptions(agentA, agentB);
const recipient =
chosenRecipient ?? pickDefaultRecipient(agentA, agentB, lastSender);
options.length === 1
? options[0]
: (chosenRecipient ?? pickDefaultRecipient(agentA, agentB, lastSender));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -86,7 +92,7 @@ export function A2AReplyComposer({
<SelectValue />
</SelectTrigger>
<SelectContent>
{[agentA, agentB].map((slug) => (
{options.map((slug) => (
<SelectItem key={slug} value={slug}>
{getAgentDisplayName(slug)}
</SelectItem>
@@ -104,9 +110,8 @@ export function A2AReplyComposer({
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Sends a direct A2A message from you to the selected participant. It
lands in your own conversation with that agent, not inside this
transcript.
Posts into this conversation visible to both participants, addressed
to whoever you pick above.
</p>
</form>
);
+14
View File
@@ -4,6 +4,10 @@
import type { A2AChatMessage } from "@/lib/api/a2a";
/** The human CEO's fixed slug — never a valid reply target (the CEO composes
* as itself, so it can't be its own recipient). */
export const CEO_SLUG = "ceo";
/**
* Slug of the sender of the chronologically latest message, or null when the
* transcript is empty. Sorts defensively — the API contract is oldest-first,
@@ -20,6 +24,16 @@ export function lastSenderOf(
return sorted[sorted.length - 1].from_agent;
}
/**
* Valid reply recipients for a conversation: both participants, minus the
* CEO itself when it's a party. A CEO<->agent conversation has exactly one
* possible target (the agent) — no picker ambiguity, and critically no way
* to select "ceo" and have the CEO reply to itself.
*/
export function recipientOptions(agentA: string, agentB: string): string[] {
return [agentA, agentB].filter((slug) => slug !== CEO_SLUG);
}
/**
* Default reply recipient: the participant who spoke last (the natural
* "answer them" target), falling back to agent_a when the transcript is empty
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
// Production-triage bug: a manual spawn POSTed TWICE 2.5ms apart, both
// rejected "Agent already running" with no visible reason. Covers the fix:
// a synchronous re-entrancy guard against the double-fire, an
// already_running-aware toast, and the real backend refusal message
// reaching the CEO instead of a generic "Failed to spawn agent".
const { mutateAsync, toastSuccess, toastError, toastInfo } = vi.hoisted(() => ({
mutateAsync: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
toastInfo: vi.fn(),
}));
vi.mock("@/hooks/use-agents", () => ({
useSpawnAgent: () => ({ mutateAsync, isPending: false }),
}));
vi.mock("sonner", () => ({
toast: { success: toastSuccess, error: toastError, info: toastInfo },
}));
// client.ts registers axios interceptors that pull in the rate-limit store
// at import time; stub it so importing the real getErrorMessage is side-effect
// free (mirrors lib/__tests__/client.test.ts).
vi.mock("@/store/rate-limit-store", () => ({
useRateLimitStore: { getState: vi.fn(() => ({ hitRateLimit: vi.fn() })) },
}));
import { SpawnAgentDialog } from "../spawn-agent-dialog";
function openDialog() {
render(
<SpawnAgentDialog
agentId="fe-dev-2"
agentName="fe-dev-2"
trigger={<button type="button">Open Spawn</button>}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Open Spawn" }));
}
function submitButton() {
return screen.getByRole("button", { name: /Spawn Agent/i });
}
describe("SpawnAgentDialog", () => {
beforeEach(() => {
mutateAsync.mockReset();
toastSuccess.mockReset();
toastError.mockReset();
toastInfo.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("submits task id and initial prompt from the form", async () => {
mutateAsync.mockResolvedValue({ already_running: false });
openDialog();
fireEvent.change(screen.getByLabelText(/Task ID/i), {
target: { value: "task-123" },
});
fireEvent.change(screen.getByLabelText(/Initial Prompt/i), {
target: { value: "go fix it" },
});
fireEvent.click(submitButton());
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
expect(mutateAsync).toHaveBeenCalledWith({
agentId: "fe-dev-2",
request: { task_id: "task-123", initial_prompt: "go fix it" },
});
expect(toastSuccess).toHaveBeenCalled();
});
it("shows a distinct toast when the spawn was skipped as already-running", async () => {
mutateAsync.mockResolvedValue({ already_running: true });
openDialog();
fireEvent.click(submitButton());
await waitFor(() => expect(toastInfo).toHaveBeenCalledTimes(1));
expect(toastInfo.mock.calls[0][0]).toMatch(/already running/i);
expect(toastSuccess).not.toHaveBeenCalled();
});
it("surfaces the backend's actual refusal reason, not a generic message", async () => {
// Shape returned by axios on the 409 AgentReadinessError mapping.
const axiosLikeError = {
isAxiosError: true,
message: "Request failed with status code 409",
response: {
status: 409,
data: {
detail:
"spawn refused for fe-dev-2 (task=t1): state=awaiting_qa " +
"requires role in {'qa'} but agent fe-dev-2 is 'developer'",
},
},
};
mutateAsync.mockRejectedValue(axiosLikeError);
openDialog();
fireEvent.click(submitButton());
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1));
expect(toastError.mock.calls[0][0]).toContain("state=awaiting_qa");
expect(toastError.mock.calls[0][0]).not.toBe("Failed to spawn agent");
});
it("blocks a second submit fired before the first mutation settles", async () => {
let resolveSpawn: (v: { already_running: boolean }) => void = () => {};
mutateAsync.mockImplementation(
() =>
new Promise((resolve) => {
resolveSpawn = resolve;
}),
);
openDialog();
// Two synchronous clicks, mirroring the 2.5ms-apart double-fire from the
// production report — the second must never reach mutateAsync.
fireEvent.click(submitButton());
fireEvent.click(submitButton());
expect(mutateAsync).toHaveBeenCalledTimes(1);
resolveSpawn({ already_running: false });
await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1));
expect(mutateAsync).toHaveBeenCalledTimes(1);
});
});
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useSpawnAgent } from "@/hooks/use-agents";
import { getErrorMessage } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -32,21 +33,34 @@ export function SpawnAgentDialog({
const [taskId, setTaskId] = useState("");
const [initialPrompt, setInitialPrompt] = useState("");
const spawnAgent = useSpawnAgent();
// Synchronous re-entrancy guard: `spawnAgent.isPending` only flips on a
// re-render, which lags a fast double-click/double-fire by a tick or two —
// the guard below blocks a second call within the same synchronous burst
// regardless of render timing.
const submittingRef = useRef(false);
const handleSpawn = async () => {
if (submittingRef.current) return;
submittingRef.current = true;
try {
await spawnAgent.mutateAsync({
const result = await spawnAgent.mutateAsync({
agentId,
request: {
task_id: taskId || undefined,
initial_prompt: initialPrompt || undefined,
},
});
toast.success(`Agent ${agentName} spawned successfully`);
if (result.already_running) {
toast.info(`Agent ${agentName} already running — spawn skipped`);
} else {
toast.success(`Agent ${agentName} spawned successfully`);
}
setOpen(false);
resetForm();
} catch {
toast.error("Failed to spawn agent");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
submittingRef.current = false;
}
};
+3 -3
View File
@@ -88,9 +88,9 @@ export interface A2AChatMessage {
}
/**
* CEO reply payload. The backend sends a DIRECT CEO -> to_agent message (it
* lands in the CEO<->to_agent pairwise conversation), not an injection into
* the watched transcript.
* CEO interjection payload. The backend posts this INTO the conversation
* being viewed (readable by both participants), addressed to `to_agent` —
* not a re-homed CEO<->to_agent pairwise DM.
*/
export interface AdminReplyRequest {
to_agent: string;
+3
View File
@@ -420,6 +420,9 @@ export interface AgentStatusResponse {
error_count: number;
started_at: string | null;
waiting_for: string | null;
// Only set on the spawn response: true when the spawn was a no-op because
// the agent was already active (see SpawnAgentResponse on the backend).
already_running?: boolean;
}
// OrchestratorStatusResponse from backend