From e2944dabac435c6c648ba01ce608c9861ba5a512 Mon Sep 17 00:00:00 2001
From: "open-design-crew[bot]"
<299007234+open-design-crew[bot]@users.noreply.github.com>
Date: Mon, 14 Sep 2026 03:22:04 +0000
Subject: [PATCH] fix(daemon): accept a clarification turn whose Runtime State
names the wrong stage (#7980)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After the user answers the one clarification round, the agent's next turn
carries a complete Full Plan plus a Runtime State. On 0.22.1-prerelease.9
(OPEND-2954) that state said `inputStage: "request"` — the value every
Runtime State example in the protocol reference shows, and the one field the
clarification continuation never named — and the coordinator refused the turn
on that field alone. The task settled terminal-`blocked`, a plan that passed
every other gate never reached production, and no repair path could anchor on
it because the block was schema-valid and the mismatch is not a parser issue.
The stage is host-owned truth: the daemon issued it, wrote it into the
continuation wrapper, and holds it on the task, so a disagreeing declaration
carries no authority — the same footing as the execution mode a clarification
turn predicts, which the parser already discards. `adoptHostInputStage` now
gives the state the host's stage before validation whenever the corrected
state is a valid declaration for that stage, and records the decision as a
normalization rather than a reason code so an agent-declared `blocked` keeps
its own attribution. Where the field does carry meaning — a second
`clarification_required`, a `plan_ready` at production — the corrected state
fails the contract, the agent's value stands, and the turn is refused exactly
as before. The serialization-repair anchor gets the same treatment so a fenced
plan on a clarification turn can still enter its one repair.
The clarification continuation now also says which stage it runs at, mirroring
the production continuation's `inputStage production`, so the agent has one
sentence to follow instead of three examples that all say `request`.
Specs: a clarification turn declaring `request` / `plan_ready` with a valid
plan reaches `plan_ready` (red on main: blocked with
`od_next_protocol_stage_mismatch`); an agent-declared block with the wrong
stage keeps `od_next_agent_declared_block`; a second question round is still
refused; a fenced plan with the wrong stage still anchors a repair; the
continuation text names `inputStage clarification`.
Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
---
.../src/strategies/od-next/coordinator.ts | 63 +++++++-
.../strategies/od-next/coordinator.test.ts | 139 ++++++++++++++++++
.../contracts/src/prompts/od-next-strategy.ts | 2 +-
.../tests/od-next-prompt-recipe.test.ts | 7 +
4 files changed, 209 insertions(+), 2 deletions(-)
diff --git a/apps/daemon/src/strategies/od-next/coordinator.ts b/apps/daemon/src/strategies/od-next/coordinator.ts
index 560f55fbf3..db5ac6b429 100644
--- a/apps/daemon/src/strategies/od-next/coordinator.ts
+++ b/apps/daemon/src/strategies/od-next/coordinator.ts
@@ -6,6 +6,7 @@ import {
AppliedStrategyBindingV2Schema,
OD_NEXT_AGENT_DECLARED_BLOCK_REASON,
OD_NEXT_RUNTIME_STATE_SCHEMA,
+ StrategyRuntimeStateV2Schema,
composeOdNextStrategyContinuationV2,
} from '@open-design/contracts';
import type Database from 'better-sqlite3';
@@ -468,6 +469,17 @@ export function finalizeStrategyPlanningResult(db: SqliteDb, input: {
input.updatedAt,
);
}
+ const adopted = adoptHostInputStage(current, state);
+ if (adopted.normalized) {
+ console.info('[od-next-task] protocol normalized', {
+ taskExecutionId: current.taskExecutionId,
+ runId: input.runId,
+ normalizations: ['od_next_protocol_input_stage_normalized'],
+ declaredInputStage: state.inputStage,
+ inputStage: current.inputStage,
+ });
+ }
+ state = adopted.state;
const reasonCodes = validateAcceptedTurn(db, current, state, parsed.planContract, parsed.visibleText, {
toolUseCount: input.toolUseCount ?? 0,
...(input.executionPreflight ? { executionPreflight: input.executionPreflight } : {}),
@@ -844,6 +856,47 @@ function questionFormMarkerReasonCodes(
return codes;
}
+/**
+ * Give a Runtime State the input stage the daemon itself issued the turn at,
+ * when the agent's declaration disagrees with it and the corrected state is a
+ * valid declaration for that stage.
+ *
+ * The stage is host-owned truth: the daemon chose it, wrote it into the
+ * continuation wrapper (`stage="clarification"`), and holds it on the task. An
+ * agent that writes a different value has told the host nothing it did not
+ * already know — the same footing as the execution mode a clarification turn
+ * predicts, which the parser already discards as authority-free
+ * (`OdNextMachineProtocolStream.normalizeMachineValue`).
+ *
+ * The case that made this necessary (OPEND-2954): the user answered the one
+ * clarification round, the agent returned a complete, correctly bound Full Plan,
+ * and its Runtime State said `inputStage: "request"` — the value every example
+ * in the protocol reference shows. `validateAcceptedTurn` refused the turn on
+ * that field alone, `blockTask` made the task terminal, and a plan that passed
+ * every other gate never reached production. Nothing could rescue it: the
+ * serialization repair only anchors on parser issues, and this was not one.
+ *
+ * Fail-closed where the field DOES carry meaning. The contract keys its
+ * stage/outcome/mode rules on `inputStage`, so the corrected state is re-run
+ * through the schema; a state the host's stage cannot admit (a second
+ * `clarification_required`, a `plan_ready` at production) keeps the agent's own
+ * value, and the mismatch is reported as before. The decision is recorded as a
+ * normalization, never as a reason code: a reason code would displace the
+ * agent's own attribution on a declared `blocked`.
+ */
+function adoptHostInputStage(
+ task: StrategyTaskExecutionRecord,
+ state: StrategyRuntimeStateV2,
+): { state: StrategyRuntimeStateV2; normalized: boolean } {
+ if (state.inputStage === task.inputStage) return { state, normalized: false };
+ const corrected = StrategyRuntimeStateV2Schema.safeParse({
+ ...state,
+ inputStage: task.inputStage,
+ });
+ if (!corrected.success) return { state, normalized: false };
+ return { state: corrected.data, normalized: true };
+}
+
function validateAcceptedTurn(
db: SqliteDb,
task: StrategyTaskExecutionRecord,
@@ -866,6 +919,8 @@ function validateAcceptedTurn(
if (task.route !== null && state.route !== task.route) {
reasonCodes.push('od_next_protocol_route_mismatch');
}
+ // Reached only when `adoptHostInputStage` declined: the agent's stage
+ // disagrees with the host's AND the host's stage cannot admit this outcome.
if (state.inputStage !== task.inputStage) reasonCodes.push('od_next_protocol_stage_mismatch');
if (task.executionMode && state.executionMode !== task.executionMode) {
reasonCodes.push('od_next_protocol_execution_mode_mismatch');
@@ -1007,7 +1062,13 @@ function tryBeginSerializationRepair(
) return null;
const recoveredState = parsed.runtimeState ?? parsed.repairRuntimeState;
if (recoveredState) {
- const stateCodes = validateRepairAnchorState(current, recoveredState, plan);
+ // The anchor is the same declaration the strict path validates, so it gets
+ // the same host-owned stage before its stage is compared.
+ const stateCodes = validateRepairAnchorState(
+ current,
+ adoptHostInputStage(current, recoveredState).state,
+ plan,
+ );
if (stateCodes.length > 0) return null;
}
const repairRun = input.repairRun;
diff --git a/apps/daemon/tests/strategies/od-next/coordinator.test.ts b/apps/daemon/tests/strategies/od-next/coordinator.test.ts
index dbd7d0e312..cf585a64f1 100644
--- a/apps/daemon/tests/strategies/od-next/coordinator.test.ts
+++ b/apps/daemon/tests/strategies/od-next/coordinator.test.ts
@@ -381,6 +381,145 @@ describe('OD Next planning coordinator', () => {
});
});
+ // OPEND-2954 (0.22.1-prerelease.9, deepseek-v4-flash): the user answered the
+ // one clarification round, and the agent came back with a complete, correctly
+ // bound Full Plan whose Runtime State still said `inputStage: "request"` — the
+ // value every example in the protocol reference shows, and the only stage the
+ // clarification continuation never names. The coordinator refused the turn on
+ // that field alone, the task went terminal-`blocked`, and a plan that would
+ // have passed every other gate never reached production. The stage is
+ // host-owned truth (the daemon wrote it into the turn wrapper), so a
+ // declaration that disagrees with it carries no authority: adopt the host's
+ // stage when the corrected state is a valid declaration for it.
+ function answerTheOneClarificationRound(): void {
+ prepareStrategyRequest(db, {
+ taskExecutionId: 'task-1',
+ preference: 'full_plan',
+ directEdit: directEligible,
+ intake: intakePassed,
+ updatedAt: 110,
+ });
+ const question = '{"questions":[{"id":"surface","label":"Surface?"}]}';
+ finalizeStrategyPlanningTurn(db, {
+ taskExecutionId: 'task-1',
+ runId: 'run-request',
+ protocol: protocol(`${question}\n${block('open-design-runtime-state', runtimeState({
+ outcome: 'clarification_required',
+ }))}`),
+ updatedAt: 120,
+ });
+ beginStrategyClarification(db, {
+ taskExecutionId: 'task-1',
+ sourceRunId: 'run-request',
+ nextRunId: 'run-clarification',
+ answer: 'Both audiences; one 40-minute lesson.',
+ updatedAt: 130,
+ });
+ }
+
+ it('accepts a clarification turn whose plan-ready state still names the request stage', () => {
+ answerTheOneClarificationRound();
+ const plan = planContract(snapshot);
+ const final = finalizeStrategyPlanningTurn(db, {
+ taskExecutionId: 'task-1',
+ runId: 'run-clarification',
+ protocol: protocol([
+ 'Direction settled; building next.',
+ block('open-design-plan-contract', plan),
+ block('open-design-runtime-state', runtimeState({
+ inputStage: 'request', outcome: 'plan_ready', executionMode: 'simple',
+ })),
+ ].join('\n')),
+ executionPreflight: executionPassed,
+ updatedAt: 140,
+ });
+ expect(final).toMatchObject({
+ action: 'plan_ready',
+ reasonCodes: [],
+ decisionSummary: plan.decisionSummary,
+ task: {
+ inputStage: 'clarification',
+ outcome: 'plan_ready',
+ executionMode: 'simple',
+ clarificationCount: 1,
+ planContract: plan,
+ },
+ });
+ });
+
+ it('keeps the agent-declared attribution of a clarification block that names the request stage', () => {
+ answerTheOneClarificationRound();
+ const final = finalizeStrategyPlanningTurn(db, {
+ taskExecutionId: 'task-1',
+ runId: 'run-clarification',
+ protocol: protocol(`The answer contradicts the brief; nothing to plan.\n${block('open-design-runtime-state', runtimeState({
+ inputStage: 'request', outcome: 'blocked',
+ }))}`),
+ updatedAt: 140,
+ });
+ // Same shape as any agent-declared block: the stage the agent wrote is not
+ // promoted into a reason code, so the attribution stays the agent's own.
+ expect(final).toMatchObject({
+ action: 'blocked',
+ reasonCodes: [],
+ task: {
+ inputStage: 'clarification',
+ outcome: 'blocked',
+ blockedContext: {
+ reasonCodes: ['od_next_agent_declared_block'],
+ visibleText: 'The answer contradicts the brief; nothing to plan.\n',
+ },
+ },
+ });
+ });
+
+ it('still refuses a clarification turn whose outcome the host stage cannot admit', () => {
+ answerTheOneClarificationRound();
+ const question = '{"questions":[{"id":"tone","label":"Tone?"}]}';
+ const final = finalizeStrategyPlanningTurn(db, {
+ taskExecutionId: 'task-1',
+ runId: 'run-clarification',
+ protocol: protocol(`${question}\n${block('open-design-runtime-state', runtimeState({
+ inputStage: 'request', outcome: 'clarification_required',
+ }))}`),
+ updatedAt: 140,
+ });
+ expect(final.action).toBe('blocked');
+ expect(final.reasonCodes).toEqual(expect.arrayContaining([
+ 'od_next_protocol_stage_mismatch',
+ 'od_next_clarification_repeated',
+ ]));
+ expect(final.task).toMatchObject({ inputStage: 'clarification', outcome: 'blocked' });
+ });
+
+ it('anchors a clarification-turn repair on a plan whose state names the request stage', () => {
+ answerTheOneClarificationRound();
+ const plan = planContract(snapshot);
+ const repair = finalizeStrategyPlanningTurn(db, {
+ taskExecutionId: 'task-1',
+ runId: 'run-clarification',
+ protocol: protocol([
+ block('open-design-plan-contract', plan, true),
+ block('open-design-runtime-state', runtimeState({
+ inputStage: 'request', outcome: 'plan_ready', executionMode: 'simple',
+ })),
+ ].join('\n')),
+ repairRun: { runId: 'run-repair', sourceRunId: 'run-clarification' },
+ executionPreflight: executionPassed,
+ updatedAt: 140,
+ });
+ expect(repair).toMatchObject({
+ action: 'contract_repair',
+ task: {
+ inputStage: 'contract_repair',
+ outcome: 'running',
+ executionMode: 'simple',
+ planContractRepairAttempts: 1,
+ planContract: plan,
+ },
+ });
+ });
+
it('persists a valid Full Plan and returns only its decision summary as structured output', () => {
prepareStrategyRequest(db, {
taskExecutionId: 'task-1',
diff --git a/packages/contracts/src/prompts/od-next-strategy.ts b/packages/contracts/src/prompts/od-next-strategy.ts
index 80e5b30d2d..af77b6162d 100644
--- a/packages/contracts/src/prompts/od-next-strategy.ts
+++ b/packages/contracts/src/prompts/od-next-strategy.ts
@@ -1003,7 +1003,7 @@ export function composeOdNextStrategyContinuationV2(
}
let payload: string;
if (input.stage === 'clarification') {
- payload = `# OD Next native continuation — clarification\n\nMerge the user's answer below into the existing Full Plan context. Preserve the locked route, ask no second question round, rerun only affected resolution and Preflight work, and emit the updated V2 machine structures.\n\n## Clarification answer\n\n${requireText(input.answer, 'answer')}`;
+ payload = `# OD Next native continuation — clarification\n\nMerge the user's answer below into the existing Full Plan context. Preserve the locked route, ask no second question round, rerun only affected resolution and Preflight work, and emit the updated V2 machine structures. This turn runs at the clarification stage: the Runtime State reports inputStage clarification (not request) with outcome plan_ready once the Full Plan is frozen, otherwise blocked or canceled.\n\n## Clarification answer\n\n${requireText(input.answer, 'answer')}`;
} else if (input.stage === 'contract_repair') {
payload = `# OD Next native continuation — contract_repair\n\nThe semantic plan in this native session is frozen. Make one serialization-only attempt that addresses the issue below. Use no tools, do not re-plan, and preserve the locked route, execution mode, Design Spec, steps, and Build Packages.\n\n## Serialization issue\n\n${requireText(input.serializationIssue, 'serializationIssue')}`;
} else {
diff --git a/packages/contracts/tests/od-next-prompt-recipe.test.ts b/packages/contracts/tests/od-next-prompt-recipe.test.ts
index 63edecb641..8246b4932d 100644
--- a/packages/contracts/tests/od-next-prompt-recipe.test.ts
+++ b/packages/contracts/tests/od-next-prompt-recipe.test.ts
@@ -676,6 +676,13 @@ describe('OD Next V2 prompt recipe', () => {
});
expect(clarification).toContain('Clarification answer');
+ // OPEND-2954: every Runtime State example in the protocol reference shows
+ // `inputStage: "request"`, and this was the one continuation that never
+ // named its own stage — so a clarification turn copied the example and was
+ // refused for it. The continuation now says which stage it runs at.
+ expect(clarification).toContain('stage="clarification" task_run_index="1"');
+ expect(clarification).toContain('inputStage clarification');
+ expect(clarification).toContain('outcome plan_ready');
expect(contractRepair).toContain('serialization-only');
expect(production).toContain(`planContractHash=${A}`);
expect(production).toMatch(/^