From 0bfbb439c0111cf69dc2de114d20f095ed078f82 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:47:37 -0700 Subject: [PATCH] Keep Build waiting while a reopened decision needs an answer Reopening a decided card shows it as needing an answer again in the Decisions tab and the sidebar, but Build readiness only blocked on decisions still in their first "open" state. A document with a reopened decision showed it waiting while Build plan, Build and Retry went ahead as if everything was settled. implementationReadiness now uses isOpenStatus, so a reopened decision blocks with "unanswered questionnaires" like an open one. The test covers every decision status through an exhaustive table, so a new status fails to compile until someone decides whether it blocks Build. It also checks that the server's stored-record unanswered count classifies the same statuses as waiting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/server/src/tasks/plan-graphs.test.ts | 49 +++++++++++++++++++++++ apps/server/src/tasks/plan-graphs.ts | 3 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/server/src/tasks/plan-graphs.test.ts b/apps/server/src/tasks/plan-graphs.test.ts index ecac8ec44..e9b5edb94 100644 --- a/apps/server/src/tasks/plan-graphs.test.ts +++ b/apps/server/src/tasks/plan-graphs.test.ts @@ -10,9 +10,11 @@ import { liveSnapshot, readRebuild, reportBuild, reportRebuild, restoreLive } fr import { historyFor } from "./lifecycle"; import { MemoryStorage } from "../storage/memory/adapter"; import { claimStored, close, implementationActive, open, source } from "../plan/service"; +import { unansweredDecisions } from "../questions/unanswered"; import type { Server } from "bun"; import type { Backend, Plan } from "../plan/service"; +import type { Record as DecisionRecord } from "../questions/record-types"; import type { JsonValue } from "../storage/model"; import type { SocketData } from "../wire"; @@ -113,6 +115,53 @@ async function verifyRun(plan: Plan, runId: string) { } describe("the plan graph adapter", () => { + let blocksBuild = { + open: true, + reopened: true, + answered: false, + discarded: false, + cancelled: false, + expired: false, + } satisfies Record; + + it.each(Object.entries(blocksBuild) as Array<[DecisionRecord["status"], boolean]>)( + "Build readiness blocks while a decision is open or reopened (%s: %p)", + async (status, blocks) => { + let context = await hosted(); + let plan = await open(context.channel.id, context.backend, context.server); + try { + let record: DecisionRecord = { + id: "decision", + status, + origin: "planner", + definition: { + questions: [{ + id: "choice", + header: "Choice", + question: "Which approach?", + multiple: false, + options: [], + }], + }, + ...(status === "open" ? {} : { answers: { choice: "Previous choice" } }), + history: [], + optionOrigins: {}, + editors: [], + }; + plan.records.set(record.id, record); + expect(graphPlans.implementationReadiness(plan, plan.revision)).toEqual( + blocks + ? { ok: false, blockers: ["unanswered questionnaires"] } + : { ok: true, revision: plan.revision }, + ); + // The stored-record unanswered count classifies the same statuses as waiting. + expect(unansweredDecisions([record]) > 0).toBe(blocks); + } finally { + await close(plan); + } + }, + ); + it("keeps implementation preparation policy with graph persistence", async () => { let context = await hosted(); let plan = await open(context.channel.id, context.backend, context.server); diff --git a/apps/server/src/tasks/plan-graphs.ts b/apps/server/src/tasks/plan-graphs.ts index a9e6bd212..85d14e542 100644 --- a/apps/server/src/tasks/plan-graphs.ts +++ b/apps/server/src/tasks/plan-graphs.ts @@ -3,6 +3,7 @@ import { claim, Graphs } from "./graphs"; import { claimEligibility, historyFor, implementationLifecycle, transition } from "./lifecycle"; import * as Comments from "../comments/service"; +import { isOpenStatus } from "../questions/records"; import { drain, exclusive, persistExclusive, source } from "../plan/service"; import { broadcast } from "../wire"; import { announceImplementation } from "./notifications"; @@ -176,7 +177,7 @@ export function implementationReadiness( revision: unknown, ): { ok: true; revision: number } | { ok: false; blockers: string[] } { let blockers: string[] = []; - if ([...plan.records.values()].some(record => record.status === "open")) { + if ([...plan.records.values()].some(record => isOpenStatus(record.status))) { blockers.push("unanswered questionnaires"); } if (Comments.outstanding(plan).length > 0) {