fix recurring tasks

This commit is contained in:
Arjun 2026-03-19 01:08:42 +05:30
parent 5e1debad6a
commit e561616c75
5 changed files with 162 additions and 66 deletions

View file

@ -973,6 +973,7 @@ export function MarkdownEditor({
}
try {
// Call the copilot assistant for both one-time and recurring tasks
const result = await window.ipc.invoke('inline-task:process', {
instruction,
noteContent: editorContent,
@ -986,11 +987,13 @@ export function MarkdownEditor({
if (!node) return
if (result.schedule) {
// Scheduled task: keep the block, update with schedule info
// Recurring/scheduled task: update block with schedule, write target tags to disk
const targetId = Math.random().toString(36).slice(2, 10)
const updatedData: Record<string, unknown> = {
instruction,
instruction: result.instruction,
schedule: result.schedule,
'schedule-label': result.scheduleLabel,
targetId,
}
const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, {
data: JSON.stringify(updatedData),
@ -1004,24 +1007,40 @@ export function MarkdownEditor({
onFrontmatterChange(buildFrontmatter(fields))
}
// Insert response text below the block if any
if (result.response) {
let afterPos: number | null = null
editor.state.doc.descendants((n, p) => {
if (afterPos !== null) return false
if (n.type.name === 'taskBlock') {
try {
const data = JSON.parse(n.attrs.data || '{}')
if (data.instruction === instruction && !data.processing) {
afterPos = p + n.nodeSize
return false
// Write target tags directly to the file on disk after a short delay
// to let the editor save the updated content first
if (notePath) {
setTimeout(async () => {
try {
const file = await window.ipc.invoke('workspace:readFile', { path: notePath })
const content = file.data
const openTag = `<!--task-target:${targetId}-->`
const closeTag = `<!--/task-target:${targetId}-->`
// Only add if not already present
if (content.includes(openTag)) return
// Find the task block in the raw markdown and insert target tags after it
const blockJson = JSON.stringify(updatedData)
const blockStart = content.indexOf('```task\n' + blockJson)
if (blockStart !== -1) {
const blockEnd = content.indexOf('\n```', blockStart + 8)
if (blockEnd !== -1) {
const insertAt = blockEnd + 4 // after the closing ```
const before = content.slice(0, insertAt)
const after = content.slice(insertAt)
const updated = before + '\n\n' + openTag + '\n' + closeTag + after
await window.ipc.invoke('workspace:writeFile', {
path: notePath,
data: updated,
opts: { encoding: 'utf8' },
})
}
} catch { /* skip */ }
}
} catch (err) {
console.error('[RowboatAdd] Failed to write target tags:', err)
}
})
if (afterPos !== null) {
editor.chain().insertContentAt(afterPos, result.response).run()
}
}, 500)
}
} else {
// One-time task: remove the processing block, insert response in its place

View file

@ -19,42 +19,74 @@ ${toolEntries}
---
# Task
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and execute it.
You are an inline task execution agent. You receive a @rowboat instruction from within a knowledge note and either execute it immediately or set it up as a recurring task.
# Instructions
# Two Modes
1. You will receive the full content of a knowledge note and a specific instruction extracted from a \`@rowboat <instruction>\` line in that note.
2. Execute the instruction using your full workspace tool set. You have access to read files, edit files, search, run commands, etc.
3. Use the surrounding note content as context for the task.
4. Your response will be inserted directly into the note below the @rowboat instruction. Write your output as note content it must read naturally as part of the document.
5. NEVER include meta-commentary, thinking out loud, or narration about what you're doing. No "Let me look that up", "Here are the details", "I found the following", etc. Just write the content itself.
6. Keep the result concise and well-formatted in markdown.
7. Do not modify the original note file the service will handle inserting your response.
## 1. One-Time Tasks (no scheduling intent)
For instructions that should be executed immediately (e.g., "summarize this note", "look up the weather"):
- Execute the instruction using your full workspace tool set
- Return the result as markdown content
- Do NOT include any schedule or instruction markers
# Schedule Classification
## 2. Recurring/Scheduled Tasks (has scheduling intent)
For instructions that imply a recurring or future-scheduled task (e.g., "every morning at 8am check emails", "remind me tomorrow at 3pm"):
- Do NOT execute the task only set up the schedule
- You MUST include BOTH markers described below
- Do NOT include any other content besides the markers
If the instruction implies a recurring or future-scheduled task, you MUST include a schedule marker in your response using this exact format:
# Markers for Scheduled Tasks
When the instruction has scheduling intent, your response MUST contain these markers and nothing else:
## Schedule Marker (required)
<!--rowboat-schedule:{"type":"...","label":"..."}-->
Place this marker at the very beginning of your response, on its own line, before any other content.
Schedule types:
1. "cron" recurring schedule: <!--rowboat-schedule:{"type":"cron","expression":"<5-field cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}-->
1. "cron" recurring: \`<!--rowboat-schedule:{"type":"cron","expression":"<5-field cron>","startDate":"<ISO>","endDate":"<ISO>","label":"<label>"}-->\`
"startDate" defaults to now (${nowISO}). "endDate" defaults to 7 days from now (${defaultEndISO}).
Example: "every morning at 8am" <!--rowboat-schedule:{"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until ${defaultEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}"}-->
Example: "every morning at 8am" \`<!--rowboat-schedule:{"type":"cron","expression":"0 8 * * *","startDate":"${nowISO}","endDate":"${defaultEndISO}","label":"runs daily at 8 AM until ${defaultEnd.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}"}-->\`
2. "window" recurring with a time window: <!--rowboat-schedule:{"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<human readable>"}-->
2. "window" recurring with time window: \`<!--rowboat-schedule:{"type":"window","cron":"<cron>","startTime":"HH:MM","endTime":"HH:MM","startDate":"<ISO>","endDate":"<ISO>","label":"<label>"}-->\`
3. "once" run once at a specific future time: <!--rowboat-schedule:{"type":"once","runAt":"<ISO 8601>","label":"<human readable>"}-->
3. "once" future one-time: \`<!--rowboat-schedule:{"type":"once","runAt":"<ISO 8601>","label":"<label>"}-->\`
The "label" field must be a short plain-English description starting with "runs" (e.g. "runs every 2 minutes until Mar 12", "runs daily at 8 AM until Mar 12", "runs once on Mar 20 at 3 PM").
The "label" must be a short plain-English description starting with "runs" (e.g., "runs daily at 8 AM until Mar 24").
## Instruction Marker (required for scheduled tasks)
<!--rowboat-instruction:the refined instruction text-->
This is the instruction that will be executed on each scheduled run. You may refine/clarify the original instruction to make it more specific and actionable for the background agent that will execute it. For example:
- User says "check my emails every morning" \`<!--rowboat-instruction:Check for new emails and summarize any important ones.-->\`
- User says "news about claude daily" \`<!--rowboat-instruction:Search for the latest news about Anthropic's Claude AI and list the top stories with sources.-->\`
If the instruction is already clear and actionable, you can keep it as-is.
# Context
Current local time: ${localNow}
Timezone: ${tz}
Current UTC time: ${nowISO}
If the instruction is a one-time immediate task with no scheduling intent, do NOT include the schedule marker. Just execute and return the result.
If the instruction has BOTH scheduling intent AND something to execute immediately, include the schedule marker AND your response content.
# Output Rules
- For one-time tasks: write output as note content it must read naturally as part of the document. NEVER include meta-commentary. Keep concise and well-formatted in markdown.
- For scheduled tasks: output ONLY the two markers (schedule + instruction), nothing else.
- Do not modify the original note file the system handles all insertions.
# Target Regions
For recurring/scheduled tasks, the note will contain a **target region** delimited by HTML comment tags:
\`\`\`
<!--task-target:TARGETID-->
...existing content...
<!--/task-target:TARGETID-->
\`\`\`
When you see a target region associated with your task (during a scheduled run), your response MUST be the replacement content for that region. You should:
- Write content that replaces whatever is currently between the tags
- Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate)
- Do NOT include the target tags themselves in your response
`;
}

View file

@ -176,6 +176,8 @@ interface InlineTask {
startLine: number;
/** Line index of the closing ``` fence */
endLine: number;
/** Target region ID for recurring tasks */
targetId: string | null;
}
/**
@ -183,7 +185,7 @@ interface InlineTask {
* Returns { instruction, schedule } or null if not valid JSON.
* Also supports legacy @rowboat format.
*/
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null } | null {
function parseBlockContent(contentLines: string[]): { instruction: string; schedule: InlineTaskSchedule | null; lastRunAt: string | null; targetId: string | null } | null {
const raw = contentLines.join('\n').trim();
try {
const data = JSON.parse(raw);
@ -193,6 +195,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
instruction: parsed.data.instruction,
schedule: parsed.data.schedule ? { ...parsed.data.schedule, label: parsed.data['schedule-label'] ?? '' } as InlineTaskSchedule : null,
lastRunAt: parsed.data.lastRunAt ?? null,
targetId: parsed.data.targetId ?? null,
};
}
// Fallback for blocks that have instruction but don't fully match schema
@ -201,6 +204,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
instruction: data.instruction,
schedule: data.schedule ?? null,
lastRunAt: data.lastRunAt ?? null,
targetId: data.targetId ?? null,
};
}
} catch {
@ -227,7 +231,7 @@ function parseBlockContent(contentLines: string[]): { instruction: string; sched
const rawInstruction = firstRowboatLine?.trim() ?? instructionLines.join('\n').trim();
const instruction = rawInstruction.replace(/^@rowboat:?\s*/, '');
if (!instruction) return null;
return { instruction, schedule, lastRunAt: null };
return { instruction, schedule, lastRunAt: null, targetId: null };
}
/**
@ -308,16 +312,16 @@ function findPendingTasks(body: string): InlineTask[] {
const parsed = parseBlockContent(contentLines);
if (parsed) {
const { instruction, schedule, lastRunAt } = parsed;
const { instruction, schedule, lastRunAt, targetId } = parsed;
if (schedule) {
if (isScheduledTaskDue(schedule, lastRunAt)) {
tasks.push({ instruction, schedule, startLine, endLine });
tasks.push({ instruction, schedule, startLine, endLine, targetId });
}
} else {
// One-time task: skip if already ran
if (!lastRunAt) {
tasks.push({ instruction, schedule: null, startLine, endLine });
tasks.push({ instruction, schedule: null, startLine, endLine, targetId });
}
}
}
@ -339,6 +343,32 @@ function insertResultBelow(body: string, endLine: number, result: string): strin
}
/**
* Replace content inside a target region identified by targetId.
* If the target region exists, replaces its content.
* If it doesn't exist, creates the target region below the task block,
* wrapping any existing content between the block and the next block/heading.
*/
function replaceTargetRegion(body: string, targetId: string, result: string, endLine: number): string {
const openTag = `<!--task-target:${targetId}-->`;
const closeTag = `<!--/task-target:${targetId}-->`;
const openIdx = body.indexOf(openTag);
const closeIdx = body.indexOf(closeTag);
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
// Target region exists — replace content between the tags
const before = body.slice(0, openIdx + openTag.length);
const after = body.slice(closeIdx);
return before + '\n' + result + '\n' + after;
}
// Target region doesn't exist yet — create it below the task block's closing fence
const lines = body.split('\n');
const taggedResult = `${openTag}\n${result}\n${closeTag}`;
lines.splice(endLine + 1, 0, '', taggedResult);
return lines.join('\n');
}
/**
* Determine if a note has any "live" tell-rowboat tasks.
* A task is live if:
@ -495,7 +525,13 @@ async function processInlineTasks(): Promise<void> {
const result = await extractAgentResponse(run.id);
if (result) {
currentBody = insertResultBelow(currentBody, task.endLine, result);
if (task.targetId) {
// Recurring task with target region — replace content inside the region
currentBody = replaceTargetRegion(currentBody, task.targetId, result, task.endLine);
} else {
// No target region — insert below the block
currentBody = insertResultBelow(currentBody, task.endLine, result);
}
// Update the block JSON with lastRunAt
const timestamp = new Date().toISOString();
currentBody = updateBlockData(currentBody, task.startLine, task.endLine, timestamp);
@ -536,22 +572,25 @@ async function processInlineTasks(): Promise<void> {
* The agent can execute one-off tasks and/or detect scheduling intent.
* Returns schedule info (if any), a schedule label, and optional response text.
*/
type ScheduleWithoutLabel =
| { type: 'cron'; expression: string; startDate: string; endDate: string }
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string }
| { type: 'once'; runAt: string };
export async function processRowboatInstruction(
instruction: string,
noteContent: string,
notePath: string,
): Promise<{
schedule: { type: 'cron'; expression: string; startDate: string; endDate: string }
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string }
| { type: 'once'; runAt: string }
| null;
instruction: string;
schedule: ScheduleWithoutLabel | null;
scheduleLabel: string | null;
response: string | null;
}> {
const run = await createRun({ agentId: INLINE_TASK_AGENT });
const message = [
`Execute the following instruction from the note "${notePath}":`,
`Process the following @rowboat instruction from the note "${notePath}":`,
'',
`**Instruction:** ${instruction}`,
'',
@ -566,41 +605,45 @@ export async function processRowboatInstruction(
const rawResponse = await extractAgentResponse(run.id);
if (!rawResponse) {
return { schedule: null, scheduleLabel: null, response: null };
return { instruction, schedule: null, scheduleLabel: null, response: null };
}
// Parse out the schedule marker if present
const scheduleMarkerRegex = /<!--rowboat-schedule:(.*?)-->/;
const match = rawResponse.match(scheduleMarkerRegex);
// Parse out the schedule marker if present (allow multiline JSON)
const scheduleMarkerRegex = /<!--rowboat-schedule:([\s\S]*?)-->/;
const scheduleMatch = rawResponse.match(scheduleMarkerRegex);
type ScheduleWithoutLabel =
| { type: 'cron'; expression: string; startDate: string; endDate: string }
| { type: 'window'; cron: string; startTime: string; endTime: string; startDate: string; endDate: string }
| { type: 'once'; runAt: string };
// Parse out the instruction marker if present
const instructionMarkerRegex = /<!--rowboat-instruction:([\s\S]*?)-->/;
const instructionMatch = rawResponse.match(instructionMarkerRegex);
let schedule: ScheduleWithoutLabel | null = null;
let scheduleLabel: string | null = null;
let response: string | null = null;
let refinedInstruction = instruction;
if (match) {
if (instructionMatch) {
refinedInstruction = instructionMatch[1].trim();
}
if (scheduleMatch) {
try {
const parsed = JSON.parse(match[1]);
const parsed = JSON.parse(scheduleMatch[1]);
if (parsed && typeof parsed === 'object' && parsed.type) {
scheduleLabel = parsed.label || null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { label: _, ...rest } = parsed;
schedule = rest as ScheduleWithoutLabel;
}
} catch {
// Invalid JSON in marker — ignore
}
// Remove the marker from the response text
const cleaned = rawResponse.replace(scheduleMarkerRegex, '').trim();
response = cleaned || null;
} else {
response = rawResponse.trim() || null;
// Scheduled task — no response content (agent only returns markers)
return { instruction: refinedInstruction, schedule, scheduleLabel, response: null };
}
return { schedule, scheduleLabel, response };
// One-time task — the full response is the content
const response = rawResponse.trim() || null;
return { instruction: refinedInstruction, schedule: null, scheduleLabel: null, response };
}
/**

View file

@ -29,6 +29,7 @@ export const InlineTaskBlockSchema = z.object({
'schedule-label': z.string().optional(),
lastRunAt: z.string().optional(),
processing: z.boolean().optional(),
targetId: z.string().optional(),
});
export type InlineTaskBlock = z.infer<typeof InlineTaskBlockSchema>;

View file

@ -529,6 +529,7 @@ const ipcSchemas = {
notePath: z.string(),
}),
res: z.object({
instruction: z.string(),
schedule: z.union([
z.object({ type: z.literal('cron'), expression: z.string(), startDate: z.string(), endDate: z.string() }),
z.object({ type: z.literal('window'), cron: z.string(), startTime: z.string(), endTime: z.string(), startDate: z.string(), endDate: z.string() }),