v1.12.1: stop-handler writes terminal status + constraint cleanup + dead code removal

- handleAbortOrError now writes status='cancelled' on user stop; rows
  no longer stuck 'streaming' forever
- Drop stale messages_status_check constraint (only messages_status_chk
  remains, allowing 'cancelled' via TS MESSAGE_STATUSES)
- Remove detectSameNameLoop and DOOM_LOOP_SAME_NAME_THRESHOLD (added
  during 2026-05-21 debugging spike, never fired in any real run,
  existing detectDoomLoop covers actual failure modes)
- Remove 12 ctx.log.info diagnostic markers added during the same
  spike (verbose for production)
- Bundles workspace pane sync + status indicator overhaul +
  startup hung-row sweep landed earlier in v1.12.1 work

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:34:40 +00:00
parent 48ee63a286
commit 1a0a3b1673
2 changed files with 27 additions and 41 deletions

View File

@@ -120,6 +120,19 @@ BEGIN
END IF;
END $$;
-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
-- named *_chk variants above. messages_status_check missed 'cancelled' and
-- messages_role_check missed 'system' — both narrower than what's in use.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
ALTER TABLE messages DROP CONSTRAINT messages_status_check;
END IF;
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
ALTER TABLE messages DROP CONSTRAINT messages_role_check;
END IF;
END $$;
-- v1.2-project-ux: projects.status + projects.gitea_remote
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';

View File

@@ -62,7 +62,6 @@ const CAP_HIT_SUMMARY_NOTE = (limit: number) =>
// session/processor.ts). Threshold of 3 is the smallest value that doesn't
// false-positive on a model that retries once after a transient error.
export const DOOM_LOOP_THRESHOLD = 3;
export const DOOM_LOOP_SAME_NAME_THRESHOLD = 5;
const DOOM_LOOP_NOTE = (name: string) =>
`You called ${name} with the same arguments ${DOOM_LOOP_THRESHOLD} times in a row. Stop calling it. Produce the best answer you can with what you have.`;
@@ -86,18 +85,6 @@ export function detectDoomLoop(
return { name: ref.name, args: ref.args };
}
export function detectSameNameLoop(
recentToolCalls: ToolCall[],
): { name: string } | null {
if (recentToolCalls.length < DOOM_LOOP_SAME_NAME_THRESHOLD) return null;
const last = recentToolCalls.slice(-DOOM_LOOP_SAME_NAME_THRESHOLD);
const name = last[0]!.name;
for (let i = 1; i < last.length; i++) {
if (last[i]!.name !== name) return null;
}
return { name };
}
function isCapHitSentinel(m: Message): boolean {
return (
m.role === 'system' &&
@@ -814,6 +801,17 @@ async function handleAbortOrError(
// genuine errors flip the dot red. v1.8.2: error path also carries a
// machine-readable `reason` so the UI can render specifics inline.
if (isAbort) {
// v1.12.1: defensive cancellation write. The status=${finalStatus} UPDATE
// above already sets 'cancelled' for the AbortError case, but a row can
// leak as 'streaming' when the abort fires between the post-tool-phase
// INSERT (executeToolPhase) and the next runAssistantTurn's stream setup,
// bypassing the try/catch around executeStreamPhase. The status guard
// makes this a no-op when the earlier write already landed.
await ctx.sql`
UPDATE messages
SET status = 'cancelled', content = ${accumulated}, finished_at = clock_timestamp()
WHERE id = ${args.assistantMessageId} AND status = 'streaming'
`;
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
ctx.publish(sessionId, {
type: 'message_complete',
@@ -907,6 +905,7 @@ async function executeToolPhase(
// pre-stamped with output=null as a "pending" sentinel and no tool_result
// frame goes out — the card renders from the tool_call frame alone. Mixed
// batches still execute the other tools normally.
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'tool_running', at: new Date().toISOString() });
let pausingForUserInput = false;
await Promise.all(
toolCalls.map(async (tc) => {
@@ -951,13 +950,10 @@ async function executeToolPhase(
);
if (pausingForUserInput) {
// Drop the dot back to idle — the card is the actionable surface now.
// The next inference turn fires from POST /api/chats/:id/answer_user_input
// once the user submits their answers.
ctx.publishUser({
type: 'chat_status',
chat_id: chatId,
status: 'idle',
status: 'waiting_for_input',
at: new Date().toISOString(),
});
ctx.log.info(
@@ -972,7 +968,6 @@ async function executeToolPhase(
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
ctx.log.info({ chatId, nextToolsUsed: toolsUsed + result.toolCalls.length, phase: 'executeToolPhase:before_recurse' }, 'recursing into next turn');
await runAssistantTurn(ctx, {
sessionId,
chatId,
@@ -1057,7 +1052,6 @@ async function runAssistantTurn(
args: TurnArgs,
): Promise<void> {
const { sessionId, chatId } = args;
ctx.log.info({ chatId, sessionId, toolsUsed: args.toolsUsed, recentToolCallsLen: args.recentToolCalls?.length ?? 0, phase: 'runAssistantTurn:enter' }, 'turn enter');
// v1.11: if the prior turn flagged this chat for compaction, run it first
// so loadContext below reads the post-compaction history. We swallow
@@ -1088,7 +1082,6 @@ async function runAssistantTurn(
return;
}
const { session, project, history } = loaded;
ctx.log.info({ chatId, historyLen: history.length, phase: 'runAssistantTurn:loaded' }, 'context loaded');
const projectRoot = await resolveProjectRoot(project.path);
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
// effect on the next message. Unknown agent_id returns null silently —
@@ -1106,7 +1099,6 @@ async function runAssistantTurn(
await runCapHitSummary(ctx, args, session, project, history, agent, budget);
return;
}
ctx.log.info({ chatId, budget, toolsUsed: args.toolsUsed, phase: 'runAssistantTurn:budget_ok' }, 'budget ok');
// v1.11.6: doom-loop guard. Detected BEFORE the budget cap (the model can
// burn through 3 identical calls long before the 15-call budget fires).
@@ -1118,17 +1110,6 @@ async function runAssistantTurn(
await runDoomLoopSummary(ctx, args, session, project, history, agent, loop);
return;
}
ctx.log.info({ chatId, phase: 'runAssistantTurn:no_doom_loop' }, 'no doom loop');
const sameNameLoop = detectSameNameLoop(args.recentToolCalls);
if (sameNameLoop) {
await runDoomLoopSummary(ctx, args, session, project, history, agent, {
name: sameNameLoop.name,
args: {},
});
return;
}
ctx.log.info({ chatId, phase: 'runAssistantTurn:no_same_name_loop' }, 'no same-name loop');
const messages = await buildMessagesPayload(session, project, history, agent);
@@ -1142,24 +1123,17 @@ async function runAssistantTurn(
const webToolsEnabled =
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
ctx.log.info({ chatId, msgCount: messages.length, phase: 'runAssistantTurn:payload_built' }, 'payload built');
const state: StreamPhaseState = { accumulated: '', startedAt: null };
let result: StreamResult;
try {
ctx.log.info({ chatId, model: session.model, phase: 'runAssistantTurn:before_stream' }, 'calling upstream');
result = await executeStreamPhase(ctx, args, session, messages, state, agent, webToolsEnabled);
} catch (err) {
await handleAbortOrError(ctx, args, state.accumulated, err);
return;
}
ctx.log.info({ chatId, toolCallsLen: result.toolCalls.length, finishReason: result.finishReason, contentLen: result.content?.length ?? 0, phase: 'runAssistantTurn:after_stream' }, 'upstream returned');
if (result.toolCalls.length > 0) {
ctx.log.info({ chatId, toolNames: result.toolCalls.map(tc => tc.name), phase: 'runAssistantTurn:before_tools' }, 'executing tools');
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
ctx.log.info({ chatId, phase: 'runAssistantTurn:after_tools' }, 'tools complete, returning');
return;
}
@@ -1465,7 +1439,6 @@ async function runDoomLoopSummary(
loop: { name: string; args: Record<string, unknown> },
): Promise<void> {
const { sessionId, chatId, assistantMessageId, signal } = args;
ctx.log.info({ chatId, loopName: loop.name, phase: 'runDoomLoopSummary:enter' }, 'doom-loop summary firing');
const messages = await buildMessagesPayload(session, project, history, agent);
messages.push({ role: 'system', content: DOOM_LOOP_NOTE(loop.name) });
@@ -1713,7 +1686,7 @@ export function createInferenceRunner(
};
// v1.8 mobile-tabs: announce working before the async loop starts so
// every device subscribed to the user channel sees the amber dot.
callCtx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'working', at: new Date().toISOString() });
callCtx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'streaming', at: new Date().toISOString() });
const controller = new AbortController();
let resolveCompleted!: () => void;
const completed = new Promise<void>((res) => { resolveCompleted = res; });