fix(daemon): persist blocked strategy-task attribution

Every blocked strategy task now stores its gate reason codes and the
agent-visible turn text (blocked_reason_codes_json / blocked_visible_text)
so operators can diagnose a blocked task from the store alone. Covers the
coordinator blockTask gate, the automatic-continuation fail-closed path,
and startup run-terminal reconciliation.
This commit is contained in:
lefarcen
2026-08-19 15:14:16 +08:00
parent ea4cbc3d97
commit 8bc7805e62
5 changed files with 177 additions and 2 deletions
@@ -552,6 +552,12 @@ export function blockAutomaticContinuation(db: SqliteDb, input: {
const current = getStrategyTaskExecutionByRunId(db, input.runId);
if (!current) return null;
if (current.latestRunId !== input.runId || current.outcome !== 'running') return current;
console.warn('[od-next-task] blocked', {
taskExecutionId: current.taskExecutionId,
runId: input.runId,
inputStage: current.inputStage,
reasonCodes: ['od_next_native_session_continuity_unproven'],
});
return compareAndTransitionStrategyTaskExecution(db, {
taskExecutionId: current.taskExecutionId,
expectedRevision: current.revision,
@@ -561,6 +567,10 @@ export function blockAutomaticContinuation(db: SqliteDb, input: {
outcome: 'blocked',
executionMode: current.executionMode,
},
blockedContext: {
reasonCodes: ['od_next_native_session_continuity_unproven'],
visibleText: null,
},
...(input.updatedAt === undefined ? {} : { updatedAt: input.updatedAt }),
});
}
@@ -580,6 +580,13 @@ function blockTask(
['od_next_route_not_locked'],
);
}
const blockedReasonCodes = uniqueReasonCodes(reasonCodes);
console.warn('[od-next-task] blocked', {
taskExecutionId: current.taskExecutionId,
runId: current.latestRunId,
inputStage: current.inputStage,
reasonCodes: blockedReasonCodes,
});
const task = compareAndTransitionStrategyTaskExecution(db, {
taskExecutionId: current.taskExecutionId,
expectedRevision: current.revision,
@@ -589,12 +596,16 @@ function blockTask(
outcome: 'blocked',
executionMode: current.executionMode,
},
blockedContext: {
reasonCodes: blockedReasonCodes,
visibleText: visibleText.length > 0 ? visibleText : null,
},
...(updatedAt === undefined ? {} : { updatedAt }),
});
return {
action: 'blocked',
task,
visibleText,
reasonCodes: uniqueReasonCodes(reasonCodes),
reasonCodes: blockedReasonCodes,
};
}
+93 -1
View File
@@ -79,6 +79,7 @@ export interface StrategyTaskExecutionRecord {
inputStage: StrategyInputStageV2;
outcome: StrategyTaskOutcome;
executionMode: StrategyExecutionModeV2 | null;
blockedContext?: StrategyTaskBlockedContext;
planContract?: OpenDesignPlanContractV2;
planContractHash?: string;
clarificationCount: 0 | 1;
@@ -108,6 +109,16 @@ export interface CreateStrategyTaskExecutionInput {
createdAt?: number;
}
/**
* Durable attribution for a blocked strategy task: the exact gate reason codes
* plus the agent-visible text of the turn that was rejected. Every blocked
* outcome must be diagnosable from the store alone, without live logs.
*/
export interface StrategyTaskBlockedContext {
reasonCodes: string[];
visibleText: string | null;
}
export interface StrategyTaskTransitionState {
route: StrategyRouteV2;
inputStage: StrategyInputStageV2;
@@ -125,6 +136,10 @@ export interface CompareAndTransitionStrategyTaskInput {
finalText: string;
};
planContract?: OpenDesignPlanContractV2;
blockedContext?: {
reasonCodes: readonly string[];
visibleText?: string | null;
};
updatedAt?: number;
}
@@ -186,6 +201,8 @@ export function migrateStrategyTaskStore(db: SqliteDb): void {
prompt_bundle_utf8_bytes INTEGER,
prompt_bundle_sha256 TEXT,
frozen_input_identity_json TEXT,
blocked_reason_codes_json TEXT,
blocked_visible_text TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
@@ -220,6 +237,8 @@ export function migrateStrategyTaskStore(db: SqliteDb): void {
addColumnIfMissing(db, 'strategy_task_executions', 'prompt_bundle_utf8_bytes INTEGER');
addColumnIfMissing(db, 'strategy_task_executions', 'prompt_bundle_sha256 TEXT');
addColumnIfMissing(db, 'strategy_task_executions', 'frozen_input_identity_json TEXT');
addColumnIfMissing(db, 'strategy_task_executions', 'blocked_reason_codes_json TEXT');
addColumnIfMissing(db, 'strategy_task_executions', 'blocked_visible_text TEXT');
addColumnIfMissing(db, 'strategy_task_runs', 'final_text_kind TEXT');
addColumnIfMissing(db, 'strategy_task_runs', 'final_text_schema TEXT');
addColumnIfMissing(db, 'strategy_task_runs', 'final_text TEXT');
@@ -459,11 +478,21 @@ export function compareAndTransitionStrategyTaskExecution(
);
}
if (input.blockedContext && next.outcome !== 'blocked') {
throw new InvalidStrategyTaskTransitionError(
'Blocked attribution is only valid when transitioning to blocked.',
);
}
const blockedContext = next.outcome === 'blocked'
? normalizeBlockedContext(input.blockedContext)
: null;
const result = db.prepare(`
UPDATE strategy_task_executions
SET revision = revision + 1,
route = ?, input_stage = ?, outcome = ?, execution_mode = ?,
plan_contract_json = ?, plan_contract_hash = ?,
blocked_reason_codes_json = ?, blocked_visible_text = ?,
clarification_count = ?, plan_contract_repair_attempts = ?,
latest_run_id = ?, updated_at = ?
WHERE task_execution_id = ? AND revision = ?
@@ -474,6 +503,8 @@ export function compareAndTransitionStrategyTaskExecution(
next.executionMode,
plan.json,
plan.hash,
blockedContext ? JSON.stringify(blockedContext.reasonCodes) : null,
blockedContext ? blockedContext.visibleText : null,
clarificationCount,
repairAttempts,
nextRunId,
@@ -587,7 +618,8 @@ export function reconcileStrategyTaskRunTerminal(
}
const result = db.prepare(`
UPDATE strategy_task_executions
SET revision = revision + 1, outcome = ?, updated_at = ?
SET revision = revision + 1, outcome = ?, updated_at = ?,
blocked_reason_codes_json = ?, blocked_visible_text = NULL
WHERE task_execution_id = ? AND revision = ?
AND latest_run_id = ? AND outcome = 'running'
`).run(
@@ -596,6 +628,9 @@ export function reconcileStrategyTaskRunTerminal(
current.updatedAt,
normalizeTimestamp(input.updatedAt ?? Date.now(), 'updatedAt'),
),
input.status === 'canceled'
? null
: JSON.stringify(['od_next_physical_run_interrupted']),
current.taskExecutionId,
current.revision,
input.runId,
@@ -664,6 +699,11 @@ function rowToTask(db: SqliteDb, row: DbRow): StrategyTaskExecutionRecord {
const outcome = parseOutcome(row['outcome']);
const executionMode = parseNullableExecutionMode(row['execution_mode']);
validateStoredState({ route, inputStage, outcome, executionMode });
const blockedContext = parseStoredBlockedContext(
row['blocked_reason_codes_json'],
row['blocked_visible_text'],
outcome,
);
const plan = parseStoredPlanContract(row['plan_contract_json'], row['plan_contract_hash']);
if (
(inputStage === 'production' || outcome === 'plan_ready')
@@ -819,6 +859,7 @@ function rowToTask(db: SqliteDb, row: DbRow): StrategyTaskExecutionRecord {
inputStage,
outcome,
executionMode,
...(blockedContext ? { blockedContext } : {}),
...(plan.contract ? { planContract: plan.contract } : {}),
...(plan.hash ? { planContractHash: plan.hash } : {}),
clarificationCount,
@@ -836,6 +877,57 @@ function rowToTask(db: SqliteDb, row: DbRow): StrategyTaskExecutionRecord {
};
}
function normalizeBlockedContext(
input: CompareAndTransitionStrategyTaskInput['blockedContext'],
): StrategyTaskBlockedContext | null {
if (!input) return null;
const reasonCodes = [...new Set(
input.reasonCodes.filter((code) => typeof code === 'string' && code.length > 0),
)];
if (reasonCodes.length === 0) return null;
const visibleText = typeof input.visibleText === 'string' && input.visibleText.trim().length > 0
? input.visibleText
: null;
return { reasonCodes, visibleText };
}
function parseStoredBlockedContext(
reasonCodesJson: unknown,
visibleText: unknown,
outcome: StrategyTaskOutcome,
): StrategyTaskBlockedContext | null {
if (reasonCodesJson == null) return null;
if (outcome !== 'blocked') {
throw new InvalidStrategyTaskRecordError(
'Blocked attribution is only valid on blocked strategy tasks.',
);
}
const raw = requireStoredString(reasonCodesJson, 'blocked_reason_codes_json');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new InvalidStrategyTaskRecordError(
'Persisted blocked reason codes must be valid JSON.',
);
}
if (
!Array.isArray(parsed)
|| parsed.length === 0
|| !parsed.every((code) => typeof code === 'string' && code.length > 0)
) {
throw new InvalidStrategyTaskRecordError(
'Persisted blocked reason codes must be a non-empty string array.',
);
}
return {
reasonCodes: parsed,
visibleText: visibleText == null
? null
: requireStoredString(visibleText, 'blocked_visible_text'),
};
}
function addColumnIfMissing(db: SqliteDb, table: string, definition: string): void {
const column = definition.split(/\s+/u)[0];
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as DbRow[];
@@ -1093,5 +1093,32 @@ describe('OD Next planning coordinator', () => {
runId: 'run-clarification', updatedAt: 140,
});
expect(blocked).toMatchObject({ outcome: 'blocked', terminalRunId: 'run-clarification' });
expect(blocked?.blockedContext).toEqual({
reasonCodes: ['od_next_native_session_continuity_unproven'],
visibleText: null,
});
});
it('persists blocked attribution so a blocked task can be diagnosed from the store', () => {
prepareStrategyRequest(db, {
taskExecutionId: 'task-1', preference: 'full_plan', directEdit: directEligible,
intake: intakePassed, updatedAt: 110,
});
// Mirrors the observed field failure: a visible-only reply without any
// machine-protocol block must block AND leave queryable attribution.
const visible = '这轮回复没有携带机器协议块,只有普通文本。';
const result = finalizeStrategyPlanningTurn(db, {
taskExecutionId: 'task-1', runId: 'run-request',
protocol: protocol(visible),
updatedAt: 120,
});
expect(result.action).toBe('blocked');
expect(result.reasonCodes.length).toBeGreaterThan(0);
const persisted = getStrategyTaskExecution(db, 'task-1');
expect(persisted?.outcome).toBe('blocked');
expect(persisted?.blockedContext).toEqual({
reasonCodes: result.reasonCodes,
visibleText: visible,
});
});
});
@@ -1100,4 +1100,39 @@ describe('durable strategy task store', () => {
expect(fs.readdirSync(path.join(tempDir, 'runs'))).toEqual([`run-${physicalStatus}`]);
},
);
it('persists blocked attribution when startup reconciliation interrupts a running Run', async () => {
const task = createTask(db, snapshot, 'run-interrupted');
closeDatabase();
const runDir = path.join(tempDir, 'runs', 'run-interrupted');
fs.mkdirSync(runDir, { recursive: true });
fs.writeFileSync(path.join(runDir, 'state.json'), JSON.stringify({
schemaVersion: 1,
id: 'run-interrupted',
projectId: 'project-1',
conversationId: 'conversation-1',
agentId: AGENT_ID,
status: 'running',
createdAt: 100,
updatedAt: 200,
langfuseCompletedAt: 200,
}));
db = openDatabase(tempDir, { dataDir: tempDir });
await reconcileDurableRunTerminals({
analytics: { capture: vi.fn() },
appVersion: '0.18.2',
db,
reportLangfuse: vi.fn(),
runsLogDir: path.join(tempDir, 'runs'),
});
const persisted = getStrategyTaskExecution(db, task.taskExecutionId);
expect(persisted?.outcome).toBe('blocked');
expect(persisted?.blockedContext).toEqual({
reasonCodes: ['od_next_physical_run_interrupted'],
visibleText: null,
});
});
});