fix: major cleanup - verify cache, section cache, rate limit retry, split sweep from cleanup

- Separate cleanupCompletedMyTasks (fast, no Pi) from sweepIncompleteTasks (with Pi verify)
- Cache verified-not-done tasks for 30min to avoid re-checking every cycle
- Cache project sections for 5min to reduce API calls
- Add rate limit (429) retry with Retry-After header
- Add server error (5xx) retry
- Remove assignee:null - only use assignee_status:later
- Keep tasks assigned to user always
This commit is contained in:
Azreen Jamal
2026-03-06 20:40:31 +08:00
parent fbeb79264a
commit ea0ba4ee10
2 changed files with 223 additions and 204 deletions
+27 -10
View File
@@ -16,20 +16,37 @@ export class AsanaClient {
}; };
} }
private async request<T>(path: string, options: RequestInit = {}): Promise<T> { private async request<T>(path: string, options: RequestInit = {}, retries = 3): Promise<T> {
const url = `${ASANA_BASE}${path}`; const url = `${ASANA_BASE}${path}`;
const res = await fetch(url, {
...options,
headers: { ...this.headers, ...options.headers },
});
if (!res.ok) { for (let attempt = 1; attempt <= retries; attempt++) {
const body = await res.text(); const res = await fetch(url, {
throw new Error(`Asana API ${res.status}: ${body}`); ...options,
headers: { ...this.headers, ...options.headers },
});
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get("Retry-After") || "30");
logger.warn("asana", `Rate limited, retrying in ${retryAfter}s (attempt ${attempt}/${retries})`);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
if (!res.ok) {
const body = await res.text();
if (attempt < retries && res.status >= 500) {
logger.warn("asana", `Server error ${res.status}, retrying (attempt ${attempt}/${retries})`);
await new Promise((r) => setTimeout(r, 2000 * attempt));
continue;
}
throw new Error(`Asana API ${res.status}: ${body}`);
}
const json = (await res.json()) as { data: T };
return json.data;
} }
const json = (await res.json()) as { data: T }; throw new Error(`Asana API failed after ${retries} retries`);
return json.data;
} }
// Get all workspaces // Get all workspaces
+196 -194
View File
@@ -3,13 +3,12 @@ import { PiExecutor } from "../pi/executor.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { FileLock } from "./lock.js"; import { FileLock } from "./lock.js";
import type { Config } from "../config.js"; import type { Config } from "../config.js";
import type { AsanaTask } from "../asana/types.js"; import type { AsanaTask, AsanaSection } from "../asana/types.js";
// Section names in Asana board (searched in order, partial case-insensitive match)
const SECTIONS = { const SECTIONS = {
TODO: ["Pi Worker", "To Do", "New Bugs", "Ready for Development", "Recently Assigned", "Awaiting More Info"], TODO: ["Pi Worker", "To Do", "New Bugs", "Ready for Development", "Recently Assigned", "Awaiting More Info"],
IN_PROGRESS: ["In Progress", "Under Review"], IN_PROGRESS: ["In Progress", "Under Review"],
DONE: ["Done", "Resolved"], DONE: ["Done", "Resolved", "Complete", "Completed"],
}; };
export class TaskLoop { export class TaskLoop {
@@ -22,6 +21,14 @@ export class TaskLoop {
private userTaskListGid: string | null = null; private userTaskListGid: string | null = null;
private workspaceGid: string = ""; private workspaceGid: string = "";
// Cache: track tasks we already verified as "not done" to avoid re-checking
private verifiedNotDone: Map<string, number> = new Map(); // gid -> timestamp
private readonly VERIFY_CACHE_TTL = 30 * 60 * 1000; // 30 min
// Cache: sections per project
private sectionCache: Map<string, { sections: AsanaSection[]; ts: number }> = new Map();
private readonly SECTION_CACHE_TTL = 5 * 60 * 1000; // 5 min
constructor(config: Config) { constructor(config: Config) {
this.config = config; this.config = config;
this.asana = new AsanaClient(config.asanaAccessToken); this.asana = new AsanaClient(config.asanaAccessToken);
@@ -36,12 +43,10 @@ export class TaskLoop {
project: this.config.asanaProjectGid, project: this.config.asanaProjectGid,
}); });
// Initial discovery if no project GID configured
if (!this.config.asanaProjectGid) { if (!this.config.asanaProjectGid) {
await this.discoverProject(); await this.discoverProject();
} }
// Run immediately, then on interval
await this.poll(); await this.poll();
this.timer = setInterval(() => this.poll(), this.config.pollIntervalSec * 1000); this.timer = setInterval(() => this.poll(), this.config.pollIntervalSec * 1000);
} }
@@ -66,22 +71,18 @@ export class TaskLoop {
return; return;
} }
logger.info("task-loop", `Found ${workspaces.length} workspace(s)`, workspaces);
for (const ws of workspaces) { for (const ws of workspaces) {
const projects = await this.asana.getProjects(ws.gid); const projects = await this.asana.getProjects(ws.gid);
logger.info("task-loop", `Workspace "${ws.name}" has ${projects.length} project(s)`, projects); logger.info("task-loop", `Workspace "${ws.name}" has ${projects.length} project(s)`);
if (projects.length > 0 && !this.config.asanaProjectGid) { if (projects.length > 0 && !this.config.asanaProjectGid) {
// Use first project as default
this.config.asanaProjectGid = projects[0].gid; this.config.asanaProjectGid = projects[0].gid;
this.workspaceGid = ws.gid;
logger.info("task-loop", `Auto-selected project: ${projects[0].name} (${projects[0].gid})`); logger.info("task-loop", `Auto-selected project: ${projects[0].name} (${projects[0].gid})`);
} }
this.workspaceGid = ws.gid;
} }
// Cache user task list for "My Tasks" / "Recently Assigned" sweep // Cache user task list
try { try {
const me = await this.asana.getMe(); const me = await this.asana.getMe();
if (this.workspaceGid) { if (this.workspaceGid) {
@@ -100,18 +101,20 @@ export class TaskLoop {
private async poll() { private async poll() {
if (!this.running || !this.config.asanaProjectGid) return; if (!this.running || !this.config.asanaProjectGid) return;
if (!this.lock.acquire()) { if (!this.lock.acquire()) {
logger.debug("task-loop", "Skipping poll - another execution in progress"); logger.debug("task-loop", "Skipping poll another execution in progress");
return; return;
} }
try { try {
// First: sweep all sections for tasks that are already done // Step 1: Quick cleanup - move completed My Tasks to Done sections
await this.sweepDoneTasks(); await this.cleanupCompletedMyTasks();
// Then: pick up new tasks to execute // Step 2: Sweep incomplete tasks for already-done work (with caching)
await this.sweepIncompleteTasks();
// Step 3: Pick up new tasks to execute
logger.info("task-loop", "Polling for new Asana tasks..."); logger.info("task-loop", "Polling for new Asana tasks...");
const tasks = await this.getTasksFromSections(this.config.asanaProjectGid, SECTIONS.TODO);
let tasks = await this.getTasksFromSections(this.config.asanaProjectGid, SECTIONS.TODO);
if (tasks.length === 0) { if (tasks.length === 0) {
logger.info("task-loop", "No pending tasks found"); logger.info("task-loop", "No pending tasks found");
@@ -119,10 +122,8 @@ export class TaskLoop {
return; return;
} }
// Pick the first (highest priority) task
const task = tasks[0]; const task = tasks[0];
logger.info("task-loop", `Picked task: ${task.name}`, { gid: task.gid }); logger.info("task-loop", `Picked task: ${task.name}`, { gid: task.gid });
await this.executeTask(task); await this.executeTask(task);
} catch (error: any) { } catch (error: any) {
logger.error("task-loop", "Poll failed", { error: error.message }); logger.error("task-loop", "Poll failed", { error: error.message });
@@ -131,67 +132,172 @@ export class TaskLoop {
} }
} }
private async findFirstSection(candidates: string[]) { // Quick: just move completed tasks out of Recently Assigned. No Pi calls.
for (const name of candidates) { private async cleanupCompletedMyTasks() {
const section = await this.asana.findSection(this.config.asanaProjectGid, name); if (!this.userTaskListGid) return;
if (section) return section;
try {
const myTasks = await this.asana.getUserTasks(this.userTaskListGid);
const completedTasks = myTasks.filter((t) => t.completed);
if (completedTasks.length === 0) return;
logger.info("task-loop", `Cleaning ${completedTasks.length} completed tasks from My Tasks`);
for (const task of completedTasks) {
try {
// Move to Done section in the task's project
if (task.memberships && task.memberships.length > 0) {
for (const membership of task.memberships) {
const projectGid = membership.project?.gid;
const currentSection = membership.section?.name?.toLowerCase() || "";
if (currentSection.includes("done") || currentSection.includes("resolved") || currentSection.includes("complete") || currentSection.includes("won't")) {
continue;
}
if (projectGid) {
const doneSection = await this.findDoneSection(projectGid);
if (doneSection) {
await this.asana.moveTaskToSection(task.gid, doneSection.gid);
logger.info("task-loop", `Moved to ${doneSection.name}: ${task.name}`);
}
}
}
}
// Move out of Recently Assigned
await this.asana.updateTask(task.gid, { assignee_status: "later" });
} catch (e: any) {
logger.debug("task-loop", `Could not clean task ${task.name}: ${e.message}`);
}
}
} catch (error: any) {
logger.warn("task-loop", `My Tasks cleanup error: ${error.message}`);
}
}
// Sweep: verify incomplete tasks - are they already done? Uses cache to skip recently checked.
private async sweepIncompleteTasks() {
try {
const sweepSections = [...SECTIONS.TODO, ...SECTIONS.IN_PROGRESS];
const allTasks: AsanaTask[] = [];
for (const sectionName of sweepSections) {
const section = await this.findSectionCached(this.config.asanaProjectGid, sectionName);
if (!section) continue;
const tasks = await this.asana.getSectionTasks(section.gid);
allTasks.push(...tasks.filter((t) => !t.completed));
}
// Also get incomplete My Tasks
if (this.userTaskListGid) {
try {
const myTasks = await this.asana.getUserTasks(this.userTaskListGid);
allTasks.push(...myTasks.filter((t) => !t.completed));
} catch {}
}
// Deduplicate
const seen = new Set<string>();
const uniqueTasks = allTasks.filter((t) => {
if (seen.has(t.gid)) return false;
seen.add(t.gid);
return true;
});
// Filter out recently verified tasks (cache hit)
const now = Date.now();
const tasksToVerify = uniqueTasks.filter((t) => {
const lastCheck = this.verifiedNotDone.get(t.gid);
if (lastCheck && now - lastCheck < this.VERIFY_CACHE_TTL) return false;
return true;
});
if (tasksToVerify.length === 0) {
logger.debug("task-loop", `Sweep: ${uniqueTasks.length} tasks all recently verified, skipping`);
return;
}
logger.info("task-loop", `Sweeping ${tasksToVerify.length} tasks (${uniqueTasks.length - tasksToVerify.length} cached, skipped)`);
for (const task of tasksToVerify) {
const check = await this.verifyTaskAlreadyDone(task);
if (check.done) {
logger.info("task-loop", `Sweep: "${task.name}" is already done`, { evidence: check.evidence });
await this.asana.addComment(
task.gid,
`✅ Pi Worker sweep: This task is **already completed**.\n\nEvidence:\n${check.evidence}`
);
// Move to done section if task has project membership
if (task.memberships && task.memberships.length > 0) {
for (const membership of task.memberships) {
const projectGid = membership.project?.gid;
if (projectGid) {
const doneSection = await this.findDoneSection(projectGid);
if (doneSection) {
await this.asana.moveTaskToSection(task.gid, doneSection.gid);
}
}
}
}
await this.asana.updateTask(task.gid, { completed: true });
logger.info("task-loop", `Sweep: Marked done: ${task.name}`);
} else {
// Cache as "not done" to avoid re-verifying next cycle
this.verifiedNotDone.set(task.gid, now);
logger.debug("task-loop", `Sweep: "${task.name}" not yet done`);
}
}
// Clean expired cache entries
for (const [gid, ts] of this.verifiedNotDone) {
if (now - ts > this.VERIFY_CACHE_TTL) this.verifiedNotDone.delete(gid);
}
} catch (error: any) {
logger.error("task-loop", `Sweep error: ${error.message}`);
} }
return null;
} }
private async executeTask(task: AsanaTask) { private async executeTask(task: AsanaTask) {
try { try {
// STEP 1: Check if the work is already done // Step 1: Quick pre-verify
const alreadyDone = await this.verifyTaskAlreadyDone(task); const alreadyDone = await this.verifyTaskAlreadyDone(task);
if (alreadyDone.done) { if (alreadyDone.done) {
logger.info("task-loop", `Task already done: ${task.name}`, { evidence: alreadyDone.evidence }); logger.info("task-loop", `Task already done: ${task.name}`);
await this.asana.addComment(task.gid, `✅ Pi Worker verified this task is **already completed**.\n\nEvidence:\n${alreadyDone.evidence}`);
await this.asana.addComment( const doneSection = await this.findFirstSection(this.config.asanaProjectGid, SECTIONS.DONE);
task.gid, if (doneSection) await this.asana.moveTaskToSection(task.gid, doneSection.gid);
`✅ Pi Worker verified this task is **already completed** on the server.\n\nEvidence:\n${alreadyDone.evidence}`
);
// Move to Done and mark complete
const doneSection = await this.findFirstSection(SECTIONS.DONE);
if (doneSection) {
await this.asana.moveTaskToSection(task.gid, doneSection.gid);
}
await this.asana.updateTask(task.gid, { completed: true }); await this.asana.updateTask(task.gid, { completed: true });
logger.info("task-loop", `Task marked done (was already complete): ${task.name}`);
return; return;
} }
// STEP 2: Move to "In Progress" // Step 2: Move to In Progress
const inProgressSection = await this.findFirstSection(SECTIONS.IN_PROGRESS); const inProgressSection = await this.findFirstSection(this.config.asanaProjectGid, SECTIONS.IN_PROGRESS);
if (inProgressSection) { if (inProgressSection) await this.asana.moveTaskToSection(task.gid, inProgressSection.gid);
await this.asana.moveTaskToSection(task.gid, inProgressSection.gid);
}
// Add starting comment await this.asana.addComment(task.gid, `🤖 Pi Worker picked up this task.\nStarting execution at ${new Date().toISOString()}`);
await this.asana.addComment(task.gid, `🤖 Pi Worker picked up this task.\nVerification: Not yet done on server.\nStarting execution at ${new Date().toISOString()}`);
// Build context from task
const context = this.buildTaskContext(task); const context = this.buildTaskContext(task);
// STEP 3: Execute with Pi agent // Step 3: Execute
const result = await this.pi.execute(task.name + (task.notes ? `\n\nDetails:\n${task.notes}` : ""), context); const result = await this.pi.execute(task.name + (task.notes ? `\n\nDetails:\n${task.notes}` : ""), context);
// STEP 4: After execution, verify the work was actually done // Step 4: Post-verify
let verified = false; let verified = false;
if (result.success && result.toolCalls > 0) { if (result.success && result.toolCalls > 0) {
const postCheck = await this.verifyTaskAlreadyDone(task); const postCheck = await this.verifyTaskAlreadyDone(task);
verified = postCheck.done; verified = postCheck.done;
if (!verified) {
logger.warn("task-loop", `Task executed but verification failed: ${task.name}`);
}
} }
// Add completion comment
const statusEmoji = result.success ? (verified ? "✅" : "⚠️") : "❌"; const statusEmoji = result.success ? (verified ? "✅" : "⚠️") : "❌";
const verificationNote = result.success const verificationNote = result.success
? (verified ? "Post-execution verification: PASSED" : "Post-execution verification: COULD NOT CONFIRM - please review manually") ? (verified ? "Post-execution verification: PASSED" : "Post-execution verification: COULD NOT CONFIRM please review manually")
: ""; : "";
const comment = [ const comment = [
@@ -210,18 +316,13 @@ export class TaskLoop {
await this.asana.addComment(task.gid, comment); await this.asana.addComment(task.gid, comment);
if (result.success) { if (result.success) {
const doneSection = await this.findFirstSection(SECTIONS.DONE); const doneSection = await this.findFirstSection(this.config.asanaProjectGid, SECTIONS.DONE);
if (doneSection) { if (doneSection) await this.asana.moveTaskToSection(task.gid, doneSection.gid);
await this.asana.moveTaskToSection(task.gid, doneSection.gid);
}
await this.asana.updateTask(task.gid, { completed: true }); await this.asana.updateTask(task.gid, { completed: true });
logger.info("task-loop", `Task completed: ${task.name}`, { verified }); logger.info("task-loop", `Task completed: ${task.name}`, { verified });
} else { } else {
// Move back to To Do on failure const todoSection = await this.findFirstSection(this.config.asanaProjectGid, SECTIONS.TODO);
const todoSection = await this.findFirstSection(SECTIONS.TODO); if (todoSection) await this.asana.moveTaskToSection(task.gid, todoSection.gid);
if (todoSection) {
await this.asana.moveTaskToSection(task.gid, todoSection.gid);
}
logger.error("task-loop", `Task failed: ${task.name}`, { error: result.error }); logger.error("task-loop", `Task failed: ${task.name}`, { error: result.error });
} }
} catch (error: any) { } catch (error: any) {
@@ -250,8 +351,8 @@ RESPOND EXACTLY:
DONE: true or false DONE: true or false
EVIDENCE: one line why`; EVIDENCE: one line why`;
const result = await this.pi.execute(verifyPrompt, undefined, 120_000); // 2 min timeout for verify const result = await this.pi.execute(verifyPrompt, undefined, 120_000);
if (!result.success) { if (!result.success) {
return { done: false, evidence: "Verification check failed to run" }; return { done: false, evidence: "Verification check failed to run" };
} }
@@ -263,8 +364,6 @@ EVIDENCE: one line why`;
const isDone = doneMatch ? doneMatch[1].toLowerCase() === "true" : false; const isDone = doneMatch ? doneMatch[1].toLowerCase() === "true" : false;
const evidence = evidenceMatch ? evidenceMatch[1].trim().substring(0, 1000) : output.substring(0, 1000); const evidence = evidenceMatch ? evidenceMatch[1].trim().substring(0, 1000) : output.substring(0, 1000);
logger.info("task-loop", `Verification result for "${task.name}": done=${isDone}`, { evidence: evidence.substring(0, 200) });
return { done: isDone, evidence }; return { done: isDone, evidence };
} catch (error: any) { } catch (error: any) {
logger.error("task-loop", `Verification error: ${error.message}`); logger.error("task-loop", `Verification error: ${error.message}`);
@@ -272,137 +371,40 @@ EVIDENCE: one line why`;
} }
} }
private async sweepDoneTasks() { // Cached section lookup per project
try { private async findSectionCached(projectGid: string, sectionName: string): Promise<AsanaSection | null> {
// Get all incomplete tasks from all active sections (not just TODO) const cached = this.sectionCache.get(projectGid);
const sweepSections = [...SECTIONS.TODO, ...SECTIONS.IN_PROGRESS]; let sections: AsanaSection[];
const allTasks: AsanaTask[] = [];
for (const sectionName of sweepSections) { if (cached && Date.now() - cached.ts < this.SECTION_CACHE_TTL) {
const section = await this.asana.findSection(this.config.asanaProjectGid, sectionName); sections = cached.sections;
if (!section) continue; } else {
const tasks = await this.asana.getSectionTasks(section.gid); sections = await this.asana.getSections(projectGid);
const incomplete = tasks.filter((t) => !t.completed); this.sectionCache.set(projectGid, { sections, ts: Date.now() });
allTasks.push(...incomplete);
}
if (allTasks.length === 0) return;
// Deduplicate by GID
const seen = new Set<string>();
const uniqueTasks = allTasks.filter((t) => {
if (seen.has(t.gid)) return false;
seen.add(t.gid);
return true;
});
logger.info("task-loop", `Sweeping ${uniqueTasks.length} incomplete tasks for already-done work`);
for (const task of uniqueTasks) {
const check = await this.verifyTaskAlreadyDone(task);
if (check.done) {
logger.info("task-loop", `Sweep: "${task.name}" is already done`, { evidence: check.evidence });
await this.asana.addComment(
task.gid,
`✅ Pi Worker sweep: This task is **already completed** on the server.\n\nEvidence:\n${check.evidence}`
);
const doneSection = await this.findFirstSection(SECTIONS.DONE);
if (doneSection) {
await this.asana.moveTaskToSection(task.gid, doneSection.gid);
}
await this.asana.updateTask(task.gid, { completed: true });
logger.info("task-loop", `Sweep: Marked done: ${task.name}`);
} else {
logger.debug("task-loop", `Sweep: "${task.name}" not yet done`, { evidence: check.evidence });
}
}
// Also sweep user's "My Tasks" (Recently Assigned)
if (this.userTaskListGid) {
try {
const myTasks = await this.asana.getUserTasks(this.userTaskListGid);
// First: clear completed tasks from "Recently Assigned"
// Move to "later" AND move to Done/Resolved section in their project
const completedInbox = myTasks.filter((t) => t.completed);
if (completedInbox.length > 0) {
logger.info("task-loop", `Clearing ${completedInbox.length} completed tasks from Recently Assigned`);
for (const task of completedInbox) {
try {
// Move to Done/Resolved section in the task's project
if (task.memberships && task.memberships.length > 0) {
for (const membership of task.memberships) {
const projectGid = membership.project?.gid;
const currentSection = membership.section?.name?.toLowerCase() || "";
if (currentSection.includes("done") || currentSection.includes("resolved") || currentSection.includes("won't")) {
continue;
}
if (projectGid) {
const doneNames = ["Done", "Resolved", "Complete", "Completed"];
let doneSection = null;
for (const name of doneNames) {
doneSection = await this.asana.findSection(projectGid, name);
if (doneSection) break;
}
if (doneSection) {
await this.asana.moveTaskToSection(task.gid, doneSection.gid);
logger.info("task-loop", `Moved to ${doneSection.name}: ${task.name} (project: ${membership.project?.name})`);
}
}
}
}
logger.info("task-loop", `Cleared completed task: ${task.name}`);
} catch (e: any) {
logger.debug("task-loop", `Could not clear task ${task.name}: ${e.message}`);
}
}
}
// Then: check incomplete tasks to see if they're already done
const incompleteMyTasks = myTasks.filter((t) => !t.completed);
if (incompleteMyTasks.length > 0) {
logger.info("task-loop", `Sweeping ${incompleteMyTasks.length} incomplete tasks from My Tasks`);
for (const task of incompleteMyTasks) {
if (seen.has(task.gid)) continue;
const check = await this.verifyTaskAlreadyDone(task);
if (check.done) {
logger.info("task-loop", `My Tasks sweep: "${task.name}" is already done`, { evidence: check.evidence });
await this.asana.addComment(
task.gid,
`✅ Pi Worker sweep: This task is **already completed**.\n\nEvidence:\n${check.evidence}`
);
await this.asana.updateTask(task.gid, { completed: true });
logger.info("task-loop", `My Tasks sweep: Marked done: ${task.name}`);
} else {
logger.debug("task-loop", `My Tasks sweep: "${task.name}" not yet done`);
}
}
}
} catch (error: any) {
logger.warn("task-loop", `My Tasks sweep error: ${error.message}`);
}
}
} catch (error: any) {
logger.error("task-loop", `Sweep error: ${error.message}`);
} }
return sections.find((s) => s.name.toLowerCase().includes(sectionName.toLowerCase())) || null;
}
private async findDoneSection(projectGid: string): Promise<AsanaSection | null> {
for (const name of SECTIONS.DONE) {
const section = await this.findSectionCached(projectGid, name);
if (section) return section;
}
return null;
}
private async findFirstSection(projectGid: string, names: string[]): Promise<AsanaSection | null> {
for (const name of names) {
const section = await this.findSectionCached(projectGid, name);
if (section) return section;
}
return null;
} }
private async getTasksFromSections(projectGid: string, sectionNames: string[]): Promise<AsanaTask[]> { private async getTasksFromSections(projectGid: string, sectionNames: string[]): Promise<AsanaTask[]> {
for (const name of sectionNames) { for (const name of sectionNames) {
const section = await this.asana.findSection(projectGid, name); const section = await this.findSectionCached(projectGid, name);
if (!section) continue; if (!section) continue;
const tasks = await this.asana.getSectionTasks(section.gid); const tasks = await this.asana.getSectionTasks(section.gid);
const incomplete = tasks.filter((t) => !t.completed); const incomplete = tasks.filter((t) => !t.completed);
@@ -416,7 +418,7 @@ EVIDENCE: one line why`;
`Asana Task: ${task.name}`, `Asana Task: ${task.name}`,
task.notes ? `Description: ${task.notes}` : "", task.notes ? `Description: ${task.notes}` : "",
task.due_on ? `Due: ${task.due_on}` : "", task.due_on ? `Due: ${task.due_on}` : "",
task.tags.length > 0 ? `Tags: ${task.tags.map((t) => t.name).join(", ")}` : "", task.tags?.length > 0 ? `Tags: ${task.tags.map((t) => t.name).join(", ")}` : "",
]; ];
return parts.filter(Boolean).join("\n"); return parts.filter(Boolean).join("\n");
} }