Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/server/src/orchestration-v2/MilestoneDecider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function record(
rejections.push(decision.message);
continue;
}
if (decision._tag !== "accepted") continue;
log.push(
milestoneTurnItem({
base: {
Expand Down Expand Up @@ -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<OrchestrationV2MilestoneAction>, string]>([
["start with nothing planned", [{ action: "start" }], "Nothing planned"],
[
Expand Down
21 changes: 20 additions & 1 deletion apps/server/src/orchestration-v2/MilestoneDecider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,17 @@ 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<MilestoneItemDetail, "type">): MilestoneDecision => ({
_tag: "accepted",
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,
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 3 additions & 1 deletion docs/orchestration-v2/orchestrator-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading