Skip to content
Merged
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
20 changes: 16 additions & 4 deletions src/daemon/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,20 @@ export interface DispatcherHost {
audit(action: string, detail: string): void;
}

/** Terminal statuses — a member in one of these will never run again. */
const TERMINAL: ReadonlySet<string> = new Set(["done", "failed", "blocked"]);
/**
* Terminal statuses — a task in one of these will never run again.
*
* Exported because the barrier and the client-facing panel view must agree on
* it: the barrier joins when every member is terminal, and the UI renders
* "settled" from the same rule. They were two separate literals in two files,
* so adding a status would have left the sidebar quietly disagreeing with the
* barrier about whether a fan-out had finished.
*/
export const TERMINAL_TASK_STATUS: ReadonlySet<string> = new Set([
"done",
"failed",
"blocked",
]);

/** Statuses that mean "the worker's current turn is still in flight". */
const WORKER_ACTIVE: ReadonlySet<string> = new Set([
Expand Down Expand Up @@ -663,9 +675,9 @@ export class Dispatcher {
// one place: a non-terminal task has not completed, so it is not the
// barrier's business.
const self = members.find((m) => m.id === task.id);
if (!self || !TERMINAL.has(self.status)) return false;
if (!self || !TERMINAL_TASK_STATUS.has(self.status)) return false;

const pending = members.filter((m) => !TERMINAL.has(m.status));
const pending = members.filter((m) => !TERMINAL_TASK_STATUS.has(m.status));
if (pending.length > 0) {
this.#host.audit(
"dispatch.group_waiting",
Expand Down
8 changes: 1 addition & 7 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
import {
Dispatcher,
NonRetryableDispatchError,
TERMINAL_TASK_STATUS,
type DispatcherHost,
} from "./dispatch.js";
import { createPipelineManagerFromConfig } from "./pipeline/wiring.js";
Expand Down Expand Up @@ -200,13 +201,6 @@ function normalizeWorkdir(input: string): string | null {
*/
const PANEL_HISTORY_LIMIT = 5;

/** Task statuses that will never change again — mirrors the barrier's rule. */
const TERMINAL_TASK_STATUS: ReadonlySet<string> = new Set([
"done",
"failed",
"blocked",
]);

const RESUME_MAX_SESSIONS = 50;
const RESUME_DEADLINE_MS = 20_000;
/** Per-session transcript read budget on resume. Scrollback keeps at most
Expand Down
9 changes: 9 additions & 0 deletions src/daemon/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,15 @@ export class Store {
this.#db.exec(`
CREATE INDEX IF NOT EXISTS idx_dispatch_group
ON dispatch_tasks(group_id) WHERE group_id IS NOT NULL;
-- The client-facing panel poll (dispatchRecentGroups) runs every few
-- seconds while a collaboration is focused, and without this it could only
-- use the (account, project) index and then filter created_by row by row
-- over the tenant's ENTIRE task history — measured at 17.8ms per call over
-- 20k tasks, growing with history rather than with panel count. Partial on
-- group_id so it stays small: only grouped rows are ever polled this way.
CREATE INDEX IF NOT EXISTS idx_dispatch_panels
ON dispatch_tasks(account_id, project_id, created_by, created_at DESC)
WHERE group_id IS NOT NULL;
`);

// Pre-release single-row predecessor of provider_model_catalogs — never
Expand Down
82 changes: 82 additions & 0 deletions src/tests/dispatch-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ describe("migration — opening a database written before dispatch groups", () =
/** Strip the group columns + index, simulating a pre-panel database file. */
function downgrade(dbPath: string): void {
const db = new Database(dbPath);
// Both group indexes reference the columns below; SQLite refuses to drop a
// column an index depends on, so they go first. The REAL migration only ever
// ADDS, so this ordering is an artifact of simulating a downgrade.
db.exec("DROP INDEX IF EXISTS idx_dispatch_group");
db.exec("DROP INDEX IF EXISTS idx_dispatch_panels");
db.exec("ALTER TABLE dispatch_tasks DROP COLUMN group_ordinal");
db.exec("ALTER TABLE dispatch_tasks DROP COLUMN group_id");
db.close();
Expand Down Expand Up @@ -359,3 +363,81 @@ describe("migration — opening a database written before dispatch groups", () =
expect(members.map((m) => m.groupOrdinal)).toEqual([1, 2]);
});
});

// ── Index coverage for the polled panel query ───────────────────────────────

// `dispatchRecentGroups` backs a client poll that runs every few seconds while a
// collaboration is focused. Without a supporting index SQLite could only use the
// (account, project) index and then filter `created_by` row by row across the
// tenant's ENTIRE task history — measured at 17.8ms per call over 20k tasks, and
// growing with history rather than with panel count. Asserting the PLAN rather
// than a duration, because a timing assertion in CI is a flake generator.
describe("dispatchRecentGroups is index-covered", () => {
test("seeks on (account, project, created_by) instead of scanning history", () => {
const dbPath = join(tmp, "plan.db");
const s = new Store(dbPath);
// A realistic long-lived daemon: mostly ungrouped conductor tasks, a few panels.
for (let i = 0; i < 300; i++) {
s.dispatchEnqueue({
id: `bulk-${i}`, ...TENANT, kind: "send", shape: "ship",
targetSession: "t", prompt: "p", failureLimit: 2,
createdBy: "conductor:acc-a/proj-a", now: i,
});
}
s.dispatchEnqueueGroup(
[1, 2, 3].map((n) => ({
id: `grp-${n}`, ...TENANT, kind: "send" as const, shape: "scout" as const,
targetSession: `kid-${n}`, prompt: "review", failureLimit: 2,
createdBy: "orchestrator:goal-1", groupId: "g1", groupOrdinal: n, now: 9_000,
})),
);

const db = new Database(dbPath, { readonly: true });
const plan = (
db
.prepare(
`EXPLAIN QUERY PLAN
SELECT * FROM dispatch_tasks
WHERE account_id = ? AND project_id = ? AND created_by = ?
AND group_id IN (
SELECT group_id FROM dispatch_tasks
WHERE account_id = ? AND project_id = ? AND created_by = ?
AND group_id IS NOT NULL
GROUP BY group_id ORDER BY MAX(created_at) DESC LIMIT ?)
ORDER BY created_at DESC, group_ordinal ASC, id ASC`,
)
.all(
TENANT.accountId, TENANT.projectId, "orchestrator:goal-1",
TENANT.accountId, TENANT.projectId, "orchestrator:goal-1", 5,
) as Array<{ detail: string }>
).map((r) => r.detail);
db.close();

// Both the outer query and the group subquery must use it — the subquery is
// the one that would otherwise walk every task the tenant has ever queued.
const seeks = plan.filter((d) => d.includes("idx_dispatch_panels"));
expect(seeks.length).toBeGreaterThanOrEqual(2);
expect(plan.some((d) => /SCAN dispatch_tasks(?!.*USING)/.test(d))).toBe(false);

// ...and it still returns the right rows, in fan-out order.
const members = s.dispatchRecentGroups(
TENANT.accountId, TENANT.projectId, "orchestrator:goal-1", 5,
);
expect(members.map((m) => m.groupOrdinal)).toEqual([1, 2, 3]);
expect(members.every((m) => m.createdBy === "orchestrator:goal-1")).toBe(true);
});

test("never returns another dispatcher's groups", () => {
const s = new Store(join(tmp, "scoped.db"));
s.dispatchEnqueueGroup([
{ id: "a1", ...TENANT, kind: "send", shape: "scout", targetSession: "k", prompt: "p",
failureLimit: 2, createdBy: "orchestrator:goal-A", groupId: "gA", groupOrdinal: 1, now: 1 },
{ id: "b1", ...TENANT, kind: "send", shape: "scout", targetSession: "k", prompt: "p",
failureLimit: 2, createdBy: "orchestrator:goal-B", groupId: "gB", groupOrdinal: 1, now: 2 },
]);
const mine = s.dispatchRecentGroups(TENANT.accountId, TENANT.projectId, "orchestrator:goal-A");
expect(mine.map((m) => m.id)).toEqual(["a1"]);
// And a different tenant sees nothing, group id or not.
expect(s.dispatchRecentGroups("acc-x", "proj-x", "orchestrator:goal-A")).toEqual([]);
});
});
46 changes: 17 additions & 29 deletions web/src/components/SessionListPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ vi.mock("./AnalyticsPanel", () => ({ default: () => null }));
vi.mock("./NewSessionModal", () => ({ openNewSessionModal: vi.fn() }));
// Panel state is daemon-fed; drive it directly so the render is under test
// rather than the polling transport.
const livePanelMock = vi.hoisted(() => vi.fn<() => unknown>(() => null));
const liveProgressMock = vi.hoisted(() => vi.fn<() => unknown>(() => null));
const livePanelMemberMock = vi.hoisted(() => vi.fn<(id: string) => unknown>(() => null));
vi.mock("../state/panels", () => ({
fetchPanels: vi.fn(() => Promise.resolve()),
resetPanels: vi.fn(),
livePanel: livePanelMock,
liveProgress: liveProgressMock,
livePanelMember: livePanelMemberMock,
}));

Expand Down Expand Up @@ -215,7 +215,7 @@ describe("SessionListPane — live panel state", () => {
];

afterEach(() => {
livePanelMock.mockReturnValue(null);
liveProgressMock.mockReturnValue(null);
livePanelMemberMock.mockReturnValue(null);
});

Expand All @@ -226,20 +226,9 @@ describe("SessionListPane — live panel state", () => {
});

it("shows settled-of-total on the orchestrator while a panel runs", () => {
livePanelMock.mockReturnValue({
groupId: "g1",
createdAt: 0,
settled: 2,
joined: false,
members: [
{ sessionId: "p:review", ordinal: 1, status: "done" },
{ sessionId: "p:review-2", ordinal: 2, status: "blocked" },
{ sessionId: "p:search", ordinal: 3, status: "running" },
],
});
liveProgressMock.mockReturnValue({ settled: 2, total: 3, panels: 1 });
ingestSessionList(FLEET);
const { getByText, getByRole } = render(() => <SessionListPane />);

expect(getByText("2/3")).toBeTruthy();
const bar = getByRole("progressbar");
expect(bar.getAttribute("aria-valuenow")).toBe("2");
Expand All @@ -249,37 +238,36 @@ describe("SessionListPane — live panel state", () => {
it("counts SETTLED members, so a failed member does not stall the bar", () => {
// The barrier joins on all-terminal. Counting successes would leave a
// finished panel showing 2/3 forever — the exact confusion this removes.
livePanelMock.mockReturnValue({
groupId: "g1", createdAt: 0, settled: 3, joined: false,
members: [
{ sessionId: "p:review", ordinal: 1, status: "done" },
{ sessionId: "p:review-2", ordinal: 2, status: "failed" },
{ sessionId: "p:search", ordinal: 3, status: "blocked" },
],
});
liveProgressMock.mockReturnValue({ settled: 3, total: 3, panels: 1 });
ingestSessionList(FLEET);
const { getByText, getByTitle } = render(() => <SessionListPane />);
expect(getByText("3/3")).toBeTruthy();
expect(getByTitle(/including failures/)).toBeTruthy();
});

it("aggregates when TWO fan-outs are live at once", () => {
// Concurrent panels are legal — an orchestrator can start a second while the
// first resolves. Showing one panel's numbers understates the outstanding work.
liveProgressMock.mockReturnValue({ settled: 1, total: 5, panels: 2 });
ingestSessionList(FLEET);
const { getByText, getByTitle } = render(() => <SessionListPane />);
expect(getByText(/panels ×2/)).toBeTruthy();
expect(getByText("1/5")).toBeTruthy();
expect(getByTitle(/2 panels in flight/)).toBeTruthy();
});

it("badges each participating child with its position and state", () => {
livePanelMock.mockReturnValue({
groupId: "g1", createdAt: 0, settled: 1, joined: false,
members: [{ sessionId: "x", ordinal: 1, status: "running" }],
});
liveProgressMock.mockReturnValue({ settled: 1, total: 2, panels: 1 });
livePanelMemberMock.mockImplementation((id: string) =>
id === "p:review" ? { ordinal: 1, status: "running" }
: id === "p:review-2" ? { ordinal: 2, status: "failed" }
: null,
);
ingestSessionList(FLEET);
const { getByTitle, queryAllByTitle } = render(() => <SessionListPane />);

expect(getByTitle("Panel member 1 — working")).toBeTruthy();
// A failed member is SHOWN, not hidden — same rule as the joined digest.
expect(getByTitle("Panel member 2 — failed")).toBeTruthy();
// The non-participating child carries no badge.
expect(queryAllByTitle(/Panel member/)).toHaveLength(2);
});
});
53 changes: 37 additions & 16 deletions web/src/components/SessionListPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
type FilteredFleetGroup,
} from "../lib/fleet";
import { sessionAgentLabel, shortSub } from "../lib/identity";
import { fetchPanels, livePanel, livePanelMember, resetPanels } from "../state/panels";
import { fetchPanels, liveProgress, livePanelMember, resetPanels } from "../state/panels";
import { nowTick } from "../state/clock";
import {
focusedSession,
Expand Down Expand Up @@ -91,17 +91,29 @@ const SessionListPane: Component = () => {
filterFleet(groupFleet(sessionList()), filter()),
);

// Poll panel state for the focused collaboration only. There is no push
// channel for dispatch state, and a fan-out changes over minutes — a static
// indicator would show a finished panel as still running. Scoped to the
// focused goal rather than every visible fleet so the cost stays one cheap
// metadata query per tick regardless of how many collaborations exist.
createEffect(() => {
// The goal whose panels we poll, as a plain STRING.
//
// A memo over a primitive, deliberately. Reading `focusedSession()?.collaboration`
// directly inside the effect subscribed it to that store property — and
// `mergeSession` reassigns every field on each `session.info_update` with no
// equality guard, so `collaboration` gets a fresh object reference on every
// status flip. The effect then tore down and rebuilt its interval, firing an
// immediate poll each time: during a tool-heavy turn that turned a 3s cadence
// into a burst of queries. A memo returning a string only notifies when the id
// actually changes.
const polledGoalId = createMemo<string | null>(() => {
const focused = focusedSession();
const goalId =
focused?.collaboration !== undefined
? focused.id
: focused?.collaborationRole?.parentSessionId;
if (!focused) return null;
if (focused.collaboration !== undefined) return focused.id;
return focused.collaborationRole?.parentSessionId ?? null;
});

// There is no push channel for dispatch state, and a fan-out changes over
// minutes — a static indicator would show a finished panel as still running.
// Scoped to the focused goal so the cost stays one cheap metadata query per
// tick regardless of how many collaborations exist.
createEffect(() => {
const goalId = polledGoalId();
if (!goalId) {
resetPanels();
return;
Expand Down Expand Up @@ -443,11 +455,12 @@ const SessionRow: Component<{
</Show>
{/* The fan-out, while it runs. This is the whole point of a panel and
was invisible until the daemon started reporting it. */}
<Show when={props.fleet ? livePanel() : null}>
<Show when={props.fleet ? liveProgress() : null}>
{(p) => (
<PanelProgress
settled={p().settled}
total={p().members.length}
total={p().total}
panels={p().panels}
/>
)}
</Show>
Expand Down Expand Up @@ -543,14 +556,22 @@ const SessionRow: Component<{
* leave the bar short of full on a panel that is already done, which is exactly
* the confusion the indicator exists to remove.
*/
const PanelProgress: Component<{ settled: number; total: number }> = (props) => {
const PanelProgress: Component<{
settled: number;
total: number;
/** How many fan-outs are live. More than one is legal — an orchestrator can
* run a second panel while the first is still resolving. */
panels: number;
}> = (props) => {
const pct = () => (props.total === 0 ? 0 : (props.settled / props.total) * 100);
return (
<div
class="flex items-center gap-1.5"
title={`Panel in flight — ${props.settled} of ${props.total} member(s) finished. Counts members that have SETTLED (including failures), because the join fires on all-terminal.`}
title={`${props.panels === 1 ? "Panel" : `${props.panels} panels`} in flight — ${props.settled} of ${props.total} member(s) finished. Counts members that have SETTLED (including failures), because the join fires on all-terminal.`}
>
<span class="font-mono text-[10px] text-warn">⇉ panel</span>
<span class="font-mono text-[10px] text-warn">
⇉ {props.panels === 1 ? "panel" : `panels ×${props.panels}`}
</span>
<span
class="h-1 flex-1 overflow-hidden rounded-full bg-bg"
role="progressbar"
Expand Down
Loading
Loading