diff --git a/apps/server/src/orchestration-v2/MilestoneDecider.test.ts b/apps/server/src/orchestration-v2/MilestoneDecider.test.ts index ba196c561be2d..5d255eea41a72 100644 --- a/apps/server/src/orchestration-v2/MilestoneDecider.test.ts +++ b/apps/server/src/orchestration-v2/MilestoneDecider.test.ts @@ -34,6 +34,7 @@ function record( rejections.push(decision.message); continue; } + if (decision._tag !== "accepted") continue; log.push( milestoneTurnItem({ base: { @@ -102,6 +103,54 @@ describe("decideMilestoneAction", () => { expect(statuses(state)).toEqual(["m1:s1:active"]); }); + it("keeps the active milestone when started again by its own title", () => { + const { state, items, rejections } = record([ + [RUN_1, { action: "plan", titles: ["Preparar", "Verificar"] }], + [RUN_1, { action: "start", title: "Preparar" }], + ]); + expect(rejections).toEqual([]); + expect(items).toHaveLength(1); + expect(statuses(state)).toEqual(["m1:s1:active", "m2:s1:planned"]); + expect(decideMilestoneAction(state, { action: "start", title: " preparar " })).toMatchObject({ + _tag: "unchanged", + }); + }); + + it.each([ + ["the same title", "Verificar"], + ["a title differing in case and whitespace", " vERIFICAR "], + ])("starts the planned milestone with %s", (_name, title) => { + const { state, items } = record([ + [RUN_1, { action: "plan", titles: ["Preparar", "Verificar", "Verificar"] }], + [RUN_1, { action: "start", title }], + ]); + expect(items.at(-1)).toMatchObject({ + event: "started", + milestoneId: "m2", + setId: "s1", + title: "Verificar", + closedMilestoneId: "m1", + }); + expect(statuses(state)).toEqual(["m1:s1:done", "m2:s1:active", "m3:s1:planned"]); + }); + + it("starts a new milestone when the title matches only a closed one", () => { + const { state } = record([ + [RUN_1, { action: "plan", titles: ["Preparar", "Verificar"] }], + [RUN_1, { action: "start" }], + [RUN_1, { action: "drop", id: "m2", reason: "skipped" }], + [RUN_1, { action: "start", title: "Preparar" }], + [RUN_1, { action: "start", title: "verificar" }], + ]); + expect(statuses(state)).toEqual(["m1:s1:done", "m2:s1:dropped", "m3:s1:done", "m4:s1:active"]); + expect(state.milestones.map(({ title }) => title)).toEqual([ + "Preparar", + "Verificar", + "Preparar", + "verificar", + ]); + }); + it.each<[string, ReadonlyArray, string]>([ ["start with nothing planned", [{ action: "start" }], "Nothing planned"], [ diff --git a/apps/server/src/orchestration-v2/MilestoneDecider.ts b/apps/server/src/orchestration-v2/MilestoneDecider.ts index 22d65b447f309..a47ed4c7befe1 100644 --- a/apps/server/src/orchestration-v2/MilestoneDecider.ts +++ b/apps/server/src/orchestration-v2/MilestoneDecider.ts @@ -212,7 +212,8 @@ function currentMilestoneSetId( export type MilestoneDecision = | { readonly _tag: "accepted"; readonly detail: MilestoneItemDetail } - | { readonly _tag: "rejected"; readonly message: string }; + | { readonly _tag: "rejected"; readonly message: string } + | { readonly _tag: "unchanged" }; const rejected = (message: string): MilestoneDecision => ({ _tag: "rejected", message }); const accepted = (detail: Omit): MilestoneDecision => ({ @@ -220,6 +221,8 @@ const accepted = (detail: Omit): MilestoneDecision detail: { type: "milestone", ...detail }, }); +const titleKey = (title: string) => title.trim().toLowerCase(); + /** Validates one action against the current state. Pure: the caller builds and applies the item. */ export function decideMilestoneAction( state: MilestoneState, @@ -251,6 +254,22 @@ export function decideMilestoneAction( ...(action.note === undefined ? {} : { note: action.note }), }; if (action.title !== undefined) { + const key = titleKey(action.title); + const current = milestones.filter( + (milestone) => + milestone.setId === setId && isOpen(milestone) && titleKey(milestone.title) === key, + ); + const planned = current.find((milestone) => milestone.status === "planned"); + if (planned !== undefined) { + return accepted({ + event: "started", + milestoneId: planned.id, + setId: planned.setId, + title: planned.title, + ...closing, + }); + } + if (current.length > 0) return { _tag: "unchanged" }; return accepted({ event: "started", milestoneId: nextId(), diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 04ac348792693..ba5c8c66615fb 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -9615,6 +9615,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio detail: decision.message, }); } + if (decision._tag === "unchanged") return; const now = yield* DateTime.now; const turnItem = milestoneTurnItem({ base: { diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 88dcf356598d9..199bee5927480 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -480,7 +480,9 @@ running turn (`parent_not_active` otherwise) and is hidden from delegated children. Actions: - `plan` opens a new set and starts its first milestone; -- `start` begins the next planned milestone, one by `id`, or a new one by `title`; +- `start` begins the next planned milestone, one by `id`, or one by `title`: a + planned one of that title in the current set, nothing if it is the active one, + otherwise a new one; - `done`, `change`, and `drop` act on one milestone; and - `post` attaches a note and images (absolute paths on the server) to the active one.