From f3282c3173da5eb1640b926ffa2aca3bb87fbee4 Mon Sep 17 00:00:00 2001 From: wangtao Date: Thu, 1 Oct 2026 23:42:13 +0800 Subject: [PATCH] feat(goals): delegate existing milestones without completing them --- apps/mobile/src/agent-ui.tsx | 101 +++++++++++++++---- apps/mobile/src/agent-workspace.tsx | 5 +- apps/mobile/src/details.tsx | 9 +- apps/mobile/src/workspace.tsx | 2 +- apps/server/src/db.ts | 16 +++ apps/server/src/engine/routes.ts | 21 +++- apps/server/src/engine/service.ts | 22 ++-- packages/domain/src/agent.ts | 2 + tests/agent-api.test.ts | 94 +++++++++++++++++ tests/workflows.test.ts | 150 +++++++++++++++++++++++++++- 10 files changed, 385 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/agent-ui.tsx b/apps/mobile/src/agent-ui.tsx index e7034b5d..ead83397 100644 --- a/apps/mobile/src/agent-ui.tsx +++ b/apps/mobile/src/agent-ui.tsx @@ -19,7 +19,7 @@ import { Users, X, } from "lucide-react-native"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Image, Linking, Pressable, Text, View } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import type { Artifact, BrowserSession } from "../../../packages/domain/src"; @@ -282,6 +282,9 @@ export function TaskDetail({ taskId }: { taskId: string }) { const [showFieldJson, setShowFieldJson] = useState(false); const [fields, setFields] = useState>({}); const task = data?.tasks.find((item) => item.id === taskId) || detail?.task; + const milestone = data?.goals + .find((goal) => goal.id === task?.goalId) + ?.milestones.find((item) => item.id === task?.milestoneId); useEffect(() => { let active = true; void api @@ -385,6 +388,13 @@ export function TaskDetail({ taskId }: { taskId: string }) { {task.prompt} + {task.milestoneId && data && ( + + {milestone?.title + ? `Milestone: ${milestone.title}. Completion is tracked manually in Goals.` + : "The linked milestone is no longer available. This task and its results are still accessible."} + + )} {["queued", "running", "scheduled", "waiting_input", "waiting_approval"].includes( task.status, @@ -874,28 +884,50 @@ function FinanceArtifact({ artifact }: { artifact: AgentArtifact }) { ); } -export function DelegateSheet() { +export function DelegateSheet({ goalId, milestoneId }: { goalId?: string; milestoneId?: string }) { const { workspace, close, open } = useWorkspace(); - const { delegate } = useAgentWorkspace(); + const { data, delegate } = useAgentWorkspace(); + const goal = data?.goals.find((item) => item.id === goalId); + const milestone = goal?.milestones.find((item) => item.id === milestoneId); + const submission = useRef<{ input: string; id: string } | null>(null); + const submitting = useRef(false); const [kind, setKind] = useState("plan"); - const [prompt, setPrompt] = useState(""); + const [title] = useState(milestone?.title); + const [prompt, setPrompt] = useState( + milestone && goal + ? `Help with this milestone: ${milestone.title}\nGoal: ${goal.title}\n${goal.description}` + : "", + ); const [messageId, setMessageId] = useState(""); const [csv, setCsv] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); async function submit() { + if (submitting.current) return; + submitting.current = true; setBusy(true); setError(""); try { - const task = await delegate({ + const input = { + title, prompt: prompt.trim(), kind, + goalId, + milestoneId, input: kind === "finance" ? { csv } : kind === "document" ? { messageId } : {}, - }); + }; + const serialized = JSON.stringify(input); + if (submission.current?.input !== serialized) + submission.current = { + input: serialized, + id: `delegate-${Date.now()}-${Math.random().toString(36).slice(2)}`, + }; + const task = await delegate(input, submission.current.id); open({ type: "task", taskId: task.id }); } catch (e) { setError(errorText(e)); } finally { + submitting.current = false; setBusy(false); } } @@ -905,6 +937,13 @@ export function DelegateSheet() { subtitle="OpenMuse saves a plan and keeps working on the server." onClose={close} > + {milestoneId && ( + + {milestone + ? `${goal?.title} ยท ${milestone.title}. Mark this milestone complete when you are satisfied with the result.` + : "This milestone is no longer available."} + + )} {(["plan", "document", "finance", "agent"] as const).map((item) => ( + )} + {data?.tasks + .filter((task) => task.goalId === goal.id && task.milestoneId === milestone.id) + .map((task) => ( + + ))} + ))} @@ -1421,7 +1474,11 @@ function GoalCard({ goal, onOpenTask }: { goal: Goal; onOpenTask?: () => void }) {data?.tasks - .filter((task) => task.goalId === goal.id) + .filter( + (task) => + task.goalId === goal.id && + (!task.milestoneId || !goal.milestones.some((m) => m.id === task.milestoneId)), + ) .map((task) => ( ))} diff --git a/apps/mobile/src/agent-workspace.tsx b/apps/mobile/src/agent-workspace.tsx index 2928b61b..9ddf2a39 100644 --- a/apps/mobile/src/agent-workspace.tsx +++ b/apps/mobile/src/agent-workspace.tsx @@ -20,7 +20,7 @@ interface AgentContextValue { error: string; refresh: () => Promise; mutate: (path: string, body: unknown) => Promise; - delegate: (input: CreateTaskInput) => Promise; + delegate: (input: CreateTaskInput, requestId?: string) => Promise; } const AgentContext = createContext(null); export function AgentWorkspaceProvider({ children }: { children: ReactNode }) { @@ -81,7 +81,8 @@ export function AgentWorkspaceProvider({ children }: { children: ReactNode }) { [api, refresh], ); const delegate = useCallback( - (input: CreateTaskInput) => mutate("/tasks", input), + (input: CreateTaskInput, requestId?: string) => + mutate("/tasks", { ...input, requestId }), [mutate], ); return ( diff --git a/apps/mobile/src/details.tsx b/apps/mobile/src/details.tsx index 1ff3a14a..8751ff92 100644 --- a/apps/mobile/src/details.tsx +++ b/apps/mobile/src/details.tsx @@ -61,7 +61,14 @@ export function Details({ detail }: { detail: Detail }) { const { close, navigate } = useWorkspace(); if (detail.type === "computer") return ; if (detail.type === "task") return ; - if (detail.type === "delegate") return ; + if (detail.type === "delegate") + return ( + + ); if (detail.type === "notifications") return ; if (detail.type === "mail") return ; if (detail.type === "email") return ; diff --git a/apps/mobile/src/workspace.tsx b/apps/mobile/src/workspace.tsx index 166a1f0d..552aedc2 100644 --- a/apps/mobile/src/workspace.tsx +++ b/apps/mobile/src/workspace.tsx @@ -19,7 +19,7 @@ export type Detail = | { type: "browser"; browser: BrowserSession } | { type: "review"; action: ActionProposal } | { type: "task"; taskId: string } - | { type: "delegate" } + | { type: "delegate"; goalId?: string; milestoneId?: string } | { type: "notifications" } | { type: "computer" } | { type: "menu" }; diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 29075322..f272c257 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -57,6 +57,22 @@ export class Store { ); return (result.rows[0]?.data as T | undefined) ?? null; } + // Update only the selected checkbox against the current row, preserving concurrent + // additions, renames, reordering and other milestones' completion state. + async setMilestoneDone(owner: string, goalId: string, milestoneId: string, done: boolean) { + const result = await this.db.query( + `UPDATE records SET data=jsonb_set(data, '{milestones}', ( + SELECT jsonb_agg(CASE WHEN item->>'id'=$3 + THEN item || jsonb_build_object('done', $4::boolean) ELSE item END ORDER BY ordinal) + FROM jsonb_array_elements(data->'milestones') WITH ORDINALITY AS milestones(item, ordinal) + )), updated_at=now() + WHERE owner=$1 AND kind='goals' AND id=$2 + AND EXISTS (SELECT 1 FROM jsonb_array_elements(data->'milestones') item WHERE item->>'id'=$3) + RETURNING data`, + [owner, goalId, milestoneId, done], + ); + return (result.rows[0]?.data as T | undefined) ?? null; + } async insertIfAbsent( owner: string, kind: string, diff --git a/apps/server/src/engine/routes.ts b/apps/server/src/engine/routes.ts index 139bf61c..69d94d69 100644 --- a/apps/server/src/engine/routes.ts +++ b/apps/server/src/engine/routes.ts @@ -6,6 +6,7 @@ import type { AgentMemory, AgentNotification, } from "../../../../packages/domain/src/agent.ts"; +import { createTaskSchema } from "../../../../packages/domain/src/agent.ts"; import { AppError } from "../errors.ts"; import type { AgentService } from "./service.ts"; @@ -28,9 +29,12 @@ const goalPatchSchema = z.object({ export function agentRoutes(service: AgentService): Hono<{ Variables: { owner: string } }> { const app = new Hono<{ Variables: { owner: string } }>(); app.get("/", async (c) => c.json(await service.snapshot(c.get("owner")))); - app.post("/tasks", async (c) => - c.json(await service.createTask(c.get("owner"), await c.req.json()), 201), - ); + app.post("/tasks", async (c) => { + const { requestId, ...input } = createTaskSchema + .extend({ requestId: z.string().min(1).max(200).optional() }) + .parse(await c.req.json()); + return c.json(await service.createTask(c.get("owner"), input, requestId), 201); + }); app.get("/tasks/:id", async (c) => c.json(await service.detail(c.get("owner"), c.req.param("id"))), ); @@ -60,6 +64,17 @@ export function agentRoutes(service: AgentService): Hono<{ Variables: { owner: s const body = goalPatchSchema.parse(await c.req.json()); return c.json(await service.updateGoal(c.get("owner"), c.req.param("id"), body)); }); + app.post("/goals/:id/milestones/:milestoneId", async (c) => { + const { done } = z.object({ done: z.boolean() }).parse(await c.req.json()); + return c.json( + await service.setMilestoneDone( + c.get("owner"), + c.req.param("id"), + c.req.param("milestoneId"), + done, + ), + ); + }); app.post("/monitors", async (c) => c.json(await service.createMonitor(c.get("owner"), await c.req.json()), 201), ); diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index 3f2af061..60bd786f 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -179,11 +179,14 @@ export class AgentService { } async createTask(owner: string, raw: unknown, idempotencyKey?: string, held = false) { const input = createTaskSchema.parse(raw); - if (input.goalId && !(await this.db.get(owner, "goals", input.goalId))) - throw new AppError("Goal not found", 404); const id = idempotencyKey ? hash(`task:${idempotencyKey}`) : randomUUID(); const existing = await this.db.get(owner, "tasks", id); if (existing) return existing; + if (input.milestoneId && !input.goalId) throw new AppError("A milestone requires a goal", 422); + const goal = input.goalId ? await this.db.get(owner, "goals", input.goalId) : null; + if (input.goalId && !goal) throw new AppError("Goal not found", 404); + if (input.milestoneId && !goal?.milestones.some((m) => m.id === input.milestoneId)) + throw new AppError("Milestone not found in this goal", 404); if ( (await this.db.list(owner, "tasks")).filter((t) => !terminal.has(t.status)) .length >= 100 @@ -209,7 +212,8 @@ export class AgentService { prompt: input.prompt, kind: input.kind, goalId: input.goalId, - status: held ? "paused" : "queued", + milestoneId: input.milestoneId, + status: held || goal?.status === "paused" ? "paused" : "queued", plan: titles.map((title, i) => ({ id: String(i), title, status: "pending" })), evidence: [], input: input.input, @@ -349,15 +353,19 @@ export class AgentService { id: string, patch: { status?: Goal["status"]; milestones?: Goal["milestones"] }, ) { - const goal = await this.db.get(owner, "goals", id); - if (!goal) throw new AppError("Goal not found", 404); - const saved = await this.db.put(owner, "goals", { ...goal, ...patch }); + const saved = await this.db.compareAndSwap(owner, "goals", id, {}, patch); + if (!saved) throw new AppError("Goal not found", 404); if (patch.status === "paused") for (const task of await this.db.list(owner, "tasks")) if (task.goalId === id && !terminal.has(task.status) && task.status !== "paused") await this.control(owner, task.id, "pause"); return saved; } + async setMilestoneDone(owner: string, goalId: string, milestoneId: string, done: boolean) { + const saved = await this.db.setMilestoneDone(owner, goalId, milestoneId, done); + if (!saved) throw new AppError("Goal or milestone not found", 404); + return saved; + } async createMonitor(owner: string, raw: unknown, idempotencyKey?: string) { const input = monitorInputSchema.parse(raw); const url = new URL(input.url); @@ -843,7 +851,7 @@ export class AgentService { task.id, `task-done:${task.id}`, ); - if (task.goalId) { + if (task.goalId && !task.milestoneId) { for (let attempt = 0; attempt < 8; attempt++) { const goal = await this.db.get(owner, "goals", task.goalId); if (!goal || goal.milestones.some((m) => m.id === task.id)) break; diff --git a/packages/domain/src/agent.ts b/packages/domain/src/agent.ts index 71fc78e3..c2d869bd 100644 --- a/packages/domain/src/agent.ts +++ b/packages/domain/src/agent.ts @@ -30,6 +30,7 @@ export interface AgentTask { kind: "agent" | "document" | "monitor" | "finance" | "plan"; status: TaskStatus; goalId?: string; + milestoneId?: string; plan: TaskStep[]; evidence: Evidence[]; input: Record; @@ -136,6 +137,7 @@ export const createTaskSchema = z.object({ prompt: z.string().trim().min(1).max(12000), kind: z.enum(["agent", "document", "monitor", "finance", "plan"]).default("agent"), goalId: z.string().optional(), + milestoneId: z.string().min(1).max(200).optional(), input: z.record(z.string(), z.unknown()).default({}), }); export type CreateTaskInput = z.infer; diff --git a/tests/agent-api.test.ts b/tests/agent-api.test.ts index c3b58a34..fd7371f6 100644 --- a/tests/agent-api.test.ts +++ b/tests/agent-api.test.ts @@ -344,3 +344,97 @@ test("live mode rejects sample sources and hides the fixture mutation endpoint", await live.agent.stop(); } }); + +test("milestone delegation validates membership and deduplicates only the same request", async () => { + const goal = await read( + "/goals", + { title: "Budget", milestones: ["Review spending", "Save"] }, + 201, + ); + const other = await read( + "/goals", + { title: "Other", milestones: ["Review spending"] }, + 201, + ); + const privateGoal = await server.agent.createGoal("other-user", { + title: "Private", + milestones: ["Review spending"], + }); + const input = { + prompt: "Review spending", + goalId: goal.id, + milestoneId: goal.milestones[0].id, + requestId: "budget-request", + }; + for (const [bad, status] of [ + [{ ...input, goalId: undefined }, 422], + [{ ...input, goalId: privateGoal.id, milestoneId: privateGoal.milestones[0].id }, 404], + [{ ...input, milestoneId: other.milestones[0].id }, 404], + [{ ...input, milestoneId: "missing" }, 404], + ] as const) + assert.equal((await request("/tasks", bad)).status, status); + const [first, replay] = await Promise.all([ + read("/tasks", input, 201), + read("/tasks", input, 201), + ]); + assert.equal(first.id, replay.id); + assert.equal(first.milestoneId, input.milestoneId); + const later = await read("/tasks", { ...input, requestId: "budget-later" }, 201); + assert.notEqual(first.id, later.id); + await read(`/goals/${goal.id}`, { status: "paused" }); + assert.equal((await read<{ task: AgentTask }>(`/tasks/${first.id}`)).task.status, "paused"); + const paused = await read("/tasks", { ...input, requestId: "budget-paused" }, 201); + assert.equal(paused.status, "paused"); + // A replay still recovers the original task after the milestone has been removed. + await read(`/goals/${goal.id}`, { milestones: [] }); + assert.equal((await read("/tasks", input, 201)).id, first.id); + assert.equal((await request("/tasks", { ...input, requestId: "budget-deleted" })).status, 404); + assert.equal( + (await read<{ task: AgentTask }>(`/tasks/${first.id}`)).task.milestoneId, + input.milestoneId, + ); +}); + +test("manual milestone completion preserves current titles, ordering and concurrent progress", async () => { + const goal = await read( + "/goals", + { title: "Travel", milestones: ["Dates", "Tickets"] }, + 201, + ); + const [dates, tickets] = goal.milestones; + const renamed = { ...dates, title: "Confirm dates" }; + await read(`/goals/${goal.id}`, { + milestones: [tickets, renamed, { id: "new", title: "Pack", done: true }], + }); + await Promise.all([ + read(`/goals/${goal.id}/milestones/${dates.id}`, { done: true }), + read(`/goals/${goal.id}/milestones/${tickets.id}`, { done: true }), + read(`/goals/${goal.id}`, { status: "paused" }), + ]); + const saved = (await read("")).goals.find((item) => item.id === goal.id); + assert.deepEqual(saved?.milestones, [ + { ...tickets, done: true }, + { ...renamed, done: true }, + { id: "new", title: "Pack", done: true }, + ]); + assert.equal(saved?.status, "paused"); + await read(`/goals/${goal.id}/milestones/${dates.id}`, { done: false }); + assert.equal( + (await request(`/goals/${goal.id}/milestones/${dates.id}`, { done: "yes" })).status, + 422, + ); + await read(`/goals/${goal.id}`, { milestones: [tickets] }); + assert.equal( + (await request(`/goals/${goal.id}/milestones/${dates.id}`, { done: true })).status, + 404, + ); + const hidden = await server.agent.createGoal("other-user", { + title: "Private", + milestones: ["Private step"], + }); + assert.equal( + (await request(`/goals/${hidden.id}/milestones/${hidden.milestones[0].id}`, { done: true })) + .status, + 404, + ); +}); diff --git a/tests/workflows.test.ts b/tests/workflows.test.ts index 9a3d254f..8883858e 100644 --- a/tests/workflows.test.ts +++ b/tests/workflows.test.ts @@ -5,11 +5,18 @@ import { join } from "node:path"; import { after, before, test } from "node:test"; import { createApp } from "../apps/server/src/app.ts"; import { createStore, type Store } from "../apps/server/src/db.ts"; -import type { AgentNotification, AgentTask, Idea, Monitor } from "../packages/domain/src/agent.ts"; +import type { + AgentNotification, + AgentTask, + Goal, + Idea, + Monitor, +} from "../packages/domain/src/agent.ts"; import type { ActionProposal } from "../packages/domain/src/index.ts"; let db: Store, server: Awaited>, directory: string; const owner = "workflow-user"; +const reconcile = () => (server.agent as unknown as { maintain(): Promise }).maintain(); before(async () => { directory = await mkdtemp(join(tmpdir(), "openmuse-workflows-")); db = await createStore({ dataDir: join(directory, "db") }); @@ -217,3 +224,144 @@ test("dismissal racing acceptance never creates work for a dismissed idea", asyn else assert.ok(saved?.taskId && tasks.some((t) => t.id === saved.taskId)); } }); + +test("milestone-linked outcomes preserve manual progress and legacy goal tasks still append once", async () => { + const goal = await server.agent.createGoal(owner, { + title: "Budget", + milestones: ["Review spending", "Save"], + }); + const input = { + prompt: "Review spending", + kind: "finance", + goalId: goal.id, + milestoneId: goal.milestones[0].id, + input: { csv: "date,description,amount,category\n2026-09-01,Groceries,54.20,Food" }, + }; + const task = await server.agent.createTask(owner, input, "linked-finance"); + const reordered = [ + { ...goal.milestones[1], done: true }, + { ...goal.milestones[0], title: "Review September" }, + ]; + await server.agent.updateGoal(owner, goal.id, { milestones: reordered }); + await server.agent.worker.tick(); + const detail = await server.agent.detail(owner, task.id); + assert.equal(detail.task.status, "succeeded"); + assert.equal(detail.task.milestoneId, goal.milestones[0].id); + assert.ok(detail.artifacts.length); + assert.deepEqual((await db.get(owner, "goals", goal.id))?.milestones, reordered); + await server.agent.setMilestoneDone(owner, goal.id, goal.milestones[0].id, true); + // Startup maintenance replays durable outcomes after process interruptions. + await reconcile(); + const completed = reordered.map((m) => ({ ...m, done: true })); + assert.deepEqual((await db.get(owner, "goals", goal.id))?.milestones, completed); + await server.agent.updateGoal(owner, goal.id, { milestones: [completed[0]] }); + await reconcile(); + assert.deepEqual((await db.get(owner, "goals", goal.id))?.milestones, [completed[0]]); + assert.ok((await server.agent.detail(owner, task.id)).artifacts.length); + const legacy = await server.agent.createTask( + owner, + { ...input, milestoneId: undefined }, + "legacy-finance", + ); + await server.agent.worker.tick(); + await reconcile(); + await reconcile(); + const milestones = (await db.get(owner, "goals", goal.id))?.milestones; + assert.equal(milestones?.length, 2); + assert.equal(milestones?.filter((m) => m.id === legacy.id && m.done).length, 1); +}); + +test("failed, cancelled and review-blocked tasks do not complete milestones", async () => { + const goal = await server.agent.createGoal(owner, { + title: "Documents", + milestones: ["Return form"], + }); + const link = { goalId: goal.id, milestoneId: goal.milestones[0].id }; + const failed = await server.agent.createTask(owner, { + ...link, + prompt: "Analyze invalid CSV", + kind: "finance", + input: { csv: "invalid" }, + }); + await server.agent.worker.tick(); + assert.equal((await server.agent.getTask(owner, failed.id)).status, "failed"); + const cancelled = await server.agent.createTask(owner, { ...link, prompt: "Cancelled" }); + await server.agent.control(owner, cancelled.id, "cancel"); + const mail = (await server.workspace.snapshot(owner)).mail.find((m) => m.attachments.length); + assert.ok(mail); + const document = await server.agent.createTask(owner, { + ...link, + prompt: "Return form", + kind: "document", + input: { messageId: mail.id }, + }); + await server.agent.worker.tick(); + assert.equal((await server.agent.getTask(owner, document.id)).status, "waiting_input"); + assert.deepEqual((await db.get(owner, "goals", goal.id))?.milestones, goal.milestones); + await server.agent.answer(owner, document.id, "Fictional test values", { + participant_name: "Test Student", + guardian_name: "Test Guardian", + permission_granted: true, + }); + await server.agent.worker.tick(); + const waiting = await server.agent.getTask(owner, document.id); + assert.equal(waiting.status, "waiting_approval"); + assert.deepEqual((await db.get(owner, "goals", goal.id))?.milestones, goal.milestones); + await server.agent.updateGoal(owner, goal.id, { status: "paused" }); + assert.ok(waiting.actionId); + const action = await db.get(owner, "actions", waiting.actionId); + assert.ok(action); + await assert.rejects( + server.actions.decide(owner, action.id, action.hash, "approve"), + /Resume the task/, + ); + await server.agent.control(owner, document.id, "cancel"); + await reconcile(); + assert.deepEqual((await db.get(owner, "goals", goal.id))?.milestones, goal.milestones); +}); + +test("linked task results and manual completion survive a database restart", async () => { + const root = await mkdtemp(join(tmpdir(), "openmuse-milestone-restart-")); + const config = { ...server.agent.config, dataDir: root }; + let store = await createStore({ dataDir: join(root, "db") }); + let app = await createApp(store, config); + try { + const goal = await app.agent.createGoal(owner, { + title: "Budget", + milestones: ["Review spending"], + }); + const task = await app.agent.createTask( + owner, + { + prompt: "Review spending", + kind: "finance", + goalId: goal.id, + milestoneId: goal.milestones[0].id, + input: { csv: "date,description,amount,category\n2026-09-01,Groceries,54.20,Food" }, + }, + "restart-delegation", + ); + await app.agent.worker.tick(); + assert.equal((await app.agent.getTask(owner, task.id)).status, "succeeded"); + await app.agent.stop(); + await store.close(); + store = await createStore({ dataDir: join(root, "db") }); + app = await createApp(store, config); + app.agent.start(); + await app.agent.stop(); + assert.deepEqual((await store.get(owner, "goals", goal.id))?.milestones, goal.milestones); + const restored = await app.agent.detail(owner, task.id); + assert.equal(restored.task.milestoneId, goal.milestones[0].id); + assert.ok(restored.artifacts.length); + await app.agent.setMilestoneDone(owner, goal.id, goal.milestones[0].id, true); + app.agent.start(); + await app.agent.stop(); + assert.deepEqual((await store.get(owner, "goals", goal.id))?.milestones, [ + { ...goal.milestones[0], done: true }, + ]); + } finally { + await app.agent.stop(); + await store.close(); + await rm(root, { recursive: true, force: true }); + } +});