diff --git a/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx b/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx
index 3d71421c52..b5b95610b8 100644
--- a/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/orchestration/OrchestrationPageClient.tsx
@@ -133,6 +133,13 @@ export default function OrchestrationPageClient() {
[collapsed, setParams]
);
const closeDrawer = useCallback(() => setParams({ node: null }), [setParams]);
+ const onActionDone = useCallback(
+ (newNodeId?: string) => {
+ refetch();
+ if (newNodeId) setParams({ node: newNodeId });
+ },
+ [refetch, setParams]
+ );
const selectedNode = nodeId ? (snapshot.nodes.find((n) => n.id === nodeId) ?? null) : null;
const onNodeClick = (id: string) =>
@@ -188,8 +195,17 @@ export default function OrchestrationPageClient() {
{tab === "history" && }
+ {/* A successful repeat hands back the CANVAS id of the task it created: refetch, then
+ focus it (`?node=`) so the operator lands on the new run instead of staring at the
+ finished one. No id (approve/cancel, or a creation response without one) keeps the
+ current selection. The History tab renders its own drawer and deliberately does NOT
+ navigate (HistoryTab.tsx) — its runs are not addressable in the live snapshot. */}
{tab !== "history" && (
-
+
)}
);
diff --git a/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx b/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx
index 1480e4e3d5..3a50a1589c 100644
--- a/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx
+++ b/src/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer.tsx
@@ -4,7 +4,7 @@ import { useTranslations } from "next-intl";
import { StatusDot } from "@/shared/components/flow/StatusDot";
import { orchStateColor, type OrchNode, type OrchState } from "../model/orchestrationTypes";
import { useDrawerDetail } from "./useDrawerDetail";
-import type { DrawerError } from "./useDrawerDetail";
+import type { DrawerError, RepeatOutcome } from "./useDrawerDetail";
import type { CloudAgentTask } from "@/lib/cloudAgent/types";
import type { A2ATask } from "@/lib/a2a/taskManager";
@@ -349,15 +349,18 @@ function RepeatButton({
}: {
canRepeat: boolean;
busy: boolean;
- repeat: () => Promise;
- onActionDone: () => void;
+ repeat: () => Promise;
+ onActionDone: (newNodeId?: string) => void;
onToast: (text: string) => void;
t: Translate;
}) {
const { confirming, onClick } = useTwoClickConfirm(() => {
void (async () => {
- if (await repeat()) {
- onActionDone();
+ // The created task's CANVAS id is handed to `onActionDone` so the page can focus it —
+ // `undefined` when the creation response carried no usable id (plain refetch, no jump).
+ const { ok, newNodeId } = await repeat();
+ if (ok) {
+ onActionDone(newNodeId ?? undefined);
onToast(t("repeatDone"));
}
})();
@@ -395,8 +398,8 @@ function DrawerActions({
busy: boolean;
approve: () => Promise;
cancel: () => Promise;
- repeat: () => Promise;
- onActionDone: () => void;
+ repeat: () => Promise;
+ onActionDone: (newNodeId?: string) => void;
onToast: (text: string) => void;
t: Translate;
}) {
@@ -488,7 +491,7 @@ export function OrchestrationDrawer({
}: {
node: OrchNode | null;
onClose: () => void;
- onActionDone: () => void;
+ onActionDone: (newNodeId?: string) => void;
}) {
const t = useTranslations("orchestration");
const {
diff --git a/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts b/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts
index c89f3aeb68..50a51cc0f8 100644
--- a/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts
+++ b/src/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail.ts
@@ -143,7 +143,14 @@ function repeatReqForA2a(
};
}
-/** conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route). */
+/**
+ * conductor repeat builder — `POST /api/conductor/tasks` (D1 task-creation route).
+ * `cli`/`model` come from the hub's `requirements` (`ConductorTaskDetail`, hubProxy.ts) and are
+ * carried over so the repeat lands on the SAME runner profile/model the original task was
+ * pinned to. Both are `z.string().optional()` in the route's Zod: a `null` would 400, so a
+ * missing requirement must OMIT the field (`undefined`) rather than send `null` — and the two
+ * are independent (one may be set while the other is not).
+ */
function repeatReqForConductor(detail: unknown): { url: string; init: RequestInit } | null {
const d = detail as ConductorTaskDetail | null;
if (!d?.repo || !d?.prompt) return null;
@@ -154,6 +161,8 @@ function repeatReqForConductor(detail: unknown): { url: string; init: RequestIni
prompt: d.prompt,
baseRef: d.base_ref ?? undefined,
mode: d.mode,
+ cli: d.cli ?? undefined,
+ model: d.model ?? undefined,
}),
};
}
@@ -168,6 +177,36 @@ export function repeatReqFor(
return null;
}
+/** `` when `id` is a non-empty string, `null` otherwise (never a bare prefix). */
+function prefixedNodeId(prefix: string, id: unknown): string | null {
+ return typeof id === "string" && id.length > 0 ? `${prefix}${id}` : null;
+}
+
+/**
+ * CANVAS node id of the task a successful repeat just created, from the creation response
+ * body — `null` whenever the body does not carry a usable id (the caller then simply refetches
+ * without focusing anything). The canvas addresses nodes by PREFIXED id
+ * (`mergeSnapshot.ts`), so the raw upstream id is never returned on its own. Response
+ * envelopes, verified against the live routes:
+ * - conductor (`POST /api/conductor/tasks`): `{ task_id }`.
+ * - cloud-agent (`POST /api/v1/agents/tasks`): `{ data: { id } }`.
+ * - a2a (`POST /a2a`, JSON-RPC `message/send`): `{ result: { task: { id } } }`.
+ */
+export function newNodeIdFrom(node: OrchNode, body: unknown): string | null {
+ if (!body || typeof body !== "object") return null;
+ const b = body as Record;
+ if (node.id.startsWith("conductor:task:")) return prefixedNodeId("conductor:task:", b.task_id);
+ if (node.id.startsWith("cloud-agent:")) {
+ const data = b.data as { id?: unknown } | undefined;
+ return prefixedNodeId("cloud-agent:", data?.id);
+ }
+ if (node.id.startsWith("a2a:")) {
+ const result = b.result as { task?: { id?: unknown } } | undefined;
+ return prefixedNodeId("a2a:", result?.task?.id);
+ }
+ return null;
+}
+
/**
* Unwraps a task-detail GET response to the actual task payload. Each source's
* route has its own envelope — verified against the live handlers, not assumed:
@@ -258,38 +297,57 @@ function useFetchDetail(
* that never happened, so the `/a2a` action also inspects the envelope. Only the numeric
* `error.code` is surfaced (`RPC `) — never the upstream `error.message`.
*/
-async function jsonRpcErrorCode(res: {
- json?: () => Promise;
-}): Promise {
+function jsonRpcErrorCode(body: unknown): number | undefined {
+ const b = body as { error?: { code?: unknown } } | undefined | null;
+ const code = b?.error?.code;
+ return typeof code === "number" ? code : b?.error ? -32603 : undefined;
+}
+
+/**
+ * Reads an action response body ONCE, tolerating a non-JSON/empty body. A body that cannot be
+ * parsed is not evidence of failure — the status already stood — so it yields `null` and the
+ * action stays successful (it just has no new-task id to focus).
+ */
+async function readJsonBody(res: { json?: () => Promise }): Promise {
try {
- const body = (await res.json?.()) as { error?: { code?: unknown } } | undefined;
- const code = body?.error?.code;
- return typeof code === "number" ? code : body?.error ? -32603 : undefined;
+ return (await res.json?.()) ?? null;
} catch {
- // A non-JSON / already-consumed body is not evidence of failure — the status stands.
- return undefined;
+ return null;
}
}
+/** Outcome of an action POST: whether it succeeded, plus the parsed body on success. */
+interface ActionOutcome {
+ ok: boolean;
+ body: unknown;
+}
+
async function performAction(
req: { url: string; init: RequestInit } | null,
setActionError: (text: string) => void
-): Promise {
- if (!req) return false;
+): Promise {
+ if (!req) return { ok: false, body: null };
try {
const res = await fetch(req.url, req.init);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const body = await readJsonBody(res);
if (req.url === "/a2a") {
- const code = await jsonRpcErrorCode(res);
+ const code = jsonRpcErrorCode(body);
if (code !== undefined) throw new Error(`RPC ${code}`);
}
- return true;
+ return { ok: true, body };
} catch (err) {
setActionError(toSafeErrorText(err));
- return false;
+ return { ok: false, body: null };
}
}
+/** Result of the drawer's repeat action: success plus the canvas id of the created task. */
+export interface RepeatOutcome {
+ ok: boolean;
+ newNodeId: string | null;
+}
+
export function useDrawerDetail(node: OrchNode | null) {
const [detail, setDetail] = useState(null);
const [isLoading, setIsLoading] = useState(false);
@@ -306,8 +364,10 @@ export function useDrawerDetail(node: OrchNode | null) {
const { canApprove, canCancel } = deriveActionAvailability(route, node);
const repeatReq = node ? repeatReqFor(node, detail) : null;
- const runAction = async (req: { url: string; init: RequestInit } | null): Promise => {
- if (busy) return false;
+ const runAction = async (
+ req: { url: string; init: RequestInit } | null
+ ): Promise => {
+ if (busy) return { ok: false, body: null };
setBusy(true);
try {
return await performAction(req, setActionError);
@@ -315,6 +375,8 @@ export function useDrawerDetail(node: OrchNode | null) {
setBusy(false);
}
};
+ const runBooleanAction = async (req: { url: string; init: RequestInit } | null) =>
+ (await runAction(req)).ok;
return {
detail,
@@ -325,8 +387,13 @@ export function useDrawerDetail(node: OrchNode | null) {
canApprove,
canCancel,
canRepeat: !!repeatReq && !busy,
- approve: () => runAction(route?.approveReq ?? null),
- cancel: () => runAction(route?.cancelReq ?? null),
- repeat: () => runAction(repeatReq),
+ approve: () => runBooleanAction(route?.approveReq ?? null),
+ cancel: () => runBooleanAction(route?.cancelReq ?? null),
+ // Only the repeat reports a new node id: approve/cancel act on the task already open, so
+ // there is nothing new to focus (and their responses can echo the SAME task's id back).
+ repeat: async (): Promise => {
+ const { ok, body } = await runAction(repeatReq);
+ return { ok, newNodeId: ok && node ? newNodeIdFrom(node, body) : null };
+ },
};
}
diff --git a/src/lib/conductor/hubProxy.ts b/src/lib/conductor/hubProxy.ts
index 8992aa9257..b0679d14c1 100644
--- a/src/lib/conductor/hubProxy.ts
+++ b/src/lib/conductor/hubProxy.ts
@@ -45,6 +45,10 @@ export interface ConductorTaskDetail extends FleetTask {
tests: unknown;
council: unknown;
created_at: string | null;
+ /** Runner profile the task was pinned to (hub `requirements.cli`), `null` when unconstrained. */
+ cli: string | null;
+ /** Model the task was pinned to (hub `requirements.model`), `null` when unconstrained. */
+ model: string | null;
}
// ============ Untrusted hub shapes (parse only what we read) ============
@@ -74,6 +78,13 @@ const hubTaskSchema = z.object({
tests: z.unknown().optional(),
})
.nullish(),
+ // The hub echoes back the `requirements` object `createConductorTask` sends on creation.
+ // `.catch(null)` keeps an unexpected shape from failing the WHOLE task parse — a single odd
+ // requirement must not blank the fleet snapshot (the list parses with this same schema).
+ requirements: z
+ .object({ cli: z.string().nullish(), model: z.string().nullish() })
+ .nullish()
+ .catch(null),
council: z.unknown().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
@@ -159,13 +170,16 @@ export async function getFleetSnapshot(opts: HubProxyOptions = {}): Promise ({
- id: r.id,
- name: r.capabilities.name ?? "?",
- clis: (r.capabilities.clis ?? []).map((c) => c.profile),
- online: r.online !== false,
- draining: r.draining === true,
- }));
+ const runners = z
+ .array(hubRunnerSchema)
+ .parse(rawRunners)
+ .map((r) => ({
+ id: r.id,
+ name: r.capabilities.name ?? "?",
+ clis: (r.capabilities.clis ?? []).map((c) => c.profile),
+ online: r.online !== false,
+ draining: r.draining === true,
+ }));
const tasks = z.array(hubTaskSchema).parse(rawTasks).map(toFleetTask);
emitFleetTransitions(tasks);
return { offline: false, runners, tasks };
@@ -190,6 +204,8 @@ export async function getConductorTaskDetail(
tests: t.manifest?.tests ?? null,
council: t.council ?? null,
created_at: t.created_at ?? null,
+ cli: t.requirements?.cli ?? null,
+ model: t.requirements?.model ?? null,
};
} catch {
return null;
diff --git a/tests/unit/conductor-create-route.test.ts b/tests/unit/conductor-create-route.test.ts
index d16d47b1d3..815107fb8b 100644
--- a/tests/unit/conductor-create-route.test.ts
+++ b/tests/unit/conductor-create-route.test.ts
@@ -57,21 +57,8 @@ test.after(async () => {
}
});
-test("route: requireManagementAuth antes de criar a task no hub", () => {
- const src = fs.readFileSync(
- path.join(process.cwd(), "src/app/api/conductor/tasks/route.ts"),
- "utf8"
- );
- const authAt = src.indexOf("requireManagementAuth(");
- assert.ok(authAt > 0, "handler chama requireManagementAuth");
- assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth");
- const proxyAt = src.indexOf("createConductorTask(");
- assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth");
- assert.ok(
- !src.includes("CONDUCTOR_HUB_TOKEN"),
- "token nunca manuseado na rota (vive no hubProxy)"
- );
-});
+// A prova de fonte "requireManagementAuth antes do proxy ao hub" desta rota vive no array
+// `ROUTES` de `conductor-routes-auth.test.ts` (uma única implementação para as quatro rotas).
test("POST /api/conductor/tasks: body válido + hub ok → 201 {task_id}", async () => {
process.env.CONDUCTOR_HUB_URL = await fakeHub({
diff --git a/tests/unit/conductor-routes-auth.test.ts b/tests/unit/conductor-routes-auth.test.ts
index 8c35f1f5a6..9dff394bb6 100644
--- a/tests/unit/conductor-routes-auth.test.ts
+++ b/tests/unit/conductor-routes-auth.test.ts
@@ -8,6 +8,9 @@ const ROUTES = [
"src/app/api/conductor/fleet/route.ts",
"src/app/api/conductor/tasks/[id]/route.ts",
"src/app/api/conductor/tasks/[id]/cancel/route.ts",
+ // Rota de criação (repeat do drawer): coberta aqui pelo mesmo padrão, em vez de
+ // duplicar o teste de fonte dentro de `conductor-create-route.test.ts`.
+ "src/app/api/conductor/tasks/route.ts",
];
for (const route of ROUTES) {
@@ -16,8 +19,13 @@ for (const route of ROUTES) {
const authAt = src.indexOf("requireManagementAuth(");
assert.ok(authAt > 0, "handler chama requireManagementAuth");
assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth");
- const proxyAt = src.search(/getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(/);
+ const proxyAt = src.search(
+ /getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(|createConductorTask\(/
+ );
assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth");
- assert.ok(!src.includes("CONDUCTOR_HUB_TOKEN"), "token nunca manuseado na rota (vive no hubProxy)");
+ assert.ok(
+ !src.includes("CONDUCTOR_HUB_TOKEN"),
+ "token nunca manuseado na rota (vive no hubProxy)"
+ );
});
}
diff --git a/tests/unit/ui/orchestrationDrawerRepeat.test.tsx b/tests/unit/ui/orchestrationDrawerRepeat.test.tsx
index 761b0d922f..dcbd0d0a5b 100644
--- a/tests/unit/ui/orchestrationDrawerRepeat.test.tsx
+++ b/tests/unit/ui/orchestrationDrawerRepeat.test.tsx
@@ -9,7 +9,10 @@ vi.mock("next-intl", () => ({
}));
import { OrchestrationDrawer } from "@/app/(dashboard)/dashboard/orchestration/drawer/OrchestrationDrawer";
-import { repeatReqFor } from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail";
+import {
+ newNodeIdFrom,
+ repeatReqFor,
+} from "@/app/(dashboard)/dashboard/orchestration/drawer/useDrawerDetail";
function render(el: React.ReactElement) {
const c = document.createElement("div");
@@ -784,3 +787,185 @@ describe("OrchestrationDrawer repeat action (two-click confirm)", () => {
cleanup();
});
});
+
+describe("repeatReqFor conductor requirements (cli/model)", () => {
+ const node = {
+ id: "conductor:task:1",
+ kind: "work",
+ source: "conductor",
+ state: "succeeded",
+ label: "x",
+ };
+
+ it("carries cli/model when the hub detail exposes them (a repeat must land on the same runner profile/model)", () => {
+ const detail = {
+ repo: "https://github.com/x/y",
+ prompt: "fix the bug",
+ base_ref: "main",
+ mode: "auto",
+ cli: "claude",
+ model: "sonnet",
+ };
+ const req = repeatReqFor(node as never, detail);
+ expect(JSON.parse(String(req?.init.body))).toEqual({
+ repoUrl: "https://github.com/x/y",
+ prompt: "fix the bug",
+ baseRef: "main",
+ mode: "auto",
+ cli: "claude",
+ model: "sonnet",
+ });
+ });
+
+ it("omits cli/model entirely when the detail has them null (the route's Zod treats both as optional strings — `null` would 400)", () => {
+ const detail = {
+ repo: "https://github.com/x/y",
+ prompt: "fix the bug",
+ base_ref: "main",
+ mode: "auto",
+ cli: null,
+ model: null,
+ };
+ const body = String(repeatReqFor(node as never, detail)?.init.body);
+ expect(JSON.parse(body)).toEqual({
+ repoUrl: "https://github.com/x/y",
+ prompt: "fix the bug",
+ baseRef: "main",
+ mode: "auto",
+ });
+ expect(body).not.toContain("cli");
+ expect(body).not.toContain("model");
+ });
+
+ it("carries only the field that is present (cli set, model null — they are independent)", () => {
+ const detail = {
+ repo: "https://github.com/x/y",
+ prompt: "fix the bug",
+ base_ref: null,
+ mode: "solo",
+ cli: "codex",
+ model: null,
+ };
+ expect(JSON.parse(String(repeatReqFor(node as never, detail)?.init.body))).toEqual({
+ repoUrl: "https://github.com/x/y",
+ prompt: "fix the bug",
+ mode: "solo",
+ cli: "codex",
+ });
+ });
+});
+
+describe("newNodeIdFrom", () => {
+ it("maps each source's creation response to the CANVAS node id (prefixed), never the raw id", () => {
+ const cond = { id: "conductor:task:1", source: "conductor" };
+ expect(newNodeIdFrom(cond as never, { task_id: "t2" })).toBe("conductor:task:t2");
+ const ca = { id: "cloud-agent:1", source: "cloud-agent" };
+ expect(newNodeIdFrom(ca as never, { data: { id: "c2" } })).toBe("cloud-agent:c2");
+ const a2a = { id: "a2a:1", source: "a2a" };
+ expect(newNodeIdFrom(a2a as never, { result: { task: { id: "n2" } } })).toBe("a2a:n2");
+ });
+
+ it("returns null for a body that does not carry a usable id (never throws, never yields a bare prefix)", () => {
+ const cond = { id: "conductor:task:1", source: "conductor" };
+ expect(newNodeIdFrom(cond as never, null)).toBeNull();
+ expect(newNodeIdFrom(cond as never, {})).toBeNull();
+ expect(newNodeIdFrom(cond as never, { task_id: "" })).toBeNull();
+ expect(newNodeIdFrom(cond as never, { task_id: 7 })).toBeNull();
+ expect(newNodeIdFrom(cond as never, "nope")).toBeNull();
+ const ca = { id: "cloud-agent:1", source: "cloud-agent" };
+ expect(newNodeIdFrom(ca as never, { data: {} })).toBeNull();
+ const a2a = { id: "a2a:1", source: "a2a" };
+ expect(newNodeIdFrom(a2a as never, { result: {} })).toBeNull();
+ });
+
+ it("returns null for a node whose source has no repeat contract", () => {
+ const overflow = { id: "overflow:1", kind: "overflow" };
+ expect(newNodeIdFrom(overflow as never, { task_id: "t2" })).toBeNull();
+ });
+});
+
+describe("OrchestrationDrawer repeat focuses the newly created task", () => {
+ const CONDUCTOR_DETAIL = {
+ id: "1",
+ status: "succeeded",
+ mode: "solo",
+ repo: "https://github.com/x/y",
+ runner: null,
+ summary: null,
+ branch: null,
+ error: null,
+ updated_at: null,
+ prompt: "fix the bug",
+ base_ref: "main",
+ tests: null,
+ council: null,
+ created_at: null,
+ cli: "claude",
+ model: "sonnet",
+ };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ function findRepeatButton(c: HTMLElement): HTMLButtonElement {
+ return Array.from(c.querySelectorAll("button")).find(
+ (b) => b.textContent?.includes("actionRepeat") || b.textContent?.includes("repeatConfirm")
+ ) as HTMLButtonElement;
+ }
+
+ async function repeatOnce(onActionDone: (id?: string) => void) {
+ const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
+ if (init?.method === "POST") {
+ return Promise.resolve({
+ ok: true,
+ status: 201,
+ json: () => Promise.resolve({ task_id: "t_new" }),
+ });
+ }
+ return Promise.resolve({ ok: true, json: () => Promise.resolve(CONDUCTOR_DETAIL) });
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ const node = {
+ id: "conductor:task:1",
+ kind: "work",
+ source: "conductor",
+ state: "running",
+ label: "x",
+ };
+ const { c, cleanup } = render(
+ {}} onActionDone={onActionDone} />
+ );
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ findRepeatButton(c).click();
+ });
+ await act(async () => {
+ findRepeatButton(c).click();
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ return { c, cleanup, fetchMock };
+ }
+
+ it("hands the canvas id of the created task to onActionDone, so the page can focus it", async () => {
+ const seen: Array = [];
+ const { c, cleanup, fetchMock } = await repeatOnce((id) => seen.push(id));
+ const post = fetchMock.mock.calls.find(([, init]) => (init as RequestInit)?.method === "POST");
+ expect(post![0]).toBe("/api/conductor/tasks");
+ expect(JSON.parse(String((post![1] as RequestInit).body))).toMatchObject({
+ cli: "claude",
+ model: "sonnet",
+ });
+ expect(seen).toEqual(["conductor:task:t_new"]);
+ expect(c.textContent).toContain("repeatDone");
+ cleanup();
+ });
+});