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:
@@ -120,6 +120,19 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
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
|
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
||||||
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
-- 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';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ const CAP_HIT_SUMMARY_NOTE = (limit: number) =>
|
|||||||
// session/processor.ts). Threshold of 3 is the smallest value that doesn't
|
// 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.
|
// false-positive on a model that retries once after a transient error.
|
||||||
export const DOOM_LOOP_THRESHOLD = 3;
|
export const DOOM_LOOP_THRESHOLD = 3;
|
||||||
export const DOOM_LOOP_SAME_NAME_THRESHOLD = 5;
|
|
||||||
|
|
||||||
const DOOM_LOOP_NOTE = (name: string) =>
|
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.`;
|
`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 };
|
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 {
|
function isCapHitSentinel(m: Message): boolean {
|
||||||
return (
|
return (
|
||||||
m.role === 'system' &&
|
m.role === 'system' &&
|
||||||
@@ -814,6 +801,17 @@ async function handleAbortOrError(
|
|||||||
// genuine errors flip the dot red. v1.8.2: error path also carries a
|
// genuine errors flip the dot red. v1.8.2: error path also carries a
|
||||||
// machine-readable `reason` so the UI can render specifics inline.
|
// machine-readable `reason` so the UI can render specifics inline.
|
||||||
if (isAbort) {
|
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.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
|
||||||
ctx.publish(sessionId, {
|
ctx.publish(sessionId, {
|
||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
@@ -907,6 +905,7 @@ async function executeToolPhase(
|
|||||||
// pre-stamped with output=null as a "pending" sentinel and no tool_result
|
// 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
|
// frame goes out — the card renders from the tool_call frame alone. Mixed
|
||||||
// batches still execute the other tools normally.
|
// 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;
|
let pausingForUserInput = false;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toolCalls.map(async (tc) => {
|
toolCalls.map(async (tc) => {
|
||||||
@@ -951,13 +950,10 @@ async function executeToolPhase(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (pausingForUserInput) {
|
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({
|
ctx.publishUser({
|
||||||
type: 'chat_status',
|
type: 'chat_status',
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
status: 'idle',
|
status: 'waiting_for_input',
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
ctx.log.info(
|
ctx.log.info(
|
||||||
@@ -972,7 +968,6 @@ async function executeToolPhase(
|
|||||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
ctx.log.info({ chatId, nextToolsUsed: toolsUsed + result.toolCalls.length, phase: 'executeToolPhase:before_recurse' }, 'recursing into next turn');
|
|
||||||
await runAssistantTurn(ctx, {
|
await runAssistantTurn(ctx, {
|
||||||
sessionId,
|
sessionId,
|
||||||
chatId,
|
chatId,
|
||||||
@@ -1057,7 +1052,6 @@ async function runAssistantTurn(
|
|||||||
args: TurnArgs,
|
args: TurnArgs,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId } = args;
|
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
|
// v1.11: if the prior turn flagged this chat for compaction, run it first
|
||||||
// so loadContext below reads the post-compaction history. We swallow
|
// so loadContext below reads the post-compaction history. We swallow
|
||||||
@@ -1088,7 +1082,6 @@ async function runAssistantTurn(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { session, project, history } = loaded;
|
const { session, project, history } = loaded;
|
||||||
ctx.log.info({ chatId, historyLen: history.length, phase: 'runAssistantTurn:loaded' }, 'context loaded');
|
|
||||||
const projectRoot = await resolveProjectRoot(project.path);
|
const projectRoot = await resolveProjectRoot(project.path);
|
||||||
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
|
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
|
||||||
// effect on the next message. Unknown agent_id returns null silently —
|
// 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);
|
await runCapHitSummary(ctx, args, session, project, history, agent, budget);
|
||||||
return;
|
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
|
// 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).
|
// 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);
|
await runDoomLoopSummary(ctx, args, session, project, history, agent, loop);
|
||||||
return;
|
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);
|
const messages = await buildMessagesPayload(session, project, history, agent);
|
||||||
|
|
||||||
@@ -1142,24 +1123,17 @@ async function runAssistantTurn(
|
|||||||
const webToolsEnabled =
|
const webToolsEnabled =
|
||||||
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
|
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 };
|
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||||
let result: StreamResult;
|
let result: StreamResult;
|
||||||
try {
|
try {
|
||||||
ctx.log.info({ chatId, model: session.model, phase: 'runAssistantTurn:before_stream' }, 'calling upstream');
|
|
||||||
result = await executeStreamPhase(ctx, args, session, messages, state, agent, webToolsEnabled);
|
result = await executeStreamPhase(ctx, args, session, messages, state, agent, webToolsEnabled);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await handleAbortOrError(ctx, args, state.accumulated, err);
|
await handleAbortOrError(ctx, args, state.accumulated, err);
|
||||||
return;
|
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) {
|
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);
|
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
|
||||||
ctx.log.info({ chatId, phase: 'runAssistantTurn:after_tools' }, 'tools complete, returning');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1465,7 +1439,6 @@ async function runDoomLoopSummary(
|
|||||||
loop: { name: string; args: Record<string, unknown> },
|
loop: { name: string; args: Record<string, unknown> },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, chatId, assistantMessageId, signal } = args;
|
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);
|
const messages = await buildMessagesPayload(session, project, history, agent);
|
||||||
messages.push({ role: 'system', content: DOOM_LOOP_NOTE(loop.name) });
|
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
|
// v1.8 mobile-tabs: announce working before the async loop starts so
|
||||||
// every device subscribed to the user channel sees the amber dot.
|
// 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();
|
const controller = new AbortController();
|
||||||
let resolveCompleted!: () => void;
|
let resolveCompleted!: () => void;
|
||||||
const completed = new Promise<void>((res) => { resolveCompleted = res; });
|
const completed = new Promise<void>((res) => { resolveCompleted = res; });
|
||||||
|
|||||||
Reference in New Issue
Block a user