feat: strengthen task workflow and compaction recovery

This commit is contained in:
lesetong
2026-07-23 01:23:10 +08:00
parent fe05b278a4
commit 7d876735b2
13 changed files with 380 additions and 81 deletions
+22
View File
@@ -0,0 +1,22 @@
import type { Task } from "./types.ts";
import { orderedTreeRows } from "./ui.ts";
export const COMPACTION_REMINDER_TYPE = "pi-todo-herdr-compaction";
export const COMPACTION_REMINDER =
"Active tasks exist. Call list_task now; treat its result as authoritative.";
export const TASK_PROMPT_GUIDELINES = [
"For large, multi-stage work, call set_tasks before exploration as the only first-batch tool. Use append by default and replace only for unrelated finished or explicitly abandoned work. Start one task in-progress; skip smaller work.",
"Use update_task when tracked work starts, blocks, or finishes; reconcile the tree before the final response.",
];
export function formatCompactTaskTree(tasks: Task[]): string {
if (tasks.length === 0) return "No tasks.";
return orderedTreeRows(tasks)
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
.join("\n");
}
export function hasUnfinishedTasks(tasks: Task[]): boolean {
return tasks.some((task) => task.status !== "done");
}
+30 -5
View File
@@ -10,13 +10,13 @@ import type {
} from "./types.ts";
import { TaskValidationError } from "./types.ts";
const MUTATION_TOOLS = new Set(["add_task", "update_task", "rm_task"]);
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]);
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
interface TaskRecord {
id: number;
name: string;
description: string;
description?: string;
status: TaskStatus;
parentId: number | null;
}
@@ -63,7 +63,9 @@ function validateGraph(records: TaskRecord[]): string[] {
ids.add(task.id);
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`);
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
if (task.description !== undefined && !task.description.trim()) {
errors.push(`task ${task.id}: description must not be empty`);
}
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
if (task.parentId !== null && !byId.has(task.parentId)) {
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
@@ -191,7 +193,7 @@ export class TaskState {
}
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
if (typeof draft.description !== "string" || !draft.description.trim()) {
if (draft.description !== undefined && (typeof draft.description !== "string" || !draft.description.trim())) {
errors.push(`${path}.description must not be empty`);
}
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
@@ -214,7 +216,7 @@ export class TaskState {
working.push({
id,
name: typeof draft.name === "string" ? draft.name.trim() : "",
description: typeof draft.description === "string" ? draft.description.trim() : "",
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
status: draft.status ?? "pending",
parentId,
});
@@ -233,6 +235,29 @@ export class TaskState {
return this.get(addedIds);
}
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
if (mode === "append") {
if (force !== undefined) throw new TaskValidationError(["force is allowed only in replace mode"]);
return this.add(drafts);
}
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
if (!Array.isArray(drafts)) throw new TaskValidationError(["tasks must be an array"]);
const errors: string[] = [];
if (this.records.some((task) => task.status !== "done") && force !== true) {
errors.push("replace requires force when unfinished tasks exist");
}
drafts.forEach((draft, index) => {
if (draft?.parentId !== undefined) errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
});
if (errors.length > 0) throw new TaskValidationError(errors);
const replacement = new TaskState();
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
this.restore(replacement.snapshot());
return affected;
}
update(patches: TaskPatch[]): Task[] {
const errors: string[] = [];
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
+70 -40
View File
@@ -2,24 +2,33 @@ import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
import type { TaskState } from "./state.ts";
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
import { TaskValidationError } from "./types.ts";
import { orderedTreeRows, renderToolResult } from "./ui.ts";
import { renderToolResult } from "./ui.ts";
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
description: "Task status. Defaults to pending when omitted during add.",
description: "Task status. Defaults to pending.",
});
const SetTaskModeSchema = StringEnum(["append", "replace"] as const);
const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] as const);
const AddTaskSchema = Type.Cyclic(
{
AddTask: Type.Object(
{
name: Type.String({ minLength: 1 }),
description: Type.String({ minLength: 1 }),
description: Type.Optional(
Type.String({
minLength: 1,
description: "Detailed scope, constraints, and done criteria. Include when useful.",
}),
),
status: Type.Optional(TaskStatusSchema),
parentId: Type.Optional(
Type.Integer({ minimum: 1, description: "Existing parent ID. Allowed only on top-level tasks." }),
Type.Integer({ minimum: 1, description: "Existing parent ID; append mode and top-level tasks only." }),
),
children: Type.Optional(Type.Array(Type.Ref("AddTask"))),
},
@@ -29,9 +38,15 @@ const AddTaskSchema = Type.Cyclic(
"AddTask",
);
export const AddTaskParams = Type.Object(
export const SetTaskParams = Type.Object(
{
tasks: Type.Array(AddTaskSchema, { minItems: 1, description: "Tasks to add in one atomic batch." }),
mode: SetTaskModeSchema,
tasks: Type.Array(AddTaskSchema, {
description: "Tasks to append or install. May be empty only in replace mode.",
}),
force: Type.Optional(
Type.Boolean({ description: "Use only when the user explicitly abandons unfinished tasks." }),
),
},
{ additionalProperties: false },
);
@@ -61,20 +76,27 @@ export const UpdateTaskParams = Type.Object(
{ additionalProperties: false },
);
export const IdsParams = Type.Object(
const TaskIdsSchema = Type.Array(Type.Integer({ minimum: 1 }), {
minItems: 1,
description: "Task IDs to process. Duplicate IDs are deduplicated.",
});
export const RemoveTaskParams = Type.Object(
{
ids: Type.Array(Type.Integer({ minimum: 1 }), {
minItems: 1,
description: "Task IDs to process in one call. Duplicate IDs are deduplicated.",
}),
ids: TaskIdsSchema,
reason: RemovalReasonSchema,
},
{ additionalProperties: false },
);
export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalProperties: false });
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
interface AddTaskInput {
interface SetTaskInput {
mode: "append" | "replace";
tasks: AddTaskDraft[];
force?: boolean;
}
interface UpdateTaskInput {
@@ -85,19 +107,16 @@ interface IdsInput {
ids: number[];
}
interface RemoveTaskInput extends IdsInput {
reason: "mistaken" | "duplicate" | "canceled";
}
type ChangeHandler = (ctx: ExtensionContext) => void;
function taskIds(tasks: Task[]): string {
return tasks.map((task) => task.id).join(", ");
}
function compactTree(tasks: Task[]): string {
if (tasks.length === 0) return "No tasks.";
return orderedTreeRows(tasks)
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
.join("\n");
}
function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails {
return { action, snapshot: state.snapshot(), affected };
}
@@ -107,6 +126,13 @@ function mutationText(verb: string, affected: Task[], state: TaskState): string
return `${verb} ${affected.length} task${affected.length === 1 ? "" : "s"}: ${taskIds(affected)}. ${stats.done}/${stats.total} done.`;
}
function setTaskText(mode: SetTaskInput["mode"], affected: Task[], state: TaskState): string {
if (mode === "append") return mutationText("Added", affected, state);
if (affected.length === 0) return "Cleared task tree. Task IDs reset to 1.";
const stats = state.stats();
return `Replaced task tree with ${affected.length} task${affected.length === 1 ? "" : "s"}. ${stats.done}/${stats.total} done. Task IDs restart at 1.`;
}
function throwToolError(label: string, error: unknown): never {
if (error instanceof TaskValidationError) {
const count = error.errors.length;
@@ -132,28 +158,26 @@ function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext): void {
export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void {
pi.registerTool({
name: "add_task",
label: "Add Tasks",
name: "set_tasks",
label: "Set Tasks",
description:
"Atomically add one or more tasks. Use nested children for new descendants and parentId on top-level items for an existing parent.",
promptGuidelines: [
"Use add_task for complex multi-step work; use update_task immediately when tasks start, block, resume, or finish; skip task tracking for simple one-step work.",
],
parameters: AddTaskParams,
async execute(_toolCallId, params: AddTaskInput, _signal, _onUpdate, ctx) {
"Atomically append tasks or replace the task tree. Replace may clear with an empty array; force is required to discard unfinished tasks.",
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
parameters: SetTaskParams,
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
try {
const affected = state.add(params.tasks);
const affected = state.set(params.mode, params.tasks, params.force);
notifyChange(onChange, ctx);
return {
content: [{ type: "text" as const, text: mutationText("Added", affected, state) }],
details: details("add", state, affected),
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
details: details(params.mode, state, affected),
};
} catch (error) {
throwToolError("Add", error);
throwToolError("Set", error);
}
},
renderCall(args: AddTaskInput, theme: Theme) {
return renderCall("Add Tasks", args.tasks?.length, theme);
renderCall(args: SetTaskInput, theme: Theme) {
return renderCall(`Set Tasks · ${args.mode}`, args.tasks?.length, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
@@ -189,22 +213,28 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
pi.registerTool({
name: "rm_task",
label: "Remove Tasks",
description: "Atomically remove multiple task IDs. Removing a parent also removes its entire descendant subtree.",
parameters: IdsParams,
async execute(_toolCallId, params: IdsInput, _signal, _onUpdate, ctx) {
description:
"Remove tasks only when mistaken, duplicate, or canceled. Removing a parent also removes its descendant subtree.",
parameters: RemoveTaskParams,
async execute(_toolCallId, params: RemoveTaskInput, _signal, _onUpdate, ctx) {
try {
const affected = state.remove(params.ids);
notifyChange(onChange, ctx);
return {
content: [{ type: "text" as const, text: mutationText("Removed", affected, state) }],
content: [
{
type: "text" as const,
text: `${mutationText("Removed", affected, state)} Reason: ${params.reason}.`,
},
],
details: details("remove", state, affected),
};
} catch (error) {
throwToolError("Remove", error);
}
},
renderCall(args: IdsInput, theme: Theme) {
return renderCall("Remove Tasks", args.ids?.length, theme);
renderCall(args: RemoveTaskInput, theme: Theme) {
return renderCall(`Remove Tasks · ${args.reason}`, args.ids?.length, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
@@ -219,7 +249,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
async execute() {
const affected = state.list();
return {
content: [{ type: "text" as const, text: compactTree(affected) }],
content: [{ type: "text" as const, text: formatCompactTaskTree(affected) }],
details: details("list", state, affected),
};
},
+3 -3
View File
@@ -3,7 +3,7 @@ export type TaskStatus = "pending" | "in-progress" | "blocked" | "done";
export interface Task {
id: number;
name: string;
description: string;
description?: string;
status: TaskStatus;
parentId: number | null;
childrenIds: number[];
@@ -16,7 +16,7 @@ export interface TaskSnapshot {
export interface AddTaskDraft {
name: string;
description: string;
description?: string;
status?: TaskStatus;
parentId?: number;
children?: AddTaskDraft[];
@@ -40,7 +40,7 @@ export interface TaskStats {
}
export interface TaskToolDetails {
action: "add" | "update" | "remove" | "list" | "get";
action: "add" | "append" | "replace" | "update" | "remove" | "list" | "get";
snapshot: TaskSnapshot;
affected: Task[];
errors?: string[];
+11 -6
View File
@@ -103,7 +103,10 @@ function summaryFor(details: TaskToolDetails): string {
const progress = `${stats.done}/${stats.total} done`;
switch (details.action) {
case "add":
case "append":
return `${details.affected.length} added · ${progress}`;
case "replace":
return stats.total === 0 ? "task tree cleared" : `${stats.total} installed · ${progress}`;
case "update":
return `${details.affected.length} updated · ${progress}`;
case "remove":
@@ -137,12 +140,14 @@ function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[]
const parent = task.parentId === null ? "root" : `#${task.parentId}`;
const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", ");
lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width));
const prefix = " ";
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
for (const sourceLine of task.description.split("\n")) {
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
if (wrapped.length === 0) lines.push(prefix);
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
if (task.description) {
const prefix = " ";
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
for (const sourceLine of task.description.split("\n")) {
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
if (wrapped.length === 0) lines.push(prefix);
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
}
}
}
return lines;