Run tasks on Codex beside Claude; drop turn caps and mid-turn snapshots

This commit is contained in:
Ian Gulliver
2026-10-15 14:55:58 -07:00
parent 17898b0191
commit 74707f6398
53 changed files with 1904 additions and 1295 deletions
+14 -24
View File
@@ -296,7 +296,7 @@ export interface paths {
};
get?: never;
put?: never;
/** @description Starts the same agent turn as merge-in. When that turn ends normally, byre squashes the task's worktree into one commit titled with the task's merge_title on the project's default branch, authored by the caller with a Co-authored-by trailer for each of the task's co-authors, pushes it to origin, fast-forwards the project's checkout to it, then removes the worktree and branch and marks the task merged. While another task in the project is merging, the merge is held instead (merge_queued_at is set) and starts when no merge is in progress in the project and the task's session is idle, oldest first. Refused while a turn is running or queued, or the merge is already held. */
/** @description Starts the same agent turn as merge-in. When that turn ends normally, byre squashes the task's worktree into one commit titled with the task's merge_title on the project's default branch, authored by the caller with a Co-authored-by trailer for each of the task's co-authors, pushes it to origin, fast-forwards the project's checkout to it, then removes the worktree and branch and marks the task merged. While another task in the project is merging, the merge is held instead (merge_queued_at is set) and starts when no merge is in progress in the project and the task's session is idle, oldest first. Refused while a turn is running, or the merge is already held. */
post: operations["mergeTask"];
delete?: never;
options?: never;
@@ -315,7 +315,7 @@ export interface paths {
};
get?: never;
put?: never;
/** @description Asks the task's agent to merge the project's default branch into the task branch, resolving any conflicts and regenerating generated files. Sent to the task's idle session, or starts a session when it has none. Refused while a turn is running or queued. */
/** @description Asks the task's agent to merge the project's default branch into the task branch, resolving any conflicts and regenerating generated files. Sent to the task's idle session, or starts a session when it has none. Refused while a turn is running. */
post: operations["mergeIn"];
delete?: never;
options?: never;
@@ -947,7 +947,7 @@ export interface components {
*/
event_seq: number;
/**
* @description message: the agent wrote text after one or more tool calls; turn_end: after byre's end-of-turn commit. tool_call, ask_human and timer appear only on snapshots taken by earlier builds.
* @description turn_end: after byre's end-of-turn commit. message (the agent wrote text after one or more tool calls), tool_call, ask_human and timer appear only on snapshots taken by earlier builds.
* @enum {string}
*/
trigger: "message" | "turn_end" | "tool_call" | "ask_human" | "timer";
@@ -968,16 +968,17 @@ export interface components {
task_id: string;
/** @description The login whose lead agent this is; empty for a task's session. */
owner: string;
/** @enum {string} */
vendor: "claude";
/** @description Requested model; empty means the vendor's default. */
model: string;
vendor_session_id: string;
/**
* @description queued means a turn is waiting for a free slot under the concurrency caps.
* @description Follows from the model the session was created with: gpt-* models run on Codex, every other model on Claude Code.
* @enum {string}
*/
state: "idle" | "queued" | "running" | "completed";
vendor: "claude" | "codex";
/** @description Requested model; empty means the vendor's default. A session only switches between its own vendor's models. */
model: string;
/** @description Claude Code's session id, chosen by byre; or the Codex thread id, empty until the session's first turn starts its thread. */
vendor_session_id: string;
/** @enum {string} */
state: "idle" | "running" | "completed";
/** @enum {string} */
role: "implementer" | "lead";
created_by: string;
@@ -985,7 +986,7 @@ export interface components {
created_at: string;
/** @description A fork's first start resumes this vendor session, cut at forked_at, under a new vendor_session_id; absent for other sessions. */
forked_from?: string;
/** @description With forked_from: the vendor's id of the last message the fork keeps. */
/** @description With forked_from: the vendor's id of the last thing the fork keeps, a message for Claude Code, a turn for Codex. */
forked_at?: string;
};
SessionCreate: {
@@ -1122,9 +1123,6 @@ export interface components {
protocol_errors: components["schemas"]["ProtocolErrors"];
/** @description Turns running now, oldest session first. */
running_turns: components["schemas"]["TurnRef"][];
/** @description Turns waiting for room under the caps, in the order they will start. */
queued_turns: components["schemas"]["TurnRef"][];
caps: components["schemas"]["Caps"];
/** @description The latest rate-limit status each vendor reported, keyed by vendor. */
rate_limits: {
[key: string]: Record<string, never>;
@@ -1139,14 +1137,6 @@ export interface components {
active_tasks: number;
sse_clients: number;
};
Caps: {
/** @description Most turns running at once across all vendors. */
global: number;
/** @description Most turns running at once per vendor, keyed by vendor. */
per_vendor: {
[key: string]: number;
};
};
TurnRef: {
session_id: string;
task_id: string;
@@ -1740,7 +1730,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description The merge turn was started or queued. */
/** @description The merge turn was started. */
202: {
headers: {
[name: string]: unknown;
@@ -2167,7 +2157,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description The handoff turn was started or queued. */
/** @description The handoff turn was started. */
202: {
headers: {
[name: string]: unknown;
+5 -33
View File
@@ -108,42 +108,14 @@ function mountStatus(root: HTMLElement): () => void {
}
function turnsSection(s: Status): HTMLElement {
const vendors = [...new Set([...Object.keys(s.caps.per_vendor), ...s.running_turns.map((t) => t.vendor), ...s.queued_turns.map((t) => t.vendor)])].sort();
const runningFor = (vendor: string) => s.running_turns.filter((t) => t.vendor === vendor).length;
return h(
"section",
{ class: "status-section" },
h("h2", {}, "Turns"),
h(
"div",
{ class: "caps" },
capTile("All vendors", s.running_turns.length, s.caps.global),
...vendors.map((v) => capTile(v, runningFor(v), s.caps.per_vendor[v])),
h("div", { class: "cap-tile" }, h("div", { class: "cap-label" }, "Queued"), h("div", { class: s.queued_turns.length > 0 ? "cap-value warn" : "cap-value" }, s.queued_turns.length)),
),
h("h3", {}, "Running"),
turnList(s.running_turns, "running", "No turns running.", s.agent_processes),
h("h3", {}, "Queued"),
turnList(s.queued_turns, "queued", "Nothing queued.", []),
);
return h("section", { class: "status-section" }, h("h2", {}, "Running turns"), turnList(s.running_turns, s.agent_processes));
}
function capTile(label: string, running: number, cap: number | undefined): HTMLElement {
const full = cap !== undefined && running >= cap;
return h(
"div",
{ class: "cap-tile" },
h("div", { class: "cap-label" }, label),
h("div", { class: full ? "cap-value warn" : "cap-value" }, running, h("span", { class: "cap-max" }, cap === undefined ? " / no cap" : ` / ${cap}`)),
);
}
function turnList(turns: TurnRef[], kind: "running" | "queued", empty: string, procs: AgentProcess[]): HTMLElement {
function turnList(turns: TurnRef[], procs: AgentProcess[]): HTMLElement {
return h(
"ul",
{ class: "task-list" },
...turns.map((t, i) => {
...turns.map((t) => {
const task = findTask(t.task_id);
const project = task ? state.projects.find((p) => p.id === task.project_id) : undefined;
return h(
@@ -152,14 +124,14 @@ function turnList(turns: TurnRef[], kind: "running" | "queued", empty: string, p
h(
"a",
{ href: `#/task/${t.task_id}` },
kind === "queued" ? h("span", { class: "queue-pos" }, `${i + 1}.`) : h("span", { class: "dot running", title: "running" }),
h("span", { class: "dot running", title: "running" }),
h("span", { class: "task-title" }, project && h("span", { class: "project-name" }, `${project.name} ▸ `), h("span", { class: "task-name" }, task?.title ?? t.task_id)),
liveness(procs.find((p) => p.session_id === t.session_id)),
h("span", { class: "task-state" }, t.vendor),
),
);
}),
turns.length === 0 && h("li", { class: "empty-row" }, empty),
turns.length === 0 && h("li", { class: "empty-row" }, "No turns running."),
);
}
+27 -13
View File
@@ -30,7 +30,19 @@ export type Head = {
dispose?: () => void;
};
export const models = ["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"];
export const models = [
"claude-opus-5-5",
"claude-sonnet-5-5",
"claude-haiku-5-5",
"claude-fable-5-1",
"gpt-6.1-sol",
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
];
const quietReply = "NO_REPLY";
@@ -134,7 +146,7 @@ function taskHead(rerender: () => void): Head {
return [h("div", { class: "head-actions" }, task.status)];
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
const busy = sess?.state === "running" || sess?.state === "queued";
const busy = sess?.state === "running";
const held = task.merge_queued_at !== undefined;
const merge = h(
"button",
@@ -308,7 +320,7 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
const sess = src.session();
const live = sess && sess.state !== "completed" ? sess : undefined;
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
const form = composeForm(src, key, live?.id, live?.state ?? "idle", composerModel, showPending);
const form = composeForm(src, key, live?.id, live?.state ?? "idle", composerModel, live?.vendor, showPending);
const old = composer.querySelector("textarea");
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
replace(composer, form);
@@ -685,10 +697,15 @@ function releaseImages(imgs: PendingImage[]): void {
const modelChoices = new Map<string, string>();
function modelSelect(key: string, current: string): HTMLSelectElement {
const options = current && !models.includes(current) ? [current, ...models] : models;
function vendorOf(model: string): string {
return model.startsWith("gpt-") ? "codex" : "claude";
}
function modelSelect(key: string, current: string, vendor: string | undefined): HTMLSelectElement {
const offered = vendor ? models.filter((m) => vendorOf(m) === vendor) : models;
const options = current && !offered.includes(current) ? [current, ...offered] : offered;
const select = h("select", { title: "Model for the next turn" }, ...options.map((m) => h("option", { value: m }, m)));
select.value = modelChoices.get(key) ?? (current || models[0]!);
select.value = modelChoices.get(key) ?? (current || options[0]!);
select.onchange = () => {
modelChoices.set(key, select.value);
};
@@ -701,16 +718,13 @@ function composeForm(
sessionId: string | undefined,
sessionState: string,
currentModel: string,
vendor: string | undefined,
showPending: (text: string, imgs: PendingImage[]) => () => void,
): HTMLElement {
const text = h("textarea", { placeholder: src.placeholder(sessionState), rows: 3 });
const clearDraft = keepDraft(text, `message:${key}`);
const send = h("button", { type: "submit", disabled: !src.canSend(sessionState) }, "Send");
const interrupt = h(
"button",
{ type: "button", class: "danger", disabled: sessionState !== "running" && sessionState !== "queued" },
sessionState === "queued" ? "Cancel queued turn" : "Interrupt",
);
const interrupt = h("button", { type: "button", class: "danger", disabled: sessionState !== "running" }, "Interrupt");
interrupt.onclick = async () => {
if (!sessionId) {
return;
@@ -723,7 +737,7 @@ function composeForm(
};
const modelKey = `message:${sessionId ?? key}`;
const model = modelSelect(modelKey, currentModel);
const model = modelSelect(modelKey, currentModel, vendor);
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, `message:${key}`);
form.append(tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
@@ -1064,7 +1078,7 @@ function messageImages(ids: unknown): HTMLElement | null {
export async function confirmMerge(taskId: string): Promise<void> {
const task = findTask(taskId);
const sessionState = currentSession(taskId)?.state;
if (!task || task.status !== "active" || task.merge_queued_at !== undefined || sessionState === "running" || sessionState === "queued") {
if (!task || task.status !== "active" || task.merge_queued_at !== undefined || sessionState === "running") {
return;
}
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";