Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,16 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
titleRegenerationStartedAt: event.payload.titleRegeneration?.startedAt ?? null,
}
: {}),
...(event.payload.titleRegenerationFailure !== undefined
? {
titleRegenerationFailureRequestId:
event.payload.titleRegenerationFailure?.requestId ?? null,
titleRegenerationFailureAt:
event.payload.titleRegenerationFailure?.failedAt ?? null,
titleRegenerationFailureError:
event.payload.titleRegenerationFailure?.error ?? null,
}
: {}),
...(event.payload.modelSelection !== undefined
? { modelSelection: event.payload.modelSelection }
: {}),
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,18 @@ function mapTitleRegeneration(row: Schema.Schema.Type<typeof ProjectionThreadDbR
: null;
}

function mapTitleRegenerationFailure(row: Schema.Schema.Type<typeof ProjectionThreadDbRowSchema>) {
return row.titleRegenerationFailureRequestId != null &&
row.titleRegenerationFailureAt != null &&
row.titleRegenerationFailureError != null
? {
requestId: row.titleRegenerationFailureRequestId,
failedAt: row.titleRegenerationFailureAt,
error: row.titleRegenerationFailureError,
}
: null;
}

function mapSessionRow(
row: Schema.Schema.Type<typeof ProjectionThreadSessionDbRowSchema>,
): OrchestrationSession {
Expand Down Expand Up @@ -386,6 +398,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId",
title_regeneration_failure_at AS "titleRegenerationFailureAt",
title_regeneration_failure_error AS "titleRegenerationFailureError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -420,6 +435,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId",
title_regeneration_failure_at AS "titleRegenerationFailureAt",
title_regeneration_failure_error AS "titleRegenerationFailureError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -456,6 +474,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId",
title_regeneration_failure_at AS "titleRegenerationFailureAt",
title_regeneration_failure_error AS "titleRegenerationFailureError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -892,6 +913,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId",
title_regeneration_failure_at AS "titleRegenerationFailureAt",
title_regeneration_failure_error AS "titleRegenerationFailureError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -1329,6 +1353,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedUntil: row.snoozedUntil,
snoozedAt: row.snoozedAt,
titleRegeneration: mapTitleRegeneration(row),
titleRegenerationFailure: mapTitleRegenerationFailure(row),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update snapshot tests for the added failure field

When ProjectionSnapshotQuery.test.ts runs, both getSnapshot() and getShellSnapshot() now return titleRegenerationFailure: null because this mapping always materializes the field, but the exact assert.deepEqual expectations beginning at lines 283 and 398 omit it. Those existing backend tests therefore fail even for threads without a regeneration failure; update the expected fixtures and cover the non-null projection path.

AGENTS.md reference: AGENTS.md:L29-L33

Useful? React with 👍 / 👎.

deletedAt: row.deletedAt,
messages: messagesByThread.get(row.threadId) ?? [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
Expand Down Expand Up @@ -1532,6 +1557,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedUntil: row.snoozedUntil,
snoozedAt: row.snoozedAt,
titleRegeneration: mapTitleRegeneration(row),
titleRegenerationFailure: mapTitleRegenerationFailure(row),
deletedAt: row.deletedAt,
messages: [],
proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
Expand Down Expand Up @@ -1666,6 +1692,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedUntil: row.snoozedUntil,
snoozedAt: row.snoozedAt,
titleRegeneration: mapTitleRegeneration(row),
titleRegenerationFailure: mapTitleRegenerationFailure(row),
session: sessionByThread.get(row.threadId) ?? null,
latestUserMessageAt: row.latestUserMessageAt,
hasPendingApprovals: row.pendingApprovalCount > 0,
Expand Down Expand Up @@ -1805,6 +1832,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedUntil: row.snoozedUntil,
snoozedAt: row.snoozedAt,
titleRegeneration: mapTitleRegeneration(row),
titleRegenerationFailure: mapTitleRegenerationFailure(row),
session: sessionByThread.get(row.threadId) ?? null,
latestUserMessageAt: row.latestUserMessageAt,
hasPendingApprovals: row.pendingApprovalCount > 0,
Expand Down Expand Up @@ -2076,6 +2104,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedUntil: threadRow.value.snoozedUntil,
snoozedAt: threadRow.value.snoozedAt,
titleRegeneration: mapTitleRegeneration(threadRow.value),
titleRegenerationFailure: mapTitleRegenerationFailure(threadRow.value),
session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
latestUserMessageAt: threadRow.value.latestUserMessageAt,
hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
Expand Down Expand Up @@ -2175,6 +2204,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozedUntil: threadRow.value.snoozedUntil,
snoozedAt: threadRow.value.snoozedAt,
titleRegeneration: mapTitleRegeneration(threadRow.value),
titleRegenerationFailure: mapTitleRegenerationFailure(threadRow.value),
deletedAt: null,
messages: messageRows.map((row) => {
const message = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,7 @@ describe("ProviderCommandReactor", () => {
expect(thread?.titleRegeneration).toBeNull();
});

it("clears title regeneration state when generation fails", async () => {
it("records the failure reason when generation fails", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

Expand Down Expand Up @@ -906,7 +906,68 @@ describe("ProviderCommandReactor", () => {
const readModel = await harness.readModel();
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
expect(thread?.title).toBe("Keep title after failure");
// The request is over — the pending record clears exactly as it does on
// success, so clients that only understand "pending or not" are unaffected
// — but the reason survives on its own field.
expect(thread?.titleRegeneration).toBeNull();
expect(thread?.titleRegenerationFailure).toMatchObject({
requestId: CommandId.make("cmd-thread-title-failed-regeneration"),
error: "disabled in test harness",
});
});

it("clears a recorded failure when regeneration is requested again", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";

await harness.runEffect(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make("cmd-turn-start-before-retried-regeneration"),
threadId: ThreadId.make("thread-1"),
message: {
messageId: asMessageId("user-message-before-retried-regeneration"),
role: "user",
text: "Investigate the reconnect state.",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
}),
);
await harness.runEffect(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-title-first-failed-regeneration"),
threadId: ThreadId.make("thread-1"),
regenerateTitle: true,
}),
);
await harness.drain();

let readModel = await harness.readModel();
expect(
readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))
?.titleRegenerationFailure?.error,
).toBe("disabled in test harness");

harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Recovered title" }));
await harness.runEffect(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("cmd-thread-title-retried-regeneration"),
threadId: ThreadId.make("thread-1"),
regenerateTitle: true,
}),
);
await harness.drain();

readModel = await harness.readModel();
const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
expect(thread?.title).toBe("Recovered title");
expect(thread?.titleRegeneration).toBeNull();
expect(thread?.titleRegenerationFailure).toBeNull();
});

it("retries a failed completion and continues regenerating", async () => {
Expand Down
27 changes: 25 additions & 2 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
const DEFAULT_THREAD_TITLE = "New thread";
const MAX_REGENERATION_ATTACHMENTS = 4;
const TITLE_REGENERATION_FALLBACK_ERROR = "Title generation failed unexpectedly.";
const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;
const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";

Expand Down Expand Up @@ -890,13 +891,15 @@ const make = Effect.gen(function* () {
readonly threadId: ThreadId;
readonly requestId: CommandId;
readonly title?: string;
readonly error?: string;
}) {
yield* orchestrationEngine.dispatch({
type: "thread.title.regeneration.complete",
commandId: yield* serverCommandId("thread-title-regeneration-complete"),
threadId: input.threadId,
requestId: input.requestId,
...(input.title !== undefined ? { title: input.title } : {}),
...(input.error !== undefined ? { error: input.error } : {}),
});
});
const clearInterruptedThreadTitleRegenerations = Effect.fn(
Expand Down Expand Up @@ -941,15 +944,32 @@ const make = Effect.gen(function* () {
if (requestId === null) {
return;
}
// Generation failures are carried into the completion instead of being
// swallowed: without them the request completes "successfully" with no
// title and the user sees a spinner clear with nothing changed. Providers
// whose text generation is unimplemented fail this way every time.
const result = yield* regenerateThreadTitle(event, requestId).pipe(
Effect.catchTag("TextGenerationError", (error) =>
Effect.logWarning("provider command reactor failed to regenerate thread title", {
threadId: event.payload.threadId,
detail: error.detail,
}).pipe(
Effect.as({
_tag: "Failed",
// The completion payload requires a non-empty reason; a provider
// that fails without one still has to produce a visible error.
error: error.detail.trim() || TITLE_REGENERATION_FALLBACK_ERROR,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound persisted title-generation error details

When a provider exits with verbose output, this stores the entire TextGenerationError.detail without a size limit. For example, CodexTextGeneration.ts:239-248 builds the detail from the complete stderr/stdout stream; the value is then persisted in the event and projection and included in every shell snapshot until the failure is cleared, so one noisy failure can permanently inflate SQLite rows and WebSocket payloads. Normalize or truncate the user-facing reason before dispatching the completion.

AGENTS.md reference: AGENTS.md:L56-L62

Useful? React with 👍 / 👎.

} as const),
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) {
return Effect.failCause(cause);
}
return Effect.logWarning("provider command reactor failed to regenerate thread title", {
threadId: event.payload.threadId,
cause: Cause.pretty(cause),
}).pipe(Effect.as({ _tag: "Completed", title: undefined } as const));
}).pipe(Effect.as({ _tag: "Failed", error: TITLE_REGENERATION_FALLBACK_ERROR } as const));
}),
);
if (result._tag === "Superseded") {
Expand All @@ -959,7 +979,10 @@ const make = Effect.gen(function* () {
const completion = {
threadId: event.payload.threadId,
requestId,
...(result.title !== undefined ? { title: result.title } : {}),
...(result._tag === "Completed" && result.title !== undefined
? { title: result.title }
: {}),
...(result._tag === "Failed" ? { error: result.error } : {}),
};
yield* dispatchThreadTitleRegenerationCompletion(completion).pipe(
Effect.catchCause((cause) => {
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,11 +662,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
requestId: command.commandId,
startedAt: occurredAt,
},
// A retry supersedes whatever the last attempt reported.
titleRegenerationFailure: null,
}
: {}),
...(command.title !== undefined && thread.titleRegeneration != null
? { titleRegeneration: null }
: {}),
// A manual rename answers the question the failure was reporting.
...(command.title !== undefined && thread.titleRegenerationFailure != null
? { titleRegenerationFailure: null }
: {}),
...(command.modelSelection !== undefined
? { modelSelection: command.modelSelection }
: {}),
Expand All @@ -685,6 +691,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
});
const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId;
const occurredAt = yield* nowIso;
// Every completion clears the pending record — the request is over either
// way. A failure additionally records why, on its own field, so clients
// that only understand "pending or not" are unaffected.
const failure =
command.error !== undefined
? { requestId: command.requestId, failedAt: occurredAt, error: command.error }
: null;
return {
...(yield* withEventBase({
aggregateKind: "thread",
Expand All @@ -696,7 +709,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
payload: {
threadId: command.threadId,
...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}),
...(requestIsCurrent ? { titleRegeneration: null } : {}),
...(requestIsCurrent
? { titleRegeneration: null, titleRegenerationFailure: failure }
: {}),
updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt,
},
};
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,9 @@ export function projectEvent(
...(payload.titleRegeneration !== undefined
? { titleRegeneration: payload.titleRegeneration }
: {}),
...(payload.titleRegenerationFailure !== undefined
? { titleRegenerationFailure: payload.titleRegenerationFailure }
: {}),
...(payload.modelSelection !== undefined
? { modelSelection: payload.modelSelection }
: {}),
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/persistence/Layers/ProjectionThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at,
title_regeneration_request_id,
title_regeneration_started_at,
title_regeneration_failure_request_id,
title_regeneration_failure_at,
title_regeneration_failure_error,
latest_user_message_at,
pending_approval_count,
pending_user_input_count,
Expand All @@ -74,6 +77,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
${row.snoozedAt},
${row.titleRegenerationRequestId ?? null},
${row.titleRegenerationStartedAt ?? null},
${row.titleRegenerationFailureRequestId ?? null},
${row.titleRegenerationFailureAt ?? null},
${row.titleRegenerationFailureError ?? null},
${row.latestUserMessageAt},
${row.pendingApprovalCount},
${row.pendingUserInputCount},
Expand All @@ -99,6 +105,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at = excluded.snoozed_at,
title_regeneration_request_id = excluded.title_regeneration_request_id,
title_regeneration_started_at = excluded.title_regeneration_started_at,
title_regeneration_failure_request_id = excluded.title_regeneration_failure_request_id,
title_regeneration_failure_at = excluded.title_regeneration_failure_at,
title_regeneration_failure_error = excluded.title_regeneration_failure_error,
latest_user_message_at = excluded.latest_user_message_at,
pending_approval_count = excluded.pending_approval_count,
pending_user_input_count = excluded.pending_user_input_count,
Expand Down Expand Up @@ -131,6 +140,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId",
title_regeneration_failure_at AS "titleRegenerationFailureAt",
title_regeneration_failure_error AS "titleRegenerationFailureError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -165,6 +177,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId",
title_regeneration_failure_at AS "titleRegenerationFailureAt",
title_regeneration_failure_error AS "titleRegenerationFailureError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down
Loading
Loading