Let task agents create and manage subtasks
This commit is contained in:
+13
-4
@@ -282,7 +282,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Sets the task's merge_title to the confirmed title, then starts the same agent turn as merge-in, without its invitation to revise the title. 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. The title is fixed from here until the task closes. Refused while a turn is running, or the merge is already held. */
|
||||
/** @description Sets the task's merge_title to the confirmed title, then starts the same agent turn as merge-in, without its invitation to revise the title. 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. The title is fixed from here until the task closes. Refused while a turn is running, or the merge is already held. With target parent, for a subtask only, the subtask's branch is merged into its parent task's branch instead: the parent's agent is asked to merge it (queued if it is mid-turn), and the subtask closes as merged once its branch is in the parent's HEAD at the end of one of the parent's turns (merging_into_parent is set until then). Refused while the subtask's turn is running or it is already merging into its parent. Either target is refused while the task has active subtasks of its own. */
|
||||
post: operations["mergeTask"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -320,7 +320,7 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Gives the task's agent one dedicated turn to write the task summary, started at once when its session is idle (or in a new session when it has none) and otherwise as soon as the running turn ends, with no other turn admitted meanwhile; drops any held merge; and when that turn ends however it ends, removes the task's worktree and branch without merging and marks the task discarded. The task's record, summary and events remain. Refused while the task is merging or already being discarded; on a task whose cleanup failed, retries the cleanup. */
|
||||
/** @description Gives the task's agent one dedicated turn to write the task summary, started at once when its session is idle (or in a new session when it has none) and otherwise as soon as the running turn ends, with no other turn admitted meanwhile; drops any held merge; and when that turn ends however it ends, removes the task's worktree and branch without merging and marks the task discarded. The task's record, summary and events remain. Refused while the task is merging, already being discarded, or has active subtasks; on a task whose cleanup failed, retries the cleanup. */
|
||||
post: operations["discardTask"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -878,6 +878,10 @@ export interface components {
|
||||
merging: boolean;
|
||||
/** @description Whether a Discard was started and the task isn't closed yet: its summary turn is waiting for the running turn, running, or done and the task is being removed. */
|
||||
discarding: boolean;
|
||||
/** @description The task whose agent created this one as its subtask; absent for other tasks. */
|
||||
parent_task_id?: string;
|
||||
/** @description Whether a merge of this subtask into its parent was requested and the parent hasn't merged it yet. */
|
||||
merging_into_parent: boolean;
|
||||
};
|
||||
Todo: {
|
||||
/** @description Whether the default branch has a TODO.md at its root. */
|
||||
@@ -1766,8 +1770,13 @@ export interface operations {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description The squash commit's title: one line, at most 72 characters. */
|
||||
merge_title: string;
|
||||
/** @description The squash commit's title: one line, at most 72 characters. Required for target main. */
|
||||
merge_title?: string;
|
||||
/**
|
||||
* @description main (the default): squash onto the project's default branch. parent: merge into the parent task's branch; only for a subtask.
|
||||
* @enum {string}
|
||||
*/
|
||||
target?: "main" | "parent";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
+21
-3
@@ -2,7 +2,7 @@ import { get, post, type ByreEvent, type Project, type Schemas, type Task } from
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { models } from "./task.js";
|
||||
import { currentSession, focusedProject, focusTask, on, onEach, state, taskState } from "./state.js";
|
||||
import { currentSession, findTask, focusedProject, focusTask, on, onEach, state, taskState } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
|
||||
type Todo = Schemas["Todo"];
|
||||
@@ -106,6 +106,21 @@ function projectBlock(project: Project, todo: Todo | string | undefined, startin
|
||||
);
|
||||
}
|
||||
|
||||
function nested(active: Task[]): { task: Task; depth: number }[] {
|
||||
const ids = new Set(active.map((t) => t.id));
|
||||
const out: { task: Task; depth: number }[] = [];
|
||||
const add = (task: Task, depth: number) => {
|
||||
out.push({ task, depth });
|
||||
for (const child of active.filter((t) => t.parent_task_id === task.id).reverse()) {
|
||||
add(child, depth + 1);
|
||||
}
|
||||
};
|
||||
for (const task of active.filter((t) => t.parent_task_id === undefined || !ids.has(t.parent_task_id))) {
|
||||
add(task, 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tasksBlock(active: Task[]): HTMLElement {
|
||||
if (active.length === 0) {
|
||||
return h("p", { class: "empty-row" }, "No active tasks.");
|
||||
@@ -113,12 +128,13 @@ function tasksBlock(active: Task[]): HTMLElement {
|
||||
return h(
|
||||
"ul",
|
||||
{ class: "task-list" },
|
||||
...active.map((task) => {
|
||||
...nested(active).map(({ task, depth }) => {
|
||||
const ts = taskState(task);
|
||||
const count = state.inbox.filter((item) => item.task_id === task.id).length;
|
||||
const classes = [task.id === state.selectedTaskId && "selected", task.parent_task_id !== undefined && "subtask"].filter(Boolean).join(" ");
|
||||
return h(
|
||||
"li",
|
||||
{ class: task.id === state.selectedTaskId ? "selected" : "", style: taskStyle(task.id) },
|
||||
{ class: classes, style: `${taskStyle(task.id)}; --depth: ${depth}` },
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
@@ -128,10 +144,12 @@ function tasksBlock(active: Task[]): HTMLElement {
|
||||
focusTask(task.id);
|
||||
},
|
||||
},
|
||||
task.parent_task_id !== undefined && h("span", { class: "subtask-mark", title: `Subtask of ${findTask(task.parent_task_id)?.title ?? task.parent_task_id}` }, "↳"),
|
||||
h("span", { class: `dot ${ts ?? "none"}`, title: ts ?? "no session" }),
|
||||
h("span", { class: "task-title task-name" }, task.title),
|
||||
count > 0 && h("span", { class: "badge", title: "inbox items" }, count),
|
||||
task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"),
|
||||
task.merging_into_parent && h("span", { class: "task-state warn", title: "Waiting for the parent task's agent to merge it" }, "merging into parent"),
|
||||
h("span", { class: "task-state" }, ts ?? "no agent"),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -312,6 +312,8 @@ export function handleEvent(ev: ByreEvent): void {
|
||||
case "task.merge_title":
|
||||
case "task.merge_queued":
|
||||
case "task.merge_unqueued":
|
||||
case "task.parent_merge_started":
|
||||
case "task.parent_merge_stopped":
|
||||
case "task.merge_started":
|
||||
case "task.merge_ended":
|
||||
case "task.discard_started":
|
||||
|
||||
+10
-2
@@ -1,7 +1,7 @@
|
||||
import { post } from "./api.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { focusLead } from "./panes.js";
|
||||
import { activeTasks, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state, taskState } from "./state.js";
|
||||
import { activeTasks, findTask, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state, taskState } from "./state.js";
|
||||
import { confirmMerge, models } from "./task.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
|
||||
@@ -149,9 +149,17 @@ export function openSwitcher(): void {
|
||||
"li",
|
||||
{ style: taskStyle(task.id) },
|
||||
h("span", { class: `dot ${ts ?? "none"}`, title: ts ?? "no session" }),
|
||||
h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", h("span", { class: "task-name" }, task.title)),
|
||||
h(
|
||||
"span",
|
||||
{ class: "task-title" },
|
||||
h("span", { class: "project-name" }, project.name),
|
||||
" ",
|
||||
task.parent_task_id !== undefined && h("span", { class: "subtask-mark" }, `${findTask(task.parent_task_id)?.title ?? task.parent_task_id} ↳ `),
|
||||
h("span", { class: "task-name" }, task.title),
|
||||
),
|
||||
count > 0 && h("span", { class: "badge", title: "inbox items" }, count),
|
||||
task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"),
|
||||
task.merging_into_parent && h("span", { class: "task-state warn", title: "Waiting for the parent task's agent to merge it" }, "merging into parent"),
|
||||
h("span", { class: "task-state" }, ts ?? "no agent"),
|
||||
);
|
||||
return { el, pick: () => go(task.id) };
|
||||
|
||||
+87
-30
@@ -155,16 +155,21 @@ function taskHead(rerender: () => void): Head {
|
||||
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
|
||||
const busy = sess?.state === "running";
|
||||
const held = task.merge_queued_at !== undefined;
|
||||
const intoParent = task.merging_into_parent;
|
||||
const merge = h(
|
||||
"button",
|
||||
{
|
||||
class: "merge-button",
|
||||
disabled: busy || held || unmerged?.taskId !== task.id || !unmerged.has,
|
||||
title: held
|
||||
? "Waiting for the merge in progress in this project to finish"
|
||||
: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin; held while another task in the project is merging (⌘S)`,
|
||||
disabled: busy || held || intoParent || unmerged?.taskId !== task.id || !unmerged.has,
|
||||
title: intoParent
|
||||
? "Waiting for the parent task's agent to merge this subtask"
|
||||
: held
|
||||
? "Waiting for the merge in progress in this project to finish"
|
||||
: task.parent_task_id !== undefined
|
||||
? `Merge this subtask into its parent task's branch, or into ${target} (⌘S)`
|
||||
: `The agent merges ${target} in, then byre squashes into one commit on ${target} and pushes it to origin; held while another task in the project is merging (⌘S)`,
|
||||
},
|
||||
held ? "Merge held" : "Merge",
|
||||
intoParent ? "Merging into parent" : held ? "Merge held" : "Merge",
|
||||
);
|
||||
merge.onclick = () => confirmMerge(task.id);
|
||||
const mergeIn = h("button", { disabled: busy, title: `Ask the agent to bring ${target} into this branch and resolve any conflicts` }, `Sync from ${target}`);
|
||||
@@ -1068,30 +1073,28 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
|
||||
}
|
||||
|
||||
case "task.created": {
|
||||
if (typeof p["forked_from"] !== "string") {
|
||||
return null;
|
||||
if (typeof p["forked_from"] === "string") {
|
||||
return h(
|
||||
"div",
|
||||
{ class: "note" },
|
||||
`${ev.actor} forked this task from `,
|
||||
taskLink(p["forked_from"]),
|
||||
` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `,
|
||||
when,
|
||||
);
|
||||
}
|
||||
const src = findTask(p["forked_from"]);
|
||||
const title = src?.title ?? p["forked_from"];
|
||||
const link =
|
||||
src?.status === "active"
|
||||
? h(
|
||||
"a",
|
||||
{
|
||||
href: `#/task/${src.id}`,
|
||||
class: "task-link",
|
||||
style: taskStyle(src.id),
|
||||
onclick: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
focusTask(src.id);
|
||||
},
|
||||
},
|
||||
title,
|
||||
)
|
||||
: title;
|
||||
return h("div", { class: "note" }, `${ev.actor} forked this task from `, link, ` at snapshot #${String(p["forked_at"])}; its agent continues that task's conversation as it stood there `, when);
|
||||
if (typeof p["parent_task_id"] === "string") {
|
||||
return h("div", { class: "note" }, "subtask of ", taskLink(p["parent_task_id"]), ", created by its agent ", when);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "task.parent_merge_started":
|
||||
return h("div", { class: "note" }, `${ev.actor} asked `, taskLink(String(p["parent_task_id"])), "'s agent to merge this subtask into its branch ", when);
|
||||
|
||||
case "task.parent_merge_stopped":
|
||||
return h("div", { class: "note error" }, "the merge into the parent task didn't start ", when);
|
||||
|
||||
case "task.merge_title":
|
||||
return h("div", { class: "note" }, `${ev.actor} set the merge commit title to "${String(p["merge_title"])}" `, when);
|
||||
|
||||
@@ -1113,6 +1116,9 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
|
||||
}
|
||||
|
||||
case "task.merged":
|
||||
if (typeof p["parent_task_id"] === "string") {
|
||||
return h("div", { class: "note" }, `${ev.actor} merged the task into `, taskLink(p["parent_task_id"]), `, now at ${String(p["commit"]).slice(0, 7)} `, when);
|
||||
}
|
||||
return h(
|
||||
"div",
|
||||
{ class: "note" },
|
||||
@@ -1133,6 +1139,26 @@ function renderEvent(ev: ByreEvent, ctx: Ctx): HTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function taskLink(id: string): Node {
|
||||
const task = findTask(id);
|
||||
if (task?.status !== "active") {
|
||||
return document.createTextNode(task?.title ?? id);
|
||||
}
|
||||
return h(
|
||||
"a",
|
||||
{
|
||||
href: `#/task/${task.id}`,
|
||||
class: "task-link",
|
||||
style: taskStyle(task.id),
|
||||
onclick: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
focusTask(task.id);
|
||||
},
|
||||
},
|
||||
task.title,
|
||||
);
|
||||
}
|
||||
|
||||
function messageImages(ids: unknown): HTMLElement | null {
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return null;
|
||||
@@ -1161,7 +1187,12 @@ export async function confirmMerge(taskId: string): Promise<void> {
|
||||
void showAlert("Merge already held behind another merge in this project");
|
||||
return;
|
||||
}
|
||||
if (task.merging_into_parent) {
|
||||
void showAlert("Already waiting for the parent task's agent to merge this subtask");
|
||||
return;
|
||||
}
|
||||
const target = state.projects.find((p) => p.id === task.project_id)?.default_branch ?? "the default branch";
|
||||
const parent = task.parent_task_id !== undefined ? findTask(task.parent_task_id) : undefined;
|
||||
let coAuthors;
|
||||
try {
|
||||
const [unmerged, people] = await Promise.all([
|
||||
@@ -1196,11 +1227,36 @@ export async function confirmMerge(taskId: string): Promise<void> {
|
||||
field.select();
|
||||
};
|
||||
const warnings = h("ul", { class: "merge-warnings" });
|
||||
const question: Node[] = [h("p", { class: "merge-into" }, `Merge into ${target} as:`), row, warnings];
|
||||
const into = h("p", { class: "merge-into" }, `Merge into ${target} as:`);
|
||||
const co = coAuthorsSummary(coAuthors);
|
||||
if (co) {
|
||||
question.push(co);
|
||||
const toMain: Node[] = co ? [into, row, co] : [into, row];
|
||||
let toParent = false;
|
||||
const question: Node[] = [];
|
||||
if (parent?.status === "active") {
|
||||
toParent = true;
|
||||
const option = (value: boolean, label: string, hint: string) => {
|
||||
const radio = h("input", { type: "radio", name: "merge-target", checked: value === toParent });
|
||||
radio.onchange = () => {
|
||||
toParent = value;
|
||||
for (const n of toMain) {
|
||||
(n as HTMLElement).hidden = toParent;
|
||||
}
|
||||
};
|
||||
return h("label", { class: "merge-target" }, radio, h("span", {}, label, h("span", { class: "merge-target-hint" }, hint)));
|
||||
};
|
||||
question.push(
|
||||
h(
|
||||
"div",
|
||||
{ class: "merge-targets" },
|
||||
option(true, `Into ${parent.title}`, "Its agent merges this subtask's branch into the parent's branch; the subtask closes once it has."),
|
||||
option(false, `Into ${target}`, "Squashed into one commit on the default branch and pushed, like any task."),
|
||||
),
|
||||
);
|
||||
for (const n of toMain) {
|
||||
(n as HTMLElement).hidden = true;
|
||||
}
|
||||
}
|
||||
question.push(...toMain.slice(0, 2), warnings, ...toMain.slice(2));
|
||||
const leave = (go: () => void) => {
|
||||
mergeDrafts.set(taskId, input?.value ?? title);
|
||||
warnings.closest("dialog")?.close("cancel");
|
||||
@@ -1210,7 +1266,8 @@ export async function confirmMerge(taskId: string): Promise<void> {
|
||||
await watch.ready;
|
||||
try {
|
||||
await confirmThen(question, "Merge", false, document.querySelector(".merge-button"), async () => {
|
||||
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body: { merge_title: input?.value ?? title } });
|
||||
const body = toParent ? { target: "parent" as const } : { merge_title: input?.value ?? title };
|
||||
await post("/api/tasks/{taskId}/merge", { path: { taskId }, body });
|
||||
});
|
||||
} finally {
|
||||
watch.dispose();
|
||||
|
||||
Reference in New Issue
Block a user