Files
shockbot/utils/todoTracking.ts
T
Colin McDonnell b282e8b599 Improve incremental review output and fix todo tracker race (#529)
* improve incremental review output and fix todo tracker race

- reviewed changes section: summarize at logical-change level with
  past-tense verbs, not per-file enumerations
- add TodoTracker.completeAll() to mark all non-cancelled items as
  completed before snapshotting the collapsible in review/progress posts

Made-with: Cursor

* completeAll -> completeInProgress: only mark in-progress items as completed

Pending items that were genuinely skipped stay as-is in the collapsible,
so the task list honestly reflects what the agent actually did.

Made-with: Cursor
2026-04-10 20:15:34 +00:00

163 lines
5.0 KiB
TypeScript

import { log } from "./log.ts";
type TodoItem = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
};
function isValidTodoStatus(value: string): value is TodoItem["status"] {
return (
value === "pending" || value === "in_progress" || value === "completed" || value === "cancelled"
);
}
function parseTodowriteInput(input: unknown): { todos: unknown[]; merge: boolean } | undefined {
if (!input || typeof input !== "object" || !("todos" in input)) return undefined;
if (!Array.isArray(input.todos)) return undefined;
const merge = "merge" in input && input.merge === true;
return { todos: input.todos, merge };
}
function parseTodoItem(entry: unknown, index: number): TodoItem | undefined {
if (!entry || typeof entry !== "object") return undefined;
if (!("content" in entry) || typeof entry.content !== "string") return undefined;
const id = "id" in entry && typeof entry.id === "string" ? entry.id : String(index);
const status =
"status" in entry && typeof entry.status === "string" && isValidTodoStatus(entry.status)
? entry.status
: "pending";
return { id, content: entry.content, status };
}
function renderTodoMarkdown(todos: TodoItem[]): string {
return todos
.map((todo) => {
switch (todo.status) {
case "completed":
return `- [x] ${todo.content}`;
case "cancelled":
return `- ~~${todo.content}~~`;
case "in_progress":
return `- [ ] <img src="https://uploads.pullfrog.com/Progress%20Indicator.gif" width="11" style="visibility: visible; max-width: 100%;" /> ${todo.content}`;
case "pending":
return `- [ ] ${todo.content}`;
default:
todo.status satisfies never;
return `- [ ] ${todo.content}`;
}
})
.join("\n");
}
export type TodoTracker = {
update: (input: unknown) => void;
flush: () => Promise<void>;
cancel: () => void;
/** resolves when any in-flight onUpdate call completes */
settled: () => Promise<void>;
/** mark in-progress items as completed (for final snapshot before review/progress post) */
completeInProgress: () => void;
renderCollapsible: () => string;
readonly enabled: boolean;
/** true after the tracker has successfully called onUpdate at least once */
readonly hasPublished: boolean;
};
const DEBOUNCE_MS = 2000;
export function createTodoTracker(onUpdate: (body: string) => Promise<void>): TodoTracker {
const state = new Map<string, TodoItem>();
let enabled = true;
let hasPublished = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let inflightPromise: Promise<void> = Promise.resolve();
function scheduleUpdate() {
if (!enabled) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
if (!enabled || state.size === 0) return;
const markdown = renderTodoMarkdown(Array.from(state.values()));
inflightPromise = inflightPromise
.then(async () => {
if (!enabled) return;
await onUpdate(markdown);
hasPublished = true;
})
.catch((err) => {
log.debug(`todo progress update failed: ${err}`);
});
}, DEBOUNCE_MS);
}
return {
update(input: unknown) {
if (!enabled) return;
const parsed = parseTodowriteInput(input);
if (!parsed) return;
if (!parsed.merge) state.clear();
for (const [index, entry] of parsed.todos.entries()) {
const item = parseTodoItem(entry, index);
if (item) state.set(item.id, item);
}
log.debug(`» todowrite: ${state.size} items tracked`);
scheduleUpdate();
},
async flush() {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
if (!enabled || state.size === 0) return;
const markdown = renderTodoMarkdown(Array.from(state.values()));
inflightPromise = inflightPromise
.then(async () => {
if (!enabled) return;
await onUpdate(markdown);
hasPublished = true;
})
.catch((err) => {
log.debug(`todo progress flush failed: ${err}`);
});
await inflightPromise;
},
cancel() {
enabled = false;
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
},
async settled() {
await inflightPromise;
},
completeInProgress() {
for (const item of state.values()) {
if (item.status === "in_progress") item.status = "completed";
}
},
renderCollapsible(): string {
if (state.size === 0) return "";
const todos = Array.from(state.values());
const completed = todos.filter((t) => t.status === "completed").length;
const markdown = renderTodoMarkdown(todos);
return `<details>\n<summary>Task list (${completed}/${todos.length} completed)</summary>\n\n${markdown}\n\n</details>`;
},
get enabled() {
return enabled;
},
get hasPublished() {
return hasPublished;
},
};
}