mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-24 20:28:16 +02:00
feat(bg-tasks): coding-from-meetings — auto-implement coding action items (#630)
* feat(bg-tasks): coding-from-meetings — auto-implement coding action items A background-task flavor that watches for meeting notes, scans them for actionable coding items, and autonomously implements them in isolated git worktrees, summarizing results in the task's index.md. - Emit `meeting.notes_ready` when Fireflies/Granola first write a meeting note - Add optional `projectId` to BackgroundTask (pins a coding task to a repo) - New `launch-code-task` builtin tool: per group of items, create a worktree-isolated, yolo, direct code session, wrap the prompt in an autonomous scaffold, run async, and finalize a per-session row in index.md - Group code sessions under their meeting heading in index.md - Summary from the code agent's `## Summary` section; file counts from `git diff` vs the worktree fork point (counts committed work, not just dirty) - Guardrails: self-heal projectId across runs, cap launches per run, and bar the bg-task agent from managing/spawning tasks - UI: "View available templates" -> Coding-from-meetings preset (repo picker, prefilled trigger + instructions) See plan.md for the full design. * let the copilot able to configure a coding background agent * Delete plan.md --------- Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
This commit is contained in:
parent
2f926f8dc0
commit
1f8ac2cf34
13 changed files with 795 additions and 11 deletions
|
|
@ -1606,6 +1606,7 @@ export function setupIpcHandlers() {
|
|||
name: args.name,
|
||||
instructions: args.instructions,
|
||||
...(args.triggers ? { triggers: args.triggers } : {}),
|
||||
...(args.projectId ? { projectId: args.projectId } : {}),
|
||||
...(args.model ? { model: args.model } : {}),
|
||||
...(args.provider ? { provider: args.provider } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle,
|
||||
Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight,
|
||||
Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles,
|
||||
Code2, FolderOpen, LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import type { z } from 'zod'
|
||||
import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js'
|
||||
|
|
@ -271,7 +272,16 @@ function TriggersEditor({
|
|||
// New Task dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DialogMode = 'describe' | 'manual'
|
||||
type DialogMode = 'describe' | 'manual' | 'templates' | 'coding'
|
||||
|
||||
// Prefills for the "Coding from meetings" preset.
|
||||
const CODING_PRESET = {
|
||||
name: 'Implement coding items from meetings',
|
||||
instructions: `After a meeting's notes are ready, scan them for coding action items (bugs to fix, features to build, concrete changes requested) for me or my team.
|
||||
|
||||
Conservatively implement the clearly-scoped, self-contained ones in the configured repo using the launch-code-task tool — group related items into one session, split unrelated ones. Note ambiguous, large/architectural, or other-repo items as "needs review" instead of coding them. If nothing is actionable, do nothing.`,
|
||||
eventMatchCriteria: `A meeting's notes or transcript just became available (engineering standup, planning, sprint, or technical discussion) that may contain coding action items, bugs to fix, or features to build.`,
|
||||
}
|
||||
|
||||
function NewTaskDialog({
|
||||
open,
|
||||
|
|
@ -295,6 +305,9 @@ function NewTaskDialog({
|
|||
const [name, setName] = useState('')
|
||||
const [instructions, setInstructions] = useState('')
|
||||
const [triggers, setTriggers] = useState<Triggers | undefined>(undefined)
|
||||
const [projectId, setProjectId] = useState<string | undefined>(undefined)
|
||||
const [projectName, setProjectName] = useState<string | undefined>(undefined)
|
||||
const [addingProject, setAddingProject] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -304,11 +317,64 @@ function NewTaskDialog({
|
|||
setName('')
|
||||
setInstructions('')
|
||||
setTriggers(undefined)
|
||||
setProjectId(undefined)
|
||||
setProjectName(undefined)
|
||||
}
|
||||
}, [open, copilotEnabled])
|
||||
|
||||
// Switch into the coding preset: prefill name/instructions/trigger once.
|
||||
const enterCodingMode = () => {
|
||||
setMode('coding')
|
||||
setName(CODING_PRESET.name)
|
||||
setInstructions(CODING_PRESET.instructions)
|
||||
setTriggers({ eventMatchCriteria: CODING_PRESET.eventMatchCriteria })
|
||||
}
|
||||
|
||||
const pickRepo = async () => {
|
||||
setAddingProject(true)
|
||||
try {
|
||||
const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose the repository for this task' })
|
||||
const dir = res.path
|
||||
if (!dir) return
|
||||
const added = await window.ipc.invoke('codeProject:add', { path: dir })
|
||||
if (!added.git?.isGitRepo) {
|
||||
toast('That folder is not a git repository — coding tasks need one.', 'error')
|
||||
return
|
||||
}
|
||||
setProjectId(added.project.id)
|
||||
setProjectName(added.project.name)
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : String(err), 'error')
|
||||
} finally {
|
||||
setAddingProject(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmitDescribe = description.trim().length > 0 && !submitting
|
||||
const canSubmitManual = name.trim().length > 0 && instructions.trim().length > 0 && !submitting
|
||||
const canSubmitCoding = name.trim().length > 0 && instructions.trim().length > 0 && !!projectId && !submitting
|
||||
|
||||
const submitCoding = async () => {
|
||||
if (!canSubmitCoding) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke('bg-task:create', {
|
||||
name: name.trim(),
|
||||
instructions: instructions.trim(),
|
||||
...(triggers ? { triggers } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
})
|
||||
if (result.success && result.slug) {
|
||||
onCreated(result.slug)
|
||||
} else {
|
||||
toast(result.error ?? 'Failed to create task', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : String(err), 'error')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitDescribe = () => {
|
||||
if (!canSubmitDescribe || !onCreateWithCopilot) return
|
||||
|
|
@ -359,7 +425,116 @@ function NewTaskDialog({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'describe' ? (
|
||||
{(mode === 'describe' || mode === 'manual') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('templates')}
|
||||
className="mb-4 flex w-full items-center justify-between gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-left text-[12px] hover:border-solid hover:bg-accent"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutTemplate className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">View available templates</span>
|
||||
</span>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{mode === 'templates' ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{
|
||||
id: 'coding-from-meetings',
|
||||
title: 'Coding from meetings',
|
||||
description: "When a meeting's notes are ready, scan them for coding action items and auto-implement them in a repo — each on its own isolated branch, with a summary.",
|
||||
icon: Code2,
|
||||
onSelect: enterCodingMode,
|
||||
},
|
||||
].map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={preset.onSelect}
|
||||
className="flex w-full items-start gap-2.5 rounded-md border bg-muted/40 px-3 py-2.5 text-left hover:border-foreground/30 hover:bg-accent"
|
||||
>
|
||||
<preset.icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[12.5px] font-medium">{preset.title}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">{preset.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
|
||||
</div>
|
||||
</>
|
||||
) : mode === 'coding' ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Repository</label>
|
||||
{projectName ? (
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2">
|
||||
<span className="flex items-center gap-2 text-[13px]">
|
||||
<FolderOpen className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{projectName}</span>
|
||||
</span>
|
||||
<button type="button" onClick={pickRepo} className="text-[11px] text-muted-foreground hover:text-foreground" disabled={addingProject}>Change</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={pickRepo} disabled={addingProject}>
|
||||
{addingProject ? <Loader2 className="mr-1 size-3 animate-spin" /> : <FolderOpen className="mr-1 size-3" />}
|
||||
Choose a git repository…
|
||||
</Button>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Code changes run full-auto in an isolated git worktree — your working checkout is never touched.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Name</label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Instructions</label>
|
||||
<Textarea value={instructions} onChange={e => setInstructions(e.target.value)} rows={6} className="text-[12.5px] leading-relaxed" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Triggers</label>
|
||||
<TriggersEditor value={triggers} onChange={setTriggers} />
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Prefilled to fire when a meeting's notes become available. Adjust if you want.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>Cancel</Button>
|
||||
<Button size="sm" onClick={submitCoding} disabled={!canSubmitCoding}>
|
||||
{submitting && <Loader2 className="mr-1 size-3 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : mode === 'describe' ? (
|
||||
<>
|
||||
<Textarea
|
||||
value={description}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z, ZodType } from "zod";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as fs from "fs/promises";
|
||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
|
||||
|
|
@ -20,6 +21,8 @@ import type { CodeModeManager } from "../../code-mode/acp/manager.js";
|
|||
import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js";
|
||||
import { ICodeModeConfigRepo } from "../../code-mode/repo.js";
|
||||
import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
|
||||
import type { ICodeProjectsRepo } from "../../code-mode/projects/repo.js";
|
||||
import * as gitService from "../../code-mode/git/service.js";
|
||||
|
||||
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
|
||||
// descriptions; only `triggers` gets a tighter contextual override (the
|
||||
|
|
@ -32,6 +35,9 @@ const CreateBackgroundTaskInput = BackgroundTaskSchema.pick({
|
|||
provider: true,
|
||||
}).extend({
|
||||
triggers: TriggersSchema.optional().describe('All three sub-fields (cronExpr, windows, eventMatchCriteria) are independently optional — mix freely. No triggers at all = manual-only (user clicks Run).'),
|
||||
projectDir: z.string().optional().describe(
|
||||
"Set this ONLY when the user wants the task to WRITE CODE. An absolute path (or ~/…) to a LOCAL GIT REPOSITORY with at least one commit. It turns this into a *coding task*: each run scans the trigger source for actionable items and implements them autonomously in isolated git worktrees off this repo — never touching the user's checkout. Extract the directory from the user's request (e.g. 'use ~/Work/space/test as the work directory'). Omit for ordinary output/action tasks.",
|
||||
),
|
||||
});
|
||||
|
||||
const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
|
||||
|
|
@ -44,7 +50,43 @@ const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
|
|||
}).partial().extend({
|
||||
slug: z.string().describe('The slug of the task to update (the folder name under bg-tasks/).'),
|
||||
triggers: TriggersSchema.optional().describe('Replace the triggers object. To remove all triggers (make manual-only) pass an empty object.'),
|
||||
projectDir: z.string().optional().describe("Point an existing task at a code repo (or change which one) to make it a coding task. Absolute path or ~/… to a local git repository with at least one commit. Same rules as on create."),
|
||||
});
|
||||
|
||||
// Turn a user-supplied directory into a registered code project id. Reuses the
|
||||
// same idempotent registry the Code-section picker writes to (add() validates the
|
||||
// dir exists & is a directory, and dedupes by resolved path). Returns a soft
|
||||
// `warning` — not an error — when the repo isn't yet worktree-ready, so the task
|
||||
// still gets created and the copilot can tell the user what to fix.
|
||||
function expandHome(p: string): string {
|
||||
const t = p.trim();
|
||||
if (t === '~') return os.homedir();
|
||||
if (t.startsWith('~/') || t.startsWith(`~${path.sep}`)) return path.join(os.homedir(), t.slice(2));
|
||||
return t;
|
||||
}
|
||||
|
||||
async function resolveCodeProject(dirPath: string): Promise<
|
||||
{ ok: true; projectId: string; path: string; warning?: string } | { ok: false; error: string }
|
||||
> {
|
||||
const abs = path.resolve(expandHome(dirPath));
|
||||
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
||||
let project: Awaited<ReturnType<ICodeProjectsRepo['add']>>;
|
||||
try {
|
||||
project = await projectsRepo.add(abs);
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Could not use '${dirPath}' as a code directory: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
// Worktree isolation needs a real git repo with at least one commit
|
||||
// (codeSessionService.create throws otherwise). Surface it now as a soft
|
||||
// warning rather than letting the next run fail silently.
|
||||
let warning: string | undefined;
|
||||
try {
|
||||
const info = await gitService.repoInfo(project.path);
|
||||
if (!info.isGitRepo) warning = `${project.path} is not a git repository yet — run \`git init\` and make a commit, or the coding sessions will fail.`;
|
||||
else if (!info.hasCommits) warning = `${project.path} has no commits yet — make an initial commit, or the coding sessions will fail.`;
|
||||
} catch { /* best effort — worktree creation will surface it later */ }
|
||||
return { ok: true, projectId: project.id, path: project.path, ...(warning ? { warning } : {}) };
|
||||
}
|
||||
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText } from "ai";
|
||||
|
|
@ -1490,15 +1532,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
inputSchema: CreateBackgroundTaskInput,
|
||||
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
|
||||
try {
|
||||
let projectId: string | undefined;
|
||||
let warning: string | undefined;
|
||||
if (input.projectDir) {
|
||||
const r = await resolveCodeProject(input.projectDir);
|
||||
if (!r.ok) return { success: false, error: r.error };
|
||||
projectId = r.projectId;
|
||||
warning = r.warning;
|
||||
}
|
||||
const { createTask } = await import("../../background-tasks/fileops.js");
|
||||
const result = await createTask({
|
||||
name: input.name,
|
||||
instructions: input.instructions,
|
||||
...(input.triggers ? { triggers: input.triggers } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
});
|
||||
return { success: true, slug: result.slug };
|
||||
return { success: true, slug: result.slug, ...(warning ? { warning } : {}) };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
|
@ -1511,9 +1562,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
|
||||
try {
|
||||
const { patchTask } = await import("../../background-tasks/fileops.js");
|
||||
const { slug, ...partial } = input;
|
||||
const { slug, projectDir, ...partial } = input;
|
||||
let warning: string | undefined;
|
||||
if (projectDir) {
|
||||
const r = await resolveCodeProject(projectDir);
|
||||
if (!r.ok) return { success: false, error: r.error };
|
||||
(partial as { projectId?: string }).projectId = r.projectId;
|
||||
warning = r.warning;
|
||||
}
|
||||
const result = await patchTask(slug, partial);
|
||||
return { success: true, task: result };
|
||||
return { success: true, task: result, ...(warning ? { warning } : {}) };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
|
@ -1549,6 +1607,35 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'launch-code-task': {
|
||||
description: "Launch an autonomous coding session that implements a unit of work in the bg-task's pinned code repo. ONLY usable from a coding background task (one with a configured code project). The session runs full-auto in its own isolated git worktree/branch — it never touches the user's checkout — and runs asynchronously: this returns as soon as the session is created, so you can launch several (one per group of related items) in the same run. The tool writes and later updates a row under a `## Code Sessions` section in the task's index.md — do NOT edit that section yourself. Write an excellent, fully self-contained `prompt`: the coding agent has no other context and no human to ask. Group related items into one call; split unrelated items into separate calls.",
|
||||
inputSchema: z.object({
|
||||
taskSlug: z.string().describe("The slug of THIS background task (it's in your run message, e.g. 'implement-meeting-items'). Used to find the pinned repo and to update index.md."),
|
||||
meeting: z.string().min(1).describe("The name/title of the meeting these items came from (e.g. 'Eng Sync — 2026-06-18'). Sessions are grouped under this heading in index.md so the user can see which meeting each change came from."),
|
||||
title: z.string().min(1).max(120).describe("Short human title for this unit of work — one line in index.md (e.g. 'Add retry to upload client')."),
|
||||
items: z.string().min(1).describe("Brief description of the action item(s) this session implements, for the summary row (e.g. 'Fix flaky upload + add retry; raised in standup')."),
|
||||
prompt: z.string().min(1).describe("The full, self-contained coding instruction. Include the concrete goal, relevant context from the meeting, any files/areas to look at, and what 'done' means. The agent runs autonomously with no human — be specific and complete."),
|
||||
context: z.string().optional().describe("Optional extra context, e.g. the relevant excerpt from the meeting."),
|
||||
}),
|
||||
execute: async (input: { taskSlug: string; meeting: string; title: string; items: string; prompt: string; context?: string }, ctx?: ToolContext) => {
|
||||
try {
|
||||
const { launchCodeTask } = await import("../../background-tasks/code-sessions.js");
|
||||
const result = await launchCodeTask({
|
||||
taskSlug: input.taskSlug,
|
||||
meeting: input.meeting,
|
||||
title: input.title,
|
||||
items: input.items,
|
||||
prompt: input.prompt,
|
||||
...(input.context ? { context: input.context } : {}),
|
||||
...(ctx?.runId ? { runId: ctx.runId } : {}),
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'notify-user': {
|
||||
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
|
||||
inputSchema: z.object({
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ On every run: perform the action using the appropriate tool (Slack, email, web-f
|
|||
|
||||
If your instructions imply BOTH ("summarize and email it"), do both per run.
|
||||
|
||||
CODE MODE — implement code via isolated sessions.
|
||||
Only available when the run message contains a **"# Coding task"** block (the task is pinned to a code repository). In that case:
|
||||
- Detect actionable coding items from the source (e.g. the meeting notes named in the trigger), conservatively. Only implement clearly-scoped, self-contained items. Ambiguous, large/architectural, or other-repo items → list them in \`index.md\` as "needs review"; do not code them.
|
||||
- Group related items, then call \`launch-code-task\` once per group (\`taskSlug\` is your own slug). It runs full-auto in an isolated worktree and **owns the \`## Code Sessions\` section of \`index.md\`** — never edit those rows yourself. Write a complete, self-contained \`prompt\`: the coding agent has no other context and no human to ask.
|
||||
- If nothing is actionable, launch nothing and say so in your summary.
|
||||
|
||||
# Triggers
|
||||
|
||||
The run message tells you which trigger fired and how to interpret it:
|
||||
|
|
@ -76,11 +82,21 @@ The workspace lives at \`${WorkDir}\`.
|
|||
`;
|
||||
|
||||
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
|
||||
// A running bg-task must not manage bg-tasks: re-running itself risks a
|
||||
// recursive cascade, and patch/create can clobber its own task.yaml (a weak
|
||||
// model has done exactly this, dropping the pinned projectId). It implements
|
||||
// code via `launch-code-task`, not by editing task specs.
|
||||
const EXCLUDED = new Set([
|
||||
'executeCommand', // headless: no interactive approval
|
||||
'code_agent_run', // headless: needs interactive permission UI
|
||||
'run-background-task-agent',
|
||||
'create-background-task',
|
||||
'patch-background-task',
|
||||
]);
|
||||
|
||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||
for (const name of Object.keys(BuiltinTools)) {
|
||||
// code_agent_run requires an interactive UI for permission approvals — skip it
|
||||
// here (headless) so it can't hang on an approval no one can answer.
|
||||
if (name === 'executeCommand' || name === 'code_agent_run') continue;
|
||||
if (EXCLUDED.has(name)) continue;
|
||||
tools[name] = { type: 'builtin', name };
|
||||
}
|
||||
|
||||
|
|
|
|||
333
apps/x/packages/core/src/background-tasks/code-sessions.ts
Normal file
333
apps/x/packages/core/src/background-tasks/code-sessions.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import fs from 'fs/promises';
|
||||
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
|
||||
import type { GitStatusFile } from '@x/shared/dist/code-sessions.js';
|
||||
import container from '../di/container.js';
|
||||
import type { CodeSessionService } from '../code-mode/sessions/service.js';
|
||||
import type { ICodeProjectsRepo } from '../code-mode/projects/repo.js';
|
||||
import * as gitService from '../code-mode/git/service.js';
|
||||
import { extractAgentResponse } from '../agents/utils.js';
|
||||
import { withFileLock } from '../knowledge/file-lock.js';
|
||||
import { fetchTask, taskIndexPath } from './fileops.js';
|
||||
|
||||
const log = new PrefixLogger('BgTask:Code');
|
||||
|
||||
// A code session that hangs (engine wedged, never settles) shouldn't pin a
|
||||
// "running…" row forever. After this long we finalize from whatever the
|
||||
// worktree shows and tell the user to check the session.
|
||||
const MAX_WATCH_MS = 90 * 60 * 1000;
|
||||
|
||||
// A single bg-task run must not spawn an unbounded fleet of code sessions — a
|
||||
// weak model has called this 11+ times in one run. Cap per agent run.
|
||||
const MAX_LAUNCHES_PER_RUN = 5;
|
||||
const launchesPerRun = new Map<string, number>();
|
||||
|
||||
export interface LaunchCodeTaskArgs {
|
||||
/** The bg-task slug — used to find the pinned projectId and to write index.md. */
|
||||
taskSlug: string;
|
||||
/** The meeting these items came from — sessions are grouped under it in index.md. */
|
||||
meeting: string;
|
||||
/** Short human title for this unit of work (one row in index.md). */
|
||||
title: string;
|
||||
/** Short description of the item(s) being implemented (for the row). */
|
||||
items: string;
|
||||
/** The detailed, task-specific coding instruction written by the agent. */
|
||||
prompt: string;
|
||||
/** Optional extra context (e.g. the relevant meeting excerpt). */
|
||||
context?: string;
|
||||
/** The bg-task agent's runId — used to cap launches per run. */
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface LaunchCodeTaskResult {
|
||||
success: boolean;
|
||||
sessionId?: string;
|
||||
branch?: string;
|
||||
worktreePath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Wrap the agent-authored task body in a robust autonomous-coding scaffold so
|
||||
// every launch gets a strong, self-contained first message regardless of how
|
||||
// the agent phrased its part. The session runs full-auto (yolo) with no human.
|
||||
function buildCodePrompt(args: { prompt: string; branch: string; context?: string }): string {
|
||||
const { prompt, branch, context } = args;
|
||||
return `You are an autonomous coding agent. There is NO human present to answer questions, approve steps, or review mid-way — make reasonable decisions and drive the task to a complete, working result on your own.
|
||||
|
||||
${context ? `## Context\n${context}\n\n` : ''}## Task
|
||||
${prompt}
|
||||
|
||||
## Operating rules
|
||||
- You are on an isolated branch/worktree (\`${branch}\`). Work only within this repository; your changes never touch the user's main checkout.
|
||||
- Implement the task end-to-end. Do not stop half-way, leave TODOs/stubs, or defer work back to the user.
|
||||
- Before you start, briefly explore the repo to match its existing conventions, structure, and style.
|
||||
- After implementing, VERIFY: run the project's build / typecheck / lint and any directly relevant tests. Fix anything you break.
|
||||
- Make small, logically-scoped git commits with clear messages as you go.
|
||||
- Stay in scope — don't refactor unrelated code or make sweeping changes the task didn't ask for.
|
||||
- If the task is genuinely ambiguous or blocked (missing dependency, contradictory requirement), make the safest reasonable partial progress and clearly flag what's blocked in your final summary — never guess in a way that could be destructive.
|
||||
|
||||
## When done
|
||||
Finish your response with a section titled exactly \`## Summary\` as the LAST thing you write — nothing after it. Under it, put 2–5 short bullet points only: what you changed, which files/areas, how you verified it, and any follow-ups or blockers. No narration or preamble inside the summary (no "I then…", "Let me…") — just the facts. This section is shown to the user verbatim, so keep it clean and self-contained.`;
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// The code agent's final message is mostly streamed narration ("Let me view it
|
||||
// in context…"). We instruct it to end with a `## Summary` section — extract just
|
||||
// that. Fall back to the last paragraph if it didn't comply.
|
||||
const SUMMARY_MAX_CHARS = 900;
|
||||
function cleanSummary(text: string): string {
|
||||
if (!text) return '';
|
||||
let body: string;
|
||||
const idx = text.toLowerCase().lastIndexOf('## summary');
|
||||
if (idx >= 0) {
|
||||
body = text.slice(idx + '## summary'.length).trim();
|
||||
} else {
|
||||
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
|
||||
body = paras.length ? paras[paras.length - 1] : text.trim();
|
||||
}
|
||||
// Drop empty lines and any leftover heading markers; keep bullet structure.
|
||||
const lines = body.split('\n').map((l) => l.replace(/^#+\s*/, '').trimEnd()).filter((l) => l.trim() !== '');
|
||||
let out = lines.join('\n').trim();
|
||||
if (out.length > SUMMARY_MAX_CHARS) out = out.slice(0, SUMMARY_MAX_CHARS).trimEnd() + '…';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Render a summary as a clean markdown blockquote, preserving its bullet lines.
|
||||
function quoteSummary(summary: string): string[] {
|
||||
const cleaned = cleanSummary(summary);
|
||||
if (!cleaned) return [];
|
||||
return ['', ...cleaned.split('\n').map((l) => (l.trim() ? `> ${l.trim()}` : '>'))];
|
||||
}
|
||||
|
||||
const SECTION_HEADING = '## Code Sessions';
|
||||
|
||||
function startMarker(id: string): string { return `<!-- cs-start:${id} -->`; }
|
||||
function endMarker(id: string): string { return `<!-- cs-end:${id} -->`; }
|
||||
|
||||
function meetingHeading(meeting: string): string {
|
||||
return `### 📅 ${meeting}`;
|
||||
}
|
||||
|
||||
function runningBlock(args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string }): string {
|
||||
const { sessionId, title, items, branch, worktreePath } = args;
|
||||
return [
|
||||
startMarker(sessionId),
|
||||
`#### ⏳ ${title}`,
|
||||
`- **Items:** ${items}`,
|
||||
`- **Branch:** \`${branch}\``,
|
||||
`- **Worktree:** \`${worktreePath}\``,
|
||||
`- **Session:** \`${sessionId}\` _(running…)_`,
|
||||
endMarker(sessionId),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Append a "running" block for a freshly launched session, grouped under its
|
||||
// meeting's heading inside the Code Sessions section (creating section/heading as
|
||||
// needed). Serialized via the index.md file lock so concurrent launches don't
|
||||
// clobber each other.
|
||||
async function appendRunningBlock(slug: string, meeting: string, block: string): Promise<void> {
|
||||
const indexPath = taskIndexPath(slug);
|
||||
await withFileLock(indexPath, async () => {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(indexPath, 'utf-8');
|
||||
} catch {
|
||||
content = '';
|
||||
}
|
||||
if (!content.includes(SECTION_HEADING)) {
|
||||
const sep = content.endsWith('\n') || content === '' ? '' : '\n';
|
||||
content += `${sep}\n${SECTION_HEADING}\n`;
|
||||
}
|
||||
|
||||
const heading = meetingHeading(meeting);
|
||||
const lines = content.split('\n');
|
||||
const headingIdx = lines.findIndex((l) => l.trim() === heading);
|
||||
if (headingIdx === -1) {
|
||||
// New meeting group — append heading + block at the end.
|
||||
if (!content.endsWith('\n')) content += '\n';
|
||||
content += `\n${heading}\n\n${block}\n`;
|
||||
} else {
|
||||
// Existing meeting — insert this block right after the heading so
|
||||
// sessions stay grouped (newest first within the group).
|
||||
lines.splice(headingIdx + 1, 0, '', block);
|
||||
content = lines.join('\n');
|
||||
}
|
||||
await fs.writeFile(indexPath, content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
// Replace a session's block in place once its run settles.
|
||||
async function finalizeBlock(slug: string, sessionId: string, block: string): Promise<void> {
|
||||
const indexPath = taskIndexPath(slug);
|
||||
await withFileLock(indexPath, async () => {
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(indexPath, 'utf-8');
|
||||
} catch {
|
||||
return; // nothing to finalize against
|
||||
}
|
||||
const re = new RegExp(`${escapeRegExp(startMarker(sessionId))}[\\s\\S]*?${escapeRegExp(endMarker(sessionId))}`);
|
||||
if (re.test(content)) {
|
||||
content = content.replace(re, block);
|
||||
} else {
|
||||
// The running block went missing (manual edit?) — append the final one.
|
||||
if (!content.endsWith('\n')) content += '\n';
|
||||
content += `\n${block}\n`;
|
||||
}
|
||||
await fs.writeFile(indexPath, content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
// Once the code turn settles, summarize from the worktree diff + the agent's
|
||||
// final message and rewrite the row.
|
||||
async function finalizeFromResult(
|
||||
slug: string,
|
||||
args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string; baseBranch?: string; timedOut?: boolean; error?: string },
|
||||
): Promise<void> {
|
||||
const { sessionId, title, items, branch, worktreePath, baseBranch, timedOut, error } = args;
|
||||
|
||||
let summary = '';
|
||||
try {
|
||||
summary = (await extractAgentResponse(sessionId)) ?? '';
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Count everything the session changed since it forked — including commits
|
||||
// (the autonomous scaffold tells the agent to commit, so working-tree status
|
||||
// alone would read as "no changes"). Fall back to working-tree status if we
|
||||
// don't know the base.
|
||||
let files: GitStatusFile[] = [];
|
||||
try {
|
||||
files = baseBranch
|
||||
? await gitService.changedSinceBase(worktreePath, baseBranch)
|
||||
: await gitService.status(worktreePath);
|
||||
} catch { /* worktree may be gone */ }
|
||||
|
||||
const ins = files.reduce((a, f) => a + (f.insertions ?? 0), 0);
|
||||
const del = files.reduce((a, f) => a + (f.deletions ?? 0), 0);
|
||||
|
||||
let heading: string;
|
||||
let status: string;
|
||||
if (error) {
|
||||
heading = `#### ❌ ${title}`;
|
||||
status = `Failed — ${error}`;
|
||||
} else if (timedOut) {
|
||||
heading = `#### ⌛ ${title}`;
|
||||
status = `Timed out — open the session to check progress`;
|
||||
} else if (files.length > 0) {
|
||||
heading = `#### ✅ ${title}`;
|
||||
status = `Implemented — ${files.length} file(s) changed (+${ins} / -${del})`;
|
||||
} else {
|
||||
heading = `#### ⚠️ ${title}`;
|
||||
status = `No file changes — open the session for details`;
|
||||
}
|
||||
|
||||
const fileLines = files.slice(0, 25).map((f) => ` - \`${f.path}\` (${f.state})`);
|
||||
const more = files.length > 25 ? [` - …and ${files.length - 25} more`] : [];
|
||||
|
||||
const block = [
|
||||
startMarker(sessionId),
|
||||
heading,
|
||||
`- **Items:** ${items}`,
|
||||
`- **Branch:** \`${branch}\``,
|
||||
`- **Session:** \`${sessionId}\``,
|
||||
`- **Status:** ${status}`,
|
||||
...(files.length > 0 ? ['- **Files:**', ...fileLines, ...more] : []),
|
||||
...quoteSummary(summary),
|
||||
endMarker(sessionId),
|
||||
].join('\n');
|
||||
|
||||
await finalizeBlock(slug, sessionId, block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a coding session for a bg-task, asynchronously.
|
||||
*
|
||||
* Creates an isolated worktree session (yolo, direct, claude), fires the prompt
|
||||
* without waiting, writes a "running" row into the task's index.md, and detaches
|
||||
* a watcher that finalizes the row once the turn settles. Returns as soon as the
|
||||
* session exists so the bg-task agent can launch more groups (or finish).
|
||||
*/
|
||||
export async function launchCodeTask(args: LaunchCodeTaskArgs): Promise<LaunchCodeTaskResult> {
|
||||
const { taskSlug, meeting, title, items, prompt, context, runId } = args;
|
||||
|
||||
// Per-run launch cap — stop a runaway agent from spawning a session fleet.
|
||||
if (runId) {
|
||||
const used = launchesPerRun.get(runId) ?? 0;
|
||||
if (used >= MAX_LAUNCHES_PER_RUN) {
|
||||
return { success: false, error: `Launch cap reached (${MAX_LAUNCHES_PER_RUN} code sessions per run). Group remaining items instead of launching more.` };
|
||||
}
|
||||
launchesPerRun.set(runId, used + 1);
|
||||
}
|
||||
|
||||
const task = await fetchTask(taskSlug);
|
||||
if (!task) {
|
||||
return { success: false, error: `Background task '${taskSlug}' not found.` };
|
||||
}
|
||||
if (!task.projectId) {
|
||||
return { success: false, error: `Task '${taskSlug}' has no configured code project (repo). Set one to use launch-code-task.` };
|
||||
}
|
||||
|
||||
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
||||
const project = await projectsRepo.get(task.projectId);
|
||||
if (!project) {
|
||||
return { success: false, error: `Configured code project '${task.projectId}' is no longer registered.` };
|
||||
}
|
||||
|
||||
const codeSessionService = container.resolve<CodeSessionService>('codeSessionService');
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await codeSessionService.create({
|
||||
projectId: project.id,
|
||||
title,
|
||||
agent: 'claude',
|
||||
mode: 'direct',
|
||||
policy: 'yolo',
|
||||
isolation: 'worktree',
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: `Could not create code session: ${msg}` };
|
||||
}
|
||||
|
||||
const branch = session.worktree?.branch ?? 'rowboat/' + session.id;
|
||||
const baseBranch = session.worktree?.baseBranch ?? undefined;
|
||||
const worktreePath = session.cwd;
|
||||
|
||||
await appendRunningBlock(taskSlug, meeting, runningBlock({
|
||||
sessionId: session.id, title, items, branch, worktreePath,
|
||||
}));
|
||||
|
||||
const wrapped = buildCodePrompt({ prompt, branch, ...(context ? { context } : {}) });
|
||||
|
||||
log.log(`${taskSlug} — launched session ${session.id} on ${branch}`);
|
||||
|
||||
// Detached: drive the turn to completion, then finalize the index.md row.
|
||||
// `sendMessage` resolves when the turn settles (it awaits the engine and
|
||||
// never rejects on engine errors), so we don't need a separate completion
|
||||
// subscription — but we still cap it with a timeout so a wedged engine can't
|
||||
// pin the row at "running" forever.
|
||||
void (async () => {
|
||||
let timedOut = false;
|
||||
try {
|
||||
await Promise.race([
|
||||
codeSessionService.sendMessage(session.id, wrapped),
|
||||
new Promise<void>((resolve) => setTimeout(() => { timedOut = true; resolve(); }, MAX_WATCH_MS)),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.log(`${taskSlug} — session ${session.id} errored: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
try {
|
||||
await finalizeFromResult(taskSlug, {
|
||||
sessionId: session.id, title, items, branch, worktreePath, timedOut,
|
||||
...(baseBranch ? { baseBranch } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
log.log(`${taskSlug} — finalize failed for ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
})();
|
||||
|
||||
return { success: true, sessionId: session.id, branch, worktreePath };
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ export interface CreateTaskInput {
|
|||
name: string;
|
||||
instructions: string;
|
||||
triggers?: BackgroundTask['triggers'];
|
||||
projectId?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
|
@ -136,6 +137,7 @@ export async function createTask(input: CreateTaskInput): Promise<{ slug: string
|
|||
instructions: input.instructions,
|
||||
active: true,
|
||||
...(input.triggers ? { triggers: input.triggers } : {}),
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.provider ? { provider: input.provider } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
|
|
@ -194,6 +196,7 @@ export async function listTasks(opts: ListTasksOptions = {}): Promise<ListTasksR
|
|||
instructions: task.instructions,
|
||||
active: task.active,
|
||||
...(task.triggers ? { triggers: task.triggers } : {}),
|
||||
...(task.projectId ? { projectId: task.projectId } : {}),
|
||||
createdAt: task.createdAt,
|
||||
...(task.lastAttemptAt ? { lastAttemptAt: task.lastAttemptAt } : {}),
|
||||
...(task.lastRunId ? { lastRunId: task.lastRunId } : {}),
|
||||
|
|
|
|||
|
|
@ -31,11 +31,31 @@ const BG_TASK_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this e
|
|||
|
||||
const BG_TASK_MANUAL_PAREN = 'user-triggered — either the Run button in the Background Task detail view or the `run-background-task-agent` tool';
|
||||
|
||||
function buildCodeBlock(slug: string, project: { id: string; path: string; name: string }): string {
|
||||
return `
|
||||
|
||||
# Coding task
|
||||
|
||||
This is a **coding task**. It is pinned to a code repository:
|
||||
- **Project:** ${project.name}
|
||||
- **Path:** \`${project.path}\`
|
||||
|
||||
Your job this run:
|
||||
1. Read the relevant source (e.g. the meeting notes named in the trigger below) and identify **actionable coding items** — bugs to fix, features to build, concrete changes requested.
|
||||
2. Be **conservative**: only implement items that are clearly scoped and self-contained. Items that are ambiguous, large/architectural, or about a different repository — do NOT code them. List them briefly in \`index.md\` as "needs review" instead.
|
||||
3. **Group** related items together; keep unrelated items separate.
|
||||
4. For each group, call the \`launch-code-task\` tool with \`taskSlug: "${slug}"\`, the \`meeting\` name/title these items came from (so sessions are grouped by meeting), a short \`title\`, the \`items\` summary, and a **detailed, fully self-contained \`prompt\`** describing exactly what to implement (the coding agent has no other context and no human to ask). Put the relevant meeting excerpt in \`context\`.
|
||||
5. \`launch-code-task\` runs asynchronously in an isolated git worktree (full-auto) and manages a \`## Code Sessions\` section in \`index.md\` itself — **do not edit that section.** You may add a short note ABOVE it summarizing what you detected.
|
||||
|
||||
If there are no actionable coding items, launch nothing and say so in your final summary.`;
|
||||
}
|
||||
|
||||
function buildMessage(
|
||||
slug: string,
|
||||
task: BackgroundTask,
|
||||
trigger: BackgroundTaskTriggerType,
|
||||
context?: string,
|
||||
codeProject?: { id: string; path: string; name: string },
|
||||
): string {
|
||||
const now = new Date();
|
||||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
|
|
@ -50,7 +70,7 @@ function buildMessage(
|
|||
**Instructions:**
|
||||
${task.instructions}
|
||||
|
||||
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
|
||||
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).${codeProject ? buildCodeBlock(slug, codeProject) : ''}`;
|
||||
|
||||
return baseMessage + buildTriggerBlock({
|
||||
trigger,
|
||||
|
|
@ -103,6 +123,20 @@ export async function runBackgroundTask(
|
|||
// `||` not `??`: an empty-string `task.model` (occasionally synthesized
|
||||
// by an LLM call to create-background-task) should fall through to the
|
||||
// default just like undefined does.
|
||||
// Coding tasks carry a pinned code project — resolve it so the run
|
||||
// message can tell the agent which repo to work in.
|
||||
let codeProject: { id: string; path: string; name: string } | undefined;
|
||||
if (task.projectId) {
|
||||
try {
|
||||
const { default: container } = await import('../di/container.js');
|
||||
const projectsRepo = container.resolve<import('../code-mode/projects/repo.js').ICodeProjectsRepo>('codeProjectsRepo');
|
||||
const project = await projectsRepo.get(task.projectId);
|
||||
if (project) codeProject = { id: project.id, path: project.path, name: project.name };
|
||||
} catch (err) {
|
||||
log.log(`${slug} — could not resolve code project ${task.projectId}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const model = task.model || await getBackgroundTaskAgentModel();
|
||||
const agentRun = await createRun({
|
||||
agentId: 'background-task-agent',
|
||||
|
|
@ -129,9 +163,16 @@ export async function runBackgroundTask(
|
|||
// we leave `lastRunAt` / `lastRunSummary` / `lastRunError` untouched —
|
||||
// the previous successful run stays visible in the UI even while this
|
||||
// new run is in-flight or fails.
|
||||
// `projectId` is runtime-owned config the agent must never lose. A weak
|
||||
// model can clobber task.yaml mid-run (despite "never touch this"), which
|
||||
// would silently disable coding on later runs — so we re-assert it on
|
||||
// every patch to self-heal.
|
||||
const heal = task.projectId ? { projectId: task.projectId } : {};
|
||||
|
||||
await patchTask(slug, {
|
||||
lastAttemptAt: startedAt,
|
||||
lastRunId: runId,
|
||||
...heal,
|
||||
});
|
||||
|
||||
backgroundTaskBus.publish({
|
||||
|
|
@ -142,7 +183,7 @@ export async function runBackgroundTask(
|
|||
});
|
||||
|
||||
try {
|
||||
await createMessage(runId, buildMessage(slug, task, trigger, context));
|
||||
await createMessage(runId, buildMessage(slug, task, trigger, context, codeProject));
|
||||
await waitForRunCompletion(runId, { throwOnError: true });
|
||||
const summary = await extractAgentResponse(runId);
|
||||
|
||||
|
|
@ -151,6 +192,7 @@ export async function runBackgroundTask(
|
|||
lastRunAt: new Date().toISOString(),
|
||||
lastRunSummary: summary ?? undefined,
|
||||
lastRunError: undefined,
|
||||
...heal,
|
||||
});
|
||||
|
||||
log.log(`${slug} — done summary="${truncate(summary)}"`);
|
||||
|
|
@ -171,7 +213,7 @@ export async function runBackgroundTask(
|
|||
// state; the scheduler's backoff (lastAttemptAt + 5min) prevents
|
||||
// retry-storming.
|
||||
try {
|
||||
await patchTask(slug, { lastRunError: msg });
|
||||
await patchTask(slug, { lastRunError: msg, ...heal });
|
||||
} catch {
|
||||
// don't mask the original error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,67 @@ export async function status(cwd: string): Promise<GitStatusFile[]> {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Everything this worktree's branch changed since it forked from `baseRef` —
|
||||
// committed AND uncommitted. `status()` only sees the working tree (uncommitted),
|
||||
// so it misses work an agent committed; this is what you want for a session
|
||||
// summary. Counts come from numstat, states from name-status, merged by path.
|
||||
export async function changedSinceBase(cwd: string, baseRef: string): Promise<GitStatusFile[]> {
|
||||
let forkPoint = baseRef;
|
||||
try {
|
||||
forkPoint = (await git(cwd, ['merge-base', baseRef, 'HEAD'])).trim() || baseRef;
|
||||
} catch {
|
||||
forkPoint = baseRef;
|
||||
}
|
||||
|
||||
const stateByPath = new Map<string, GitFileState>();
|
||||
try {
|
||||
const ns = await git(cwd, ['diff', '--name-status', '-z', forkPoint]);
|
||||
const parts = ns.split('\0');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const code = parts[i];
|
||||
if (!code) continue;
|
||||
const letter = code[0];
|
||||
if (letter === 'R' || letter === 'C') {
|
||||
// rename/copy: "<code>\0<old>\0<new>"
|
||||
const newPath = parts[i + 2];
|
||||
i += 2;
|
||||
if (newPath) stateByPath.set(newPath, 'renamed');
|
||||
} else {
|
||||
const p = parts[i + 1];
|
||||
i += 1;
|
||||
if (p) stateByPath.set(p, letter === 'A' ? 'added' : letter === 'D' ? 'deleted' : 'modified');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// bad ref / no commits — leave states empty
|
||||
}
|
||||
|
||||
const result: GitStatusFile[] = [];
|
||||
try {
|
||||
const numstat = await git(cwd, ['diff', '--numstat', '-z', forkPoint]);
|
||||
const rows = numstat.split('\0');
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) continue;
|
||||
const m = row.match(/^(\d+|-)\t(\d+|-)\t?(.*)$/);
|
||||
if (!m) continue;
|
||||
const insertions = m[1] === '-' ? null : Number(m[1]);
|
||||
const deletions = m[2] === '-' ? null : Number(m[2]);
|
||||
let filePath = m[3];
|
||||
if (!filePath) {
|
||||
// rename form: old and new paths follow as separate tokens
|
||||
i += 2;
|
||||
filePath = rows[i] ?? '';
|
||||
}
|
||||
if (!filePath) continue;
|
||||
result.push({ path: filePath, state: stateByPath.get(filePath) ?? 'modified', insertions, deletions });
|
||||
}
|
||||
} catch {
|
||||
// bad ref / no commits — nothing to report
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import container from '../../di/container.js';
|
|||
import { IGranolaConfigRepo } from './repo.js';
|
||||
import { serviceLogger } from '../../services/service_logger.js';
|
||||
import { limitEventItems } from '../limit_event_items.js';
|
||||
import { publishMeetingNotesReadyEvent } from '../meeting-events.js';
|
||||
import {
|
||||
GetDocumentsResponse,
|
||||
SyncState,
|
||||
|
|
@ -439,6 +440,14 @@ async function syncNotes(): Promise<void> {
|
|||
} else {
|
||||
console.log(`[Granola] Saved: ${filename}`);
|
||||
newCount++;
|
||||
// First-time write only — don't re-fire on later edits to the
|
||||
// same note (Granola notes update live).
|
||||
await publishMeetingNotesReadyEvent({
|
||||
source: 'granola',
|
||||
title: docTitle,
|
||||
filePath,
|
||||
when: docDate.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Update state
|
||||
|
|
|
|||
37
apps/x/packages/core/src/knowledge/meeting-events.ts
Normal file
37
apps/x/packages/core/src/knowledge/meeting-events.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { createEvent } from '../events/producer.js';
|
||||
|
||||
// Emitted when a meeting note/transcript is first written to disk (Fireflies,
|
||||
// Granola, …). This is the natural "the meeting is over and we have content"
|
||||
// signal — unlike a calendar end-time, the notes actually exist now. Coding
|
||||
// background tasks subscribe to it (via eventMatchCriteria) to scan freshly
|
||||
// landed notes for actionable coding items.
|
||||
//
|
||||
// Fire ONCE per meeting (on first write), not on every re-sync/edit, so a note
|
||||
// that keeps updating doesn't re-trigger downstream agents.
|
||||
export async function publishMeetingNotesReadyEvent(args: {
|
||||
source: string;
|
||||
title: string;
|
||||
filePath: string;
|
||||
when?: string;
|
||||
}): Promise<void> {
|
||||
const { source, title, filePath, when } = args;
|
||||
try {
|
||||
await createEvent({
|
||||
source,
|
||||
type: 'meeting.notes_ready',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: [
|
||||
`# Meeting notes ready`,
|
||||
``,
|
||||
`**Title:** ${title}`,
|
||||
when ? `**When:** ${when}` : ``,
|
||||
`**Source:** ${source}`,
|
||||
`**Notes file:** \`${filePath}\``,
|
||||
``,
|
||||
`The full meeting notes/transcript are at the path above. They may contain coding action items (bugs to fix, features to build, changes requested). Read the file to decide whether to act.`,
|
||||
].filter(Boolean).join('\n'),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[${source}] Failed to publish meeting.notes_ready event:`, err);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { WorkDir } from '../config/config.js';
|
|||
import { FirefliesClientFactory } from './fireflies-client-factory.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { publishMeetingNotesReadyEvent } from './meeting-events.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
|
||||
|
|
@ -583,6 +584,15 @@ async function syncMeetings() {
|
|||
fs.writeFileSync(filePath, markdown);
|
||||
console.log(`[Fireflies] Saved: ${filename}`);
|
||||
|
||||
// First-time write for this meeting (guarded by syncedIds above) —
|
||||
// signal that fresh notes are available for downstream agents.
|
||||
await publishMeetingNotesReadyEvent({
|
||||
source: 'fireflies',
|
||||
title: meetingData.title || 'untitled',
|
||||
filePath,
|
||||
...(meetingData.dateString ? { when: meetingData.dateString } : {}),
|
||||
});
|
||||
|
||||
syncedIds.add(meetingId);
|
||||
newCount++;
|
||||
processedInBatch++;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ export type BackgroundTask = {
|
|||
instructions: string;
|
||||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
// When set, this is a *coding* task: it implements code in the pinned code
|
||||
// project (a registered repo) via the `launch-code-task` tool, each launch
|
||||
// running in its own isolated worktree. Omit for ordinary OUTPUT/ACTION tasks.
|
||||
projectId?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
createdAt: string;
|
||||
|
|
@ -48,6 +52,7 @@ export type BackgroundTaskSummary = {
|
|||
instructions: string;
|
||||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
projectId?: string;
|
||||
createdAt: string;
|
||||
lastAttemptAt?: string;
|
||||
lastRunId?: string;
|
||||
|
|
@ -56,11 +61,14 @@ export type BackgroundTaskSummary = {
|
|||
lastRunError?: string;
|
||||
};
|
||||
|
||||
// NOTE: keep `BackgroundTaskSummary` (above) and `BackgroundTask` (top) in sync.
|
||||
|
||||
export const BackgroundTaskSchema = z.object({
|
||||
name: z.string().min(1).describe('User-facing display name.'),
|
||||
instructions: z.string().min(1).describe('A persistent instruction in the user\'s words — what should this task keep doing? E.g. "Summarize my unread emails every morning into a brief digest." The agent re-reads instructions on every run and decides whether to rewrite index.md (OUTPUT mode) or perform a side-effect and journal it (ACTION mode) based on the verbs.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
|
||||
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
|
||||
projectId: z.string().optional().describe('When set, marks this as a coding task pinned to a registered code project (repo). The agent implements detected work via the launch-code-task tool, each launch in its own isolated worktree.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
|
||||
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
|
||||
|
|
@ -77,6 +85,7 @@ export const BackgroundTaskSummarySchema = z.object({
|
|||
instructions: z.string(),
|
||||
active: z.boolean(),
|
||||
triggers: TriggersSchema.optional(),
|
||||
projectId: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
lastAttemptAt: z.string().optional(),
|
||||
lastRunId: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -1299,6 +1299,7 @@ const ipcSchemas = {
|
|||
name: z.string(),
|
||||
instructions: z.string(),
|
||||
triggers: TriggersSchema.optional(),
|
||||
projectId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue