diff --git a/apps/studio/src/lib/system-explore-next.test.ts b/apps/studio/src/lib/system-explore-next.test.ts
new file mode 100644
index 00000000..fccb43f2
--- /dev/null
+++ b/apps/studio/src/lib/system-explore-next.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+
+import { systemExploreNextAction } from "./system-explore-next.js";
+import { serializeWorkspaceRoute } from "./workspace-route.js";
+
+const heuristicReady = Object.freeze({
+ kind: "HEURISTIC" as const,
+ status: "READY" as const,
+});
+const modelNeedsKey = Object.freeze({
+ kind: "MODEL" as const,
+ status: "NEEDS_KEY" as const,
+});
+const modelReady = Object.freeze({
+ kind: "MODEL" as const,
+ status: "READY" as const,
+});
+
+describe("system overview Explore next", () => {
+ it("keeps Explore next when the routed discovery profile can dispatch", () => {
+ expect(systemExploreNextAction({
+ workers: [heuristicReady, modelReady],
+ dispatchEligibility: "ELIGIBLE",
+ })).toEqual({ kind: "SCOUT", label: "Explore next" });
+ });
+
+ it("labels the remaining heuristic-only path when the model lane needs a key", () => {
+ expect(systemExploreNextAction({
+ workers: [heuristicReady, modelNeedsKey],
+ dispatchEligibility: "BLOCKED",
+ })).toEqual({ kind: "SCOUT", label: "Explore next · heuristic" });
+ expect(systemExploreNextAction({
+ workers: [heuristicReady, modelNeedsKey],
+ dispatchEligibility: null,
+ })).toEqual({ kind: "SCOUT", label: "Explore next · heuristic" });
+ });
+
+ it("does not keep a green Explore next when the model looks ready but cannot dispatch", () => {
+ expect(systemExploreNextAction({
+ workers: [heuristicReady, modelReady],
+ dispatchEligibility: "BLOCKED",
+ })).toEqual({
+ kind: "NEEDS_SETUP",
+ href: serializeWorkspaceRoute("agents"),
+ });
+ expect(systemExploreNextAction({
+ workers: [modelReady],
+ dispatchEligibility: null,
+ })).toEqual({
+ kind: "NEEDS_SETUP",
+ href: "?view=agents",
+ });
+ });
+
+ it("points at Agent operations when no honest scout path exists", () => {
+ expect(systemExploreNextAction({
+ workers: [modelNeedsKey],
+ dispatchEligibility: "BLOCKED",
+ })).toEqual({
+ kind: "NEEDS_SETUP",
+ href: "?view=agents",
+ });
+ expect(systemExploreNextAction({
+ workers: [],
+ dispatchEligibility: null,
+ }).kind).toBe("NEEDS_SETUP");
+ });
+});
diff --git a/apps/studio/src/lib/system-explore-next.ts b/apps/studio/src/lib/system-explore-next.ts
new file mode 100644
index 00000000..fd8d31ad
--- /dev/null
+++ b/apps/studio/src/lib/system-explore-next.ts
@@ -0,0 +1,48 @@
+import { serializeWorkspaceRoute } from "./workspace-route.js";
+
+export type SystemExploreWorker = Readonly<{
+ kind: "HEURISTIC" | "MODEL";
+ status: "READY" | "NEEDS_KEY" | "NEEDS_PROVIDER";
+}>;
+
+export type SystemExploreNextAction = Readonly<
+ | { kind: "SCOUT"; label: "Explore next" | "Explore next · heuristic" }
+ | { kind: "NEEDS_SETUP"; href: string }
+>;
+
+export function systemExploreNextAction(input: {
+ readonly workers: readonly SystemExploreWorker[];
+ readonly dispatchEligibility: "ELIGIBLE" | "BLOCKED" | null;
+}): SystemExploreNextAction {
+ const heuristicReady = input.workers.some(
+ (worker) => worker.kind === "HEURISTIC" && worker.status === "READY",
+ );
+ const modelReady = input.workers.some(
+ (worker) => worker.kind === "MODEL" && worker.status === "READY",
+ );
+
+ if (input.dispatchEligibility === "ELIGIBLE") {
+ return Object.freeze({ kind: "SCOUT", label: "Explore next" as const });
+ }
+
+ // A model worker can appear READY while Codex dispatch is still blocked.
+ // The lease refuses that path, so do not keep a green Explore next.
+ if (modelReady) {
+ return Object.freeze({
+ kind: "NEEDS_SETUP",
+ href: serializeWorkspaceRoute("agents"),
+ });
+ }
+
+ if (heuristicReady) {
+ return Object.freeze({
+ kind: "SCOUT",
+ label: "Explore next · heuristic" as const,
+ });
+ }
+
+ return Object.freeze({
+ kind: "NEEDS_SETUP",
+ href: serializeWorkspaceRoute("agents"),
+ });
+}
diff --git a/apps/studio/src/product-shell.css b/apps/studio/src/product-shell.css
index 1f5a8278..f6ca459c 100644
--- a/apps/studio/src/product-shell.css
+++ b/apps/studio/src/product-shell.css
@@ -3604,6 +3604,12 @@ footer {
font-size: 13px;
}
+.inline-alert a {
+ color: inherit;
+ font-weight: 650;
+ text-decoration: underline;
+}
+
@media (max-width: 900px) {
.agent-console-grid,
.agent-control-form,
From c517b5e0ba307cfeb8f4a09c5110395e857ac381 Mon Sep 17 00:00:00 2001
From: RainMona <316033127+RainMona@users.noreply.github.com>
Date: Wed, 19 Aug 2026 07:18:15 +0000
Subject: [PATCH 6/8] Drop the fake Explore next heuristic branch on System
overview.
Search leases always requireDispatchEligible. A heuristic suffix still called runScout and bounced, so dispatch that is not ELIGIBLE now only offers Open Agent operations.
(cherry picked from commit c3246657efb1b49a70619d90b4e80fe0aa6eeee1)
---
apps/studio/src/App.tsx | 1 -
.../src/lib/system-explore-next.test.ts | 45 +------------------
apps/studio/src/lib/system-explore-next.ts | 35 ++-------------
3 files changed, 6 insertions(+), 75 deletions(-)
diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx
index 264f5dc5..1e220a2d 100644
--- a/apps/studio/src/App.tsx
+++ b/apps/studio/src/App.tsx
@@ -5964,7 +5964,6 @@ function Overview({
const catalogObservation = studioProjection.ai.catalogObservation;
const discoveryExecution = useDiscoveryExecutionCapability();
const exploreNext = systemExploreNextAction({
- workers: studioProjection.ai.workers,
dispatchEligibility:
discoveryExecution.data?.capability.dispatchEligibility ?? null,
});
diff --git a/apps/studio/src/lib/system-explore-next.test.ts b/apps/studio/src/lib/system-explore-next.test.ts
index fccb43f2..72525221 100644
--- a/apps/studio/src/lib/system-explore-next.test.ts
+++ b/apps/studio/src/lib/system-explore-next.test.ts
@@ -3,66 +3,25 @@ import { describe, expect, it } from "vitest";
import { systemExploreNextAction } from "./system-explore-next.js";
import { serializeWorkspaceRoute } from "./workspace-route.js";
-const heuristicReady = Object.freeze({
- kind: "HEURISTIC" as const,
- status: "READY" as const,
-});
-const modelNeedsKey = Object.freeze({
- kind: "MODEL" as const,
- status: "NEEDS_KEY" as const,
-});
-const modelReady = Object.freeze({
- kind: "MODEL" as const,
- status: "READY" as const,
-});
-
describe("system overview Explore next", () => {
- it("keeps Explore next when the routed discovery profile can dispatch", () => {
+ it("keeps Explore next only when discovery can dispatch", () => {
expect(systemExploreNextAction({
- workers: [heuristicReady, modelReady],
dispatchEligibility: "ELIGIBLE",
})).toEqual({ kind: "SCOUT", label: "Explore next" });
});
- it("labels the remaining heuristic-only path when the model lane needs a key", () => {
- expect(systemExploreNextAction({
- workers: [heuristicReady, modelNeedsKey],
- dispatchEligibility: "BLOCKED",
- })).toEqual({ kind: "SCOUT", label: "Explore next · heuristic" });
- expect(systemExploreNextAction({
- workers: [heuristicReady, modelNeedsKey],
- dispatchEligibility: null,
- })).toEqual({ kind: "SCOUT", label: "Explore next · heuristic" });
- });
-
- it("does not keep a green Explore next when the model looks ready but cannot dispatch", () => {
+ it("does not keep a clickable scout when dispatch is blocked or unknown", () => {
expect(systemExploreNextAction({
- workers: [heuristicReady, modelReady],
dispatchEligibility: "BLOCKED",
})).toEqual({
kind: "NEEDS_SETUP",
href: serializeWorkspaceRoute("agents"),
});
expect(systemExploreNextAction({
- workers: [modelReady],
dispatchEligibility: null,
})).toEqual({
kind: "NEEDS_SETUP",
href: "?view=agents",
});
});
-
- it("points at Agent operations when no honest scout path exists", () => {
- expect(systemExploreNextAction({
- workers: [modelNeedsKey],
- dispatchEligibility: "BLOCKED",
- })).toEqual({
- kind: "NEEDS_SETUP",
- href: "?view=agents",
- });
- expect(systemExploreNextAction({
- workers: [],
- dispatchEligibility: null,
- }).kind).toBe("NEEDS_SETUP");
- });
});
diff --git a/apps/studio/src/lib/system-explore-next.ts b/apps/studio/src/lib/system-explore-next.ts
index fd8d31ad..6d3d0a7c 100644
--- a/apps/studio/src/lib/system-explore-next.ts
+++ b/apps/studio/src/lib/system-explore-next.ts
@@ -1,46 +1,19 @@
import { serializeWorkspaceRoute } from "./workspace-route.js";
-export type SystemExploreWorker = Readonly<{
- kind: "HEURISTIC" | "MODEL";
- status: "READY" | "NEEDS_KEY" | "NEEDS_PROVIDER";
-}>;
-
export type SystemExploreNextAction = Readonly<
- | { kind: "SCOUT"; label: "Explore next" | "Explore next · heuristic" }
+ | { kind: "SCOUT"; label: "Explore next" }
| { kind: "NEEDS_SETUP"; href: string }
>;
export function systemExploreNextAction(input: {
- readonly workers: readonly SystemExploreWorker[];
readonly dispatchEligibility: "ELIGIBLE" | "BLOCKED" | null;
}): SystemExploreNextAction {
- const heuristicReady = input.workers.some(
- (worker) => worker.kind === "HEURISTIC" && worker.status === "READY",
- );
- const modelReady = input.workers.some(
- (worker) => worker.kind === "MODEL" && worker.status === "READY",
- );
-
+ // Search leases always requireDispatchEligible. There is no Studio path
+ // that runs heuristic-fast-1 without that gate, so a heuristic suffix
+ // would still call runScout and bounce.
if (input.dispatchEligibility === "ELIGIBLE") {
return Object.freeze({ kind: "SCOUT", label: "Explore next" as const });
}
-
- // A model worker can appear READY while Codex dispatch is still blocked.
- // The lease refuses that path, so do not keep a green Explore next.
- if (modelReady) {
- return Object.freeze({
- kind: "NEEDS_SETUP",
- href: serializeWorkspaceRoute("agents"),
- });
- }
-
- if (heuristicReady) {
- return Object.freeze({
- kind: "SCOUT",
- label: "Explore next · heuristic" as const,
- });
- }
-
return Object.freeze({
kind: "NEEDS_SETUP",
href: serializeWorkspaceRoute("agents"),
From 2ed231bc51c1e23a290fe21ca417c87dce180d8d Mon Sep 17 00:00:00 2001
From: RainMona <316033127+RainMona@users.noreply.github.com>
Date: Wed, 19 Aug 2026 07:25:33 +0000
Subject: [PATCH 7/8] Stop calling catalog listingCount markets next to
Markets.
Issue #26: System ready said "580 markets" from catalogObservation.listingCount
while ?view=markets is the venue capability matrix, not a listing list. Sidebar
now says listings; the Markets page says these are venue adapters and how many
listings the catalog currently holds. No listing browser, order, or trade UI.
(cherry picked from commit 19e1bfa1e729aabc06f03d63594f89251309bc52)
---
apps/studio/src/App.tsx | 26 +++++++++-----
.../src/lib/markets-workspace-copy.test.ts | 33 +++++++++++++++++
apps/studio/src/lib/markets-workspace-copy.ts | 35 +++++++++++++++++++
3 files changed, 85 insertions(+), 9 deletions(-)
create mode 100644 apps/studio/src/lib/markets-workspace-copy.test.ts
create mode 100644 apps/studio/src/lib/markets-workspace-copy.ts
diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx
index 1e220a2d..fd8f1a1d 100644
--- a/apps/studio/src/App.tsx
+++ b/apps/studio/src/App.tsx
@@ -81,6 +81,7 @@ import {
selectedVenueSessionLabel,
VENUE_SESSION_PICKER_HEADING,
} from "@/lib/book-desk-copy";
+import { marketsWorkspaceCopy } from "@/lib/markets-workspace-copy";
import {
parseWorkspaceRoute,
serializeWorkspaceRoute,
@@ -3904,15 +3905,18 @@ async function requestCandidateWatchRefresh(): Promise<"READY" | "DEGRADED"> {
function SidebarStatus() {
const studioProjection = useStudioProjection();
const observation = studioProjection.ai.catalogObservation;
+ const copy = marketsWorkspaceCopy({
+ healthySourceCount: observation.healthySourceCount,
+ sourceCount: observation.sourceCount,
+ listingCount: observation.listingCount,
+ venueAdapterCount: studioProjection.venues.length,
+ });
return (
System ready
-
- {observation.healthySourceCount}/{observation.sourceCount} sources ·{" "}
- {observation.listingCount} markets
-
+ {copy.sidebarCatalogLine}
);
@@ -12790,15 +12794,19 @@ function ResearchCaseDeskView() {
function VenueMatrix() {
const studioProjection = useStudioProjection();
+ const observation = studioProjection.ai.catalogObservation;
+ const copy = marketsWorkspaceCopy({
+ healthySourceCount: observation.healthySourceCount,
+ sourceCount: observation.sourceCount,
+ listingCount: observation.listingCount,
+ venueAdapterCount: studioProjection.venues.length,
+ });
return (
Protocol reality
-
Venue capability matrix
-
- Each adapter owns its precision, authentication boundary, mechanism,
- and qualification evidence.
-
+
{copy.pageTitle}
+
{copy.pageDescription}
{studioProjection.venues.map((venue) => (
diff --git a/apps/studio/src/lib/markets-workspace-copy.test.ts b/apps/studio/src/lib/markets-workspace-copy.test.ts
new file mode 100644
index 00000000..de92a021
--- /dev/null
+++ b/apps/studio/src/lib/markets-workspace-copy.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { marketsWorkspaceCopy } from "./markets-workspace-copy.js";
+
+const observed = {
+ healthySourceCount: 5,
+ sourceCount: 7,
+ listingCount: 580,
+ venueAdapterCount: 7,
+} as const;
+
+describe("marketsWorkspaceCopy", () => {
+ it("does not call catalog listingCount markets next to the Markets venue matrix", () => {
+ const copy = marketsWorkspaceCopy(observed);
+ expect(copy.sidebarCatalogLine).toBe("5/7 sources · 580 listings");
+ expect(copy.sidebarCatalogLine.toLowerCase()).not.toContain("market");
+ expect(copy.pageTitle).toBe("Venue capability matrix");
+ expect(copy.pageDescription).toContain("7 venue adapters");
+ expect(copy.pageDescription).toContain("580 listings");
+ expect(copy.pageDescription).toContain("not a listing browser");
+ });
+
+ it("uses the same listing count on the Markets page as in the sidebar", () => {
+ const copy = marketsWorkspaceCopy({
+ ...observed,
+ listingCount: 1,
+ venueAdapterCount: 1,
+ });
+ expect(copy.sidebarCatalogLine).toBe("5/7 sources · 1 listing");
+ expect(copy.pageDescription).toContain("1 venue adapter");
+ expect(copy.pageDescription).toContain("1 listing");
+ expect(copy.pageDescription).not.toMatch(/market/i);
+ });
+});
diff --git a/apps/studio/src/lib/markets-workspace-copy.ts b/apps/studio/src/lib/markets-workspace-copy.ts
new file mode 100644
index 00000000..204db9af
--- /dev/null
+++ b/apps/studio/src/lib/markets-workspace-copy.ts
@@ -0,0 +1,35 @@
+export type MarketsWorkspaceCounts = Readonly<{
+ healthySourceCount: number;
+ sourceCount: number;
+ listingCount: number;
+ venueAdapterCount: number;
+}>;
+
+export type MarketsWorkspaceCopy = Readonly<{
+ sidebarCatalogLine: string;
+ pageTitle: string;
+ pageDescription: string;
+}>;
+
+export const MARKETS_PAGE_TITLE = "Venue capability matrix";
+
+export function marketsWorkspaceCopy(
+ counts: MarketsWorkspaceCounts,
+): MarketsWorkspaceCopy {
+ const listings = countPhrase(counts.listingCount, "listing", "listings");
+ const adapters = countPhrase(
+ counts.venueAdapterCount,
+ "venue adapter",
+ "venue adapters",
+ );
+ return {
+ sidebarCatalogLine: `${counts.healthySourceCount}/${counts.sourceCount} sources · ${listings}`,
+ pageTitle: MARKETS_PAGE_TITLE,
+ pageDescription:
+ `These are ${adapters}, not a listing browser. The catalog currently holds ${listings}. Each adapter owns its precision, authentication boundary, mechanism, and qualification evidence.`,
+ };
+}
+
+function countPhrase(count: number, singular: string, plural: string): string {
+ return `${count} ${count === 1 ? singular : plural}`;
+}
From e0037acf5ab34c4e9ebebd1614a1bf15a8524b4a Mon Sep 17 00:00:00 2001
From: RainMona <316033127+RainMona@users.noreply.github.com>
Date: Wed, 19 Aug 2026 07:39:09 +0000
Subject: [PATCH 8/8] Disable Discover Run now when discovery cannot dispatch.
Search issue Run now POSTs /runs on the discovery route, same as the
hero scan. Pause/resume stays local. The scheduler hint no longer
claims manual runs work when dispatch is blocked. Automatic
PMH_SEARCH_ISSUE_TICK_MS dispatch stays off.
(cherry picked from commit 756bf1ae7115850fb33b9ff482d212f7659064d4)
---
apps/studio/src/App.tsx | 9 +++++--
.../src/lib/search-issue-run-now.test.ts | 27 +++++++++++++++++++
apps/studio/src/lib/search-issue-run-now.ts | 18 +++++++++++++
3 files changed, 52 insertions(+), 2 deletions(-)
create mode 100644 apps/studio/src/lib/search-issue-run-now.test.ts
create mode 100644 apps/studio/src/lib/search-issue-run-now.ts
diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx
index fd8f1a1d..e9c079c7 100644
--- a/apps/studio/src/App.tsx
+++ b/apps/studio/src/App.tsx
@@ -82,6 +82,7 @@ import {
VENUE_SESSION_PICKER_HEADING,
} from "@/lib/book-desk-copy";
import { marketsWorkspaceCopy } from "@/lib/markets-workspace-copy";
+import { searchIssueRunNow } from "@/lib/search-issue-run-now";
import {
parseWorkspaceRoute,
serializeWorkspaceRoute,
@@ -7100,6 +7101,9 @@ function MarketArchaeologistView() {
const discoveryCapability = discoveryExecution.data?.capability;
const discoveryRuntime = discoveryExecution.data?.runtime;
const discoveryModel = discoveryExecution.data?.model;
+ const searchIssueRun = searchIssueRunNow({
+ dispatchEligibility: discoveryCapability?.dispatchEligibility ?? null,
+ });
const currentLensRecords = scheduler.records.filter(
(record) => record.lease.snapshotIdentity === corpus.snapshotIdentity,
);
@@ -7798,7 +7802,8 @@ function MarketArchaeologistView() {
variant="outline"
disabled={
corpus.listingCount === 0 || issueAction !== null ||
- (issue.supersededByIssueId !== undefined && issue.supersededByIssueId !== null)
+ (issue.supersededByIssueId !== undefined && issue.supersededByIssueId !== null) ||
+ !searchIssueRun.dispatchEligible
}
onClick={() => void runIssue(issue.issueId)}
>
@@ -7894,7 +7899,7 @@ function MarketArchaeologistView() {
{!issueScheduler.enabled && (
- Automatic dispatch is installed but intentionally explicit. Set PMH_SEARCH_ISSUE_TICK_MS to 1000–60000 and restart the control plane; manual runs work now.
+ {searchIssueRun.schedulerHint}
)}
{issueDiagnostic !== null && (
diff --git a/apps/studio/src/lib/search-issue-run-now.test.ts b/apps/studio/src/lib/search-issue-run-now.test.ts
new file mode 100644
index 00000000..ddd8b797
--- /dev/null
+++ b/apps/studio/src/lib/search-issue-run-now.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+
+import { searchIssueRunNow } from "./search-issue-run-now.js";
+
+describe("search issue Run now", () => {
+ it("allows Run now only when discovery can dispatch", () => {
+ expect(searchIssueRunNow({
+ dispatchEligibility: "ELIGIBLE",
+ })).toEqual({
+ dispatchEligible: true,
+ schedulerHint:
+ "Automatic dispatch is installed but intentionally explicit. Set PMH_SEARCH_ISSUE_TICK_MS to 1000–60000 and restart the control plane; manual runs work now.",
+ });
+ });
+
+ it("does not claim manual runs work when dispatch is blocked or unknown", () => {
+ for (const dispatchEligibility of ["BLOCKED", null] as const) {
+ const result = searchIssueRunNow({ dispatchEligibility });
+ expect(result.dispatchEligible).toBe(false);
+ expect(result.schedulerHint).toBe(
+ "Automatic dispatch is installed but intentionally explicit. Manual Run now stays blocked until discovery can dispatch.",
+ );
+ expect(result.schedulerHint.toLowerCase()).not.toContain("manual runs work now");
+ expect(result.schedulerHint).not.toContain("PMH_SEARCH_ISSUE_TICK_MS");
+ }
+ });
+});
diff --git a/apps/studio/src/lib/search-issue-run-now.ts b/apps/studio/src/lib/search-issue-run-now.ts
new file mode 100644
index 00000000..389f0fae
--- /dev/null
+++ b/apps/studio/src/lib/search-issue-run-now.ts
@@ -0,0 +1,18 @@
+export type SearchIssueRunNow = Readonly<{
+ dispatchEligible: boolean;
+ schedulerHint: string;
+}>;
+
+export function searchIssueRunNow(input: {
+ readonly dispatchEligibility: "ELIGIBLE" | "BLOCKED" | null;
+}): SearchIssueRunNow {
+ // POST /api/v1/search-issues/:id/runs spends the discovery route, same as
+ // the hero scan. Pause/resume is local via requestSearchIssueEnabled.
+ const dispatchEligible = input.dispatchEligibility === "ELIGIBLE";
+ return Object.freeze({
+ dispatchEligible,
+ schedulerHint: dispatchEligible
+ ? "Automatic dispatch is installed but intentionally explicit. Set PMH_SEARCH_ISSUE_TICK_MS to 1000–60000 and restart the control plane; manual runs work now."
+ : "Automatic dispatch is installed but intentionally explicit. Manual Run now stays blocked until discovery can dispatch.",
+ });
+}