Skip to content
This repository was archived by the owner on Oct 10, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
? {
titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null,
titleRegenerationStartedAt: event.payload.titleRegeneration?.startedAt ?? null,
titleRegenerationError: event.payload.titleRegeneration?.error ?? null,
}
: {}),
...(event.payload.modelSelection !== undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ function mapTitleRegeneration(row: Schema.Schema.Type<typeof ProjectionThreadDbR
? {
requestId: row.titleRegenerationRequestId,
startedAt: row.titleRegenerationStartedAt,
error: row.titleRegenerationError ?? null,
}
: null;
}
Expand Down Expand Up @@ -386,6 +387,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_error AS "titleRegenerationError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -420,6 +422,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_error AS "titleRegenerationError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -456,6 +459,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_error AS "titleRegenerationError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -892,6 +896,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_error AS "titleRegenerationError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
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,6 +906,64 @@ 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 (no spinner) but the reason survives, so the client
// can say why the title did not change instead of silently no-opping.
expect(thread?.titleRegeneration).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"))?.titleRegeneration
?.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();
});

Expand Down
35 changes: 32 additions & 3 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 All @@ -906,7 +909,13 @@ const make = Effect.gen(function* () {
yield* Effect.forEach(
readModel.threads,
(thread) => {
const requestId = thread.titleRegeneration?.requestId;
// Only in-flight requests were interrupted. A request that already
// failed is a finished result the user has not acknowledged yet, so
// clearing it here would re-hide the error the restart did not cause.
const requestId =
thread.titleRegeneration != null && thread.titleRegeneration.error == null
? thread.titleRegeneration.requestId
: undefined;
if (requestId === undefined) {
return Effect.void;
}
Expand Down Expand Up @@ -941,15 +950,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 +985,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
12 changes: 10 additions & 2 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,8 +683,16 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
command,
threadId: command.threadId,
});
const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId;
const pending = thread.titleRegeneration;
const requestIsCurrent = pending?.requestId === command.requestId;
const occurredAt = yield* nowIso;
// A failed completion keeps the record and stamps the reason instead of
// clearing it, so the sidebar can say why the title did not change. A
// successful (or no-op) completion clears it back to "not regenerating".
const nextTitleRegeneration =
command.error !== undefined && pending != null
? { requestId: pending.requestId, startedAt: pending.startedAt, error: command.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 Preserve pending-state compatibility with older clients

When an upgraded server reports a generation failure to an older web or desktop client, retaining a non-null titleRegeneration record makes that client treat the request as permanently in flight: the pre-change SidebarV2 computes pending state with thread.titleRegeneration != null, showing the spinner and disabling regeneration. Because error is optional, the old schema still decodes this record without signaling incompatibility. Represent the failed state without overloading the existing pending field, or negotiate support before emitting it.

Useful? React with 👍 / 👎.

: null;
return {
...(yield* withEventBase({
aggregateKind: "thread",
Expand All @@ -696,7 +704,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
payload: {
threadId: command.threadId,
...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}),
...(requestIsCurrent ? { titleRegeneration: null } : {}),
...(requestIsCurrent ? { titleRegeneration: nextTitleRegeneration } : {}),
updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt,
},
};
Expand Down
5 changes: 5 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,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at,
title_regeneration_request_id,
title_regeneration_started_at,
title_regeneration_error,
latest_user_message_at,
pending_approval_count,
pending_user_input_count,
Expand All @@ -74,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
${row.snoozedAt},
${row.titleRegenerationRequestId ?? null},
${row.titleRegenerationStartedAt ?? null},
${row.titleRegenerationError ?? null},
${row.latestUserMessageAt},
${row.pendingApprovalCount},
${row.pendingUserInputCount},
Expand All @@ -99,6 +101,7 @@ 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_error = excluded.title_regeneration_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 +134,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_error AS "titleRegenerationError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down Expand Up @@ -165,6 +169,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
snoozed_at AS "snoozedAt",
title_regeneration_request_id AS "titleRegenerationRequestId",
title_regeneration_started_at AS "titleRegenerationStartedAt",
title_regeneration_error AS "titleRegenerationError",
latest_user_message_at AS "latestUserMessageAt",
pending_approval_count AS "pendingApprovalCount",
pending_user_input_count AS "pendingUserInputCount",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import Migration0035 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
import Migration0036 from "./Migrations/033_ProjectionThreadsSettled.ts";
import Migration0037 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
import Migration0038 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";
import Migration0039 from "./Migrations/036_ProjectionThreadTitleRegenerationError.ts";

/**
* Migration loader with all migrations defined inline.
Expand Down Expand Up @@ -108,6 +109,7 @@ export const migrationEntries = [
[36, "ProjectionThreadsSettled", Migration0036],
[37, "ProjectionThreadsSnoozed", Migration0037],
[38, "ProjectionThreadTitleRegeneration", Migration0038],
[39, "ProjectionThreadTitleRegenerationError", Migration0039],
] as const;

export const makeMigrationLoader = (throughId?: number) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "../NodeSqliteClient.ts";

const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));

layer("036_ProjectionThreadTitleRegenerationError", (it) => {
it.effect("adds the title regeneration error column", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* runMigrations({ toMigrationInclusive: 38 });
yield* runMigrations({ toMigrationInclusive: 39 });

const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;
const names = new Set(columns.map((column) => column.name));
assert.ok(names.has("title_regeneration_error"));
}),
);

it.effect("is idempotent when the column already exists", () =>
Effect.gen(function* () {
yield* runMigrations({ toMigrationInclusive: 39 });
yield* runMigrations({ toMigrationInclusive: 39 });

const sql = yield* SqlClient.SqlClient;
const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;
assert.strictEqual(
columns.filter((column) => column.name === "title_regeneration_error").length,
1,
);
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
const columns = yield* sql<{ readonly name: string }>`
PRAGMA table_info(projection_threads)
`;

if (!columns.some((column) => column.name === "title_regeneration_error")) {
yield* sql`
ALTER TABLE projection_threads
ADD COLUMN title_regeneration_error TEXT
`;
}
});
1 change: 1 addition & 0 deletions apps/server/src/persistence/Services/ProjectionThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const ProjectionThread = Schema.Struct({
snoozedAt: Schema.NullOr(IsoDateTime),
titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
titleRegenerationError: Schema.optional(Schema.NullOr(Schema.String)),
latestUserMessageAt: Schema.NullOr(IsoDateTime),
pendingApprovalCount: NonNegativeInt,
pendingUserInputCount: NonNegativeInt,
Expand Down
Loading
Loading