Follow-ups from the main merge: - Migration numbering: main's 035_ProjectionThreadTitleRegeneration is released and keeps its id; this branch's unreleased v2 migrations shift to 036-044 instead (a released migration can never be renumbered). - thread.metadata.update accepts regenerateTitle: true arms an in-flight titleRegeneration marker on the thread payload (requestId + startedAt), a landing title or explicit false clears it. The marker projects onto thread shells and through the client-runtime shell model, so the sidebar's Regenerating state works unchanged. - New ThreadTitleRegenerationService worker reacts to armed markers on the live domain-event stream (so ws, MCP, and mobile dispatches all behave the same): builds a newest-first conversation digest from the v2 projection (8k-char budget, retained attachments — ported from the v1 reactor), generates via TextGeneration, and lands the title with a follow-up metadata update. Failures clear the marker and log. - The server advertises threadTitleRegeneration again, and the client updateThreadMetadata dispatches regenerateTitle-only updates.
Unified · Split
| @@ -146,6 +146,7 @@ export const make = Effect.gen(function* () { · 145 unmodified lines | ||
| 146 | 146 | threadSettlement: true, |
| 147 | 147 | threadSnooze: true, |
| 148 | 148 | threadVisitedTracking: true, |
| 149 | + threadTitleRegeneration: true, | |
| 149 | 150 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 150 | 151 | ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), |
| 151 | 152 | }, |
| @@ -1138,6 +1138,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1137 unmodified lines | ||
| 1138 | 1138 | ...(command.title === undefined ? {} : { title: command.title }), |
| 1139 | 1139 | ...(command.branch === undefined ? {} : { branch: command.branch }), |
| 1140 | 1140 | ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), |
| 1141 | + // regenerateTitle: true arms the in-flight marker; a landing title | |
| 1142 | + // or an explicit false (generation failed/abandoned) clears it. | |
| 1143 | + ...(command.regenerateTitle === true | |
| 1144 | + ? { titleRegeneration: { requestId: command.commandId, startedAt: now } } | |
| 1145 | + : command.regenerateTitle === false || command.title !== undefined | |
| 1146 | + ? { titleRegeneration: null } | |
| 1147 | + : {}), | |
| 1141 | 1148 | updatedAt: now, |
| 1142 | 1149 | }; |
| 1143 | 1150 | case "thread.runtime-mode.set": |
| @@ -843,6 +843,7 @@ export function threadShellFromProjection( · 842 unmodified lines | ||
| 843 | 843 | snoozedUntil: projection.thread.snoozedUntil ?? null, |
| 844 | 844 | snoozedAt: projection.thread.snoozedAt ?? null, |
| 845 | 845 | lastVisitedAt: projection.thread.lastVisitedAt, |
| 846 | + titleRegeneration: projection.thread.titleRegeneration ?? null, | |
| 846 | 847 | deletedAt: projection.thread.deletedAt, |
| 847 | 848 | }; |
| 848 | 849 | } |
| @@ -1011,6 +1012,7 @@ function shellFromState(input: { · 162 unmodified lines | ||
| 1011 | 1012 | snoozedUntil: input.state.thread.snoozedUntil ?? null, |
| 1012 | 1013 | snoozedAt: input.state.thread.snoozedAt ?? null, |
| 1013 | 1014 | lastVisitedAt: input.state.thread.lastVisitedAt, |
| 1015 | + titleRegeneration: input.state.thread.titleRegeneration ?? null, | |
| 1014 | 1016 | deletedAt: input.state.thread.deletedAt, |
| 1015 | 1017 | }; |
| 1016 | 1018 | } |
| @@ -0,0 +1,160 @@ | ||
| 1 | +import { type ChatAttachment, CommandId, type ThreadId } from "@t3tools/contracts"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Layer from "effect/Layer"; | |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | +import * as Stream from "effect/Stream"; | |
| 6 | + | |
| 7 | +import * as ProjectService from "../project/ProjectService.ts"; | |
| 8 | +import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | |
| 9 | +import { ThreadManagementService } from "./ThreadManagementService.ts"; | |
| 10 | + | |
| 11 | +const MAX_REGENERATION_ATTACHMENTS = 4; | |
| 12 | +const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; | |
| 13 | +const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n"; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * Newest-first conversation digest for the title prompt: walk messages from | |
| 17 | + * the end, keep whole sections while they fit the char budget, and mark | |
| 18 | + * truncation once older content stops fitting. Ported from the v1 reactor's | |
| 19 | + * regeneration flow. | |
| 20 | + */ | |
| 21 | +export function formatThreadTitleContext( | |
| 22 | + messages: ReadonlyArray<{ | |
| 23 | + readonly role: "user" | "assistant" | "system"; | |
| 24 | + readonly text: string; | |
| 25 | + readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | |
| 26 | + }>, | |
| 27 | +): { | |
| 28 | + readonly message: string; | |
| 29 | + readonly attachments: ReadonlyArray<ChatAttachment>; | |
| 30 | +} { | |
| 31 | + let context = ""; | |
| 32 | + let truncated = false; | |
| 33 | + const retainedAttachments: Array<ChatAttachment> = []; | |
| 34 | + | |
| 35 | + for (const message of messages.toReversed()) { | |
| 36 | + if (message.role === "system") { | |
| 37 | + continue; | |
| 38 | + } | |
| 39 | + const text = message.text.trim(); | |
| 40 | + const attachmentSummary = (message.attachments ?? []) | |
| 41 | + .map((attachment) => attachment.name) | |
| 42 | + .join(", "); | |
| 43 | + const contents = [ | |
| 44 | + ...(text.length > 0 ? [text] : []), | |
| 45 | + ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []), | |
| 46 | + ].join("\n"); | |
| 47 | + if (contents.length === 0) { | |
| 48 | + continue; | |
| 49 | + } | |
| 50 | + | |
| 51 | + const section = `${message.role.toUpperCase()}:\n${contents}`; | |
| 52 | + const separator = context.length > 0 ? "\n\n" : ""; | |
| 53 | + const available = MAX_THREAD_TITLE_CONTEXT_CHARS - context.length - separator.length; | |
| 54 | + if (section.length > available) { | |
| 55 | + truncated = true; | |
| 56 | + break; | |
| 57 | + } | |
| 58 | + context = `${section}${separator}${context}`; | |
| 59 | + for (const attachment of message.attachments ?? []) { | |
| 60 | + if (retainedAttachments.length < MAX_REGENERATION_ATTACHMENTS) { | |
| 61 | + retainedAttachments.push(attachment); | |
| 62 | + } | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + return { | |
| 67 | + message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context, | |
| 68 | + attachments: retainedAttachments, | |
| 69 | + }; | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** | |
| 73 | + * Reacts to thread.metadata-updated events that arm the titleRegeneration | |
| 74 | + * marker: generates a title from the thread's conversation and lands it via a | |
| 75 | + * follow-up metadata update (which clears the marker), or clears the marker | |
| 76 | + * explicitly when generation fails. Event-driven so every dispatch surface | |
| 77 | + * (ws, MCP, mobile) gets the same behavior. | |
| 78 | + */ | |
| 79 | +export const workerLive = Layer.effectDiscard( | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const threads = yield* ThreadManagementService; | |
| 82 | + const projects = yield* ProjectService.ProjectService; | |
| 83 | + const textGeneration = yield* TextGeneration.TextGeneration; | |
| 84 | + const handledRequestIds = new Set<string>(); | |
| 85 | + | |
| 86 | + const regenerate = Effect.fn("ThreadTitleRegenerationService.regenerate")(function* ( | |
| 87 | + threadId: ThreadId, | |
| 88 | + requestId: CommandId, | |
| 89 | + ) { | |
| 90 | + const projection = yield* threads.getThreadProjection(threadId); | |
| 91 | + if (projection.thread.titleRegeneration?.requestId !== requestId) { | |
| 92 | + return; | |
| 93 | + } | |
| 94 | + const project = yield* projects.getById(projection.thread.projectId); | |
| 95 | + if (Option.isNone(project)) { | |
| 96 | + return; | |
| 97 | + } | |
| 98 | + const context = formatThreadTitleContext( | |
| 99 | + projection.messages.filter((message) => !message.streaming), | |
| 100 | + ); | |
| 101 | + if (context.message.length === 0) { | |
| 102 | + yield* threads.dispatch({ | |
| 103 | + type: "thread.metadata.update", | |
| 104 | + commandId: CommandId.make(`${requestId}:title-clear`), | |
| 105 | + threadId, | |
| 106 | + regenerateTitle: false, | |
| 107 | + }); | |
| 108 | + return; | |
| 109 | + } | |
| 110 | + const result = yield* textGeneration.generateThreadTitle({ | |
| 111 | + cwd: projection.thread.worktreePath ?? Option.getOrThrow(project).workspaceRoot, | |
| 112 | + message: context.message, | |
| 113 | + attachments: context.attachments, | |
| 114 | + modelSelection: projection.thread.modelSelection, | |
| 115 | + }); | |
| 116 | + yield* threads.dispatch({ | |
| 117 | + type: "thread.metadata.update", | |
| 118 | + commandId: CommandId.make(`${requestId}:title`), | |
| 119 | + threadId, | |
| 120 | + title: result.title, | |
| 121 | + }); | |
| 122 | + }); | |
| 123 | + | |
| 124 | + yield* threads.streamDomainEvents.pipe( | |
| 125 | + Stream.runForEach((event) => { | |
| 126 | + if (event.type !== "thread.metadata-updated") { | |
| 127 | + return Effect.void; | |
| 128 | + } | |
| 129 | + const marker = event.payload.titleRegeneration; | |
| 130 | + if (marker === null || marker === undefined || handledRequestIds.has(marker.requestId)) { | |
| 131 | + return Effect.void; | |
| 132 | + } | |
| 133 | + handledRequestIds.add(marker.requestId); | |
| 134 | + return regenerate(event.threadId, marker.requestId).pipe( | |
| 135 | + Effect.catchCause((cause) => | |
| 136 | + Effect.logWarning("Thread title regeneration failed", { | |
| 137 | + threadId: event.threadId, | |
| 138 | + requestId: marker.requestId, | |
| 139 | + cause, | |
| 140 | + }).pipe( | |
| 141 | + Effect.andThen( | |
| 142 | + threads | |
| 143 | + .dispatch({ | |
| 144 | + type: "thread.metadata.update", | |
| 145 | + commandId: CommandId.make(`${marker.requestId}:title-clear`), | |
| 146 | + threadId: event.threadId, | |
| 147 | + regenerateTitle: false, | |
| 148 | + }) | |
| 149 | + .pipe(Effect.ignore), | |
| 150 | + ), | |
| 151 | + ), | |
| 152 | + ), | |
| 153 | + Effect.forkDetach, | |
| 154 | + Effect.asVoid, | |
| 155 | + ); | |
| 156 | + }), | |
| 157 | + Effect.forkScoped, | |
| 158 | + ); | |
| 159 | + }), | |
| 160 | +); | |
| @@ -27,6 +27,7 @@ import { layer as projectionMaintenanceLayer } from "./ProjectionMaintenance.ts" · 26 unmodified lines | ||
| 27 | 27 | import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./ProviderAdapterRegistry.ts"; |
| 28 | 28 | import { layer as providerContinuationRequestsLayer } from "./ProviderContinuationRequests.ts"; |
| 29 | 29 | import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts"; |
| 30 | +import { workerLive as threadTitleRegenerationWorkerLive } from "./ThreadTitleRegenerationService.ts"; | |
| 30 | 31 | import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts"; |
| 31 | 32 | import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts"; |
| 32 | 33 | import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts"; |
| @@ -243,6 +244,9 @@ const providerContinuationWorkerProvided = providerContinuationWorkerLive.pipe( · 210 unmodified lines | ||
| 243 | 244 | Layer.mergeAll(providerContinuationRequestsLayer, threadManagementProvided, idAllocatorLayer), |
| 244 | 245 | ), |
| 245 | 246 | ); |
| 247 | +const threadTitleRegenerationWorkerProvided = threadTitleRegenerationWorkerLive.pipe( | |
| 248 | + Layer.provide(Layer.merge(threadManagementProvided, ProjectServiceLayerLive)), | |
| 249 | +); | |
| 246 | 250 | |
| 247 | 251 | export const OrchestrationV2LayerLive = Layer.mergeAll( |
| 248 | 252 | orchestratorProvided, |
| @@ -257,6 +261,7 @@ export const OrchestrationV2LayerLive = Layer.mergeAll( · 8 unmodified lines | ||
| 257 | 261 | export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( |
| 258 | 262 | OrchestrationLayerLive, |
| 259 | 263 | OrchestrationV2LayerLive, |
| 264 | + threadTitleRegenerationWorkerProvided, | |
| 260 | 265 | ProjectServiceLayerLive, |
| 261 | 266 | threadLaunchProvided, |
| 262 | 267 | threadLifecycleProvided, |
| @@ -47,15 +47,16 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; · 46 unmodified lines | ||
| 47 | 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; |
| 48 | 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; |
| 49 | 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; |
| 50 | −import Migration0035 from "./Migrations/035_OrchestrationV2.ts"; | |
| 51 | −import Migration0036 from "./Migrations/036_OrchestrationV2Subagents.ts"; | |
| 52 | −import Migration0037 from "./Migrations/037_OrchestrationV2Foundation.ts"; | |
| 53 | −import Migration0038 from "./Migrations/038_OrchestrationV2ProviderSessionBindings.ts"; | |
| 54 | −import Migration0039 from "./Migrations/039_OrchestrationV2ThreadLaunchWorkflows.ts"; | |
| 55 | −import Migration0040 from "./Migrations/040_ApplicationEventSource.ts"; | |
| 56 | −import Migration0041 from "./Migrations/041_OrchestrationV2EffectCancellation.ts"; | |
| 57 | −import Migration0042 from "./Migrations/042_ScheduledTasks.ts"; | |
| 58 | −import Migration0043 from "./Migrations/043_LegacyV1ImportState.ts"; | |
| 50 | +import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; | |
| 51 | +import Migration0036 from "./Migrations/036_OrchestrationV2.ts"; | |
| 52 | +import Migration0037 from "./Migrations/037_OrchestrationV2Subagents.ts"; | |
| 53 | +import Migration0038 from "./Migrations/038_OrchestrationV2Foundation.ts"; | |
| 54 | +import Migration0039 from "./Migrations/039_OrchestrationV2ProviderSessionBindings.ts"; | |
| 55 | +import Migration0040 from "./Migrations/040_OrchestrationV2ThreadLaunchWorkflows.ts"; | |
| 56 | +import Migration0041 from "./Migrations/041_ApplicationEventSource.ts"; | |
| 57 | +import Migration0042 from "./Migrations/042_OrchestrationV2EffectCancellation.ts"; | |
| 58 | +import Migration0043 from "./Migrations/043_ScheduledTasks.ts"; | |
| 59 | +import Migration0044 from "./Migrations/044_LegacyV1ImportState.ts"; | |
| 59 | 60 | |
| 60 | 61 | /** |
| 61 | 62 | * Migration loader with all migrations defined inline. |
| @@ -102,15 +103,16 @@ export const migrationEntries = [ · 40 unmodified lines | ||
| 102 | 103 | [32, "AuthPairingProofKeyThumbprint", Migration0032], |
| 103 | 104 | [33, "ProjectionThreadsSettled", Migration0033], |
| 104 | 105 | [34, "ProjectionThreadsSnoozed", Migration0034], |
| 105 | − [35, "OrchestrationV2", Migration0035], | |
| 106 | − [36, "OrchestrationV2Subagents", Migration0036], | |
| 107 | − [37, "OrchestrationV2Foundation", Migration0037], | |
| 108 | − [38, "OrchestrationV2ProviderSessionBindings", Migration0038], | |
| 109 | − [39, "OrchestrationV2ThreadLaunchWorkflows", Migration0039], | |
| 110 | − [40, "ApplicationEventSource", Migration0040], | |
| 111 | − [41, "OrchestrationV2EffectCancellation", Migration0041], | |
| 112 | − [42, "ScheduledTasks", Migration0042], | |
| 113 | − [43, "LegacyV1ImportState", Migration0043], | |
| 106 | + [35, "ProjectionThreadTitleRegeneration", Migration0035], | |
| 107 | + [36, "OrchestrationV2", Migration0036], | |
| 108 | + [37, "OrchestrationV2Subagents", Migration0037], | |
| 109 | + [38, "OrchestrationV2Foundation", Migration0038], | |
| 110 | + [39, "OrchestrationV2ProviderSessionBindings", Migration0039], | |
| 111 | + [40, "OrchestrationV2ThreadLaunchWorkflows", Migration0040], | |
| 112 | + [41, "ApplicationEventSource", Migration0041], | |
| 113 | + [42, "OrchestrationV2EffectCancellation", Migration0042], | |
| 114 | + [43, "ScheduledTasks", Migration0043], | |
| 115 | + [44, "LegacyV1ImportState", Migration0044], | |
| 114 | 116 | ] as const; |
| 115 | 117 | |
| 116 | 118 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
| @@ -8,7 +8,7 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; · 7 unmodified lines | ||
| 8 | 8 | |
| 9 | 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); |
| 10 | 10 | |
| 11 | −layer("035_036_OrchestrationV2", (it) => { | |
| 11 | +layer("036_037_OrchestrationV2", (it) => { | |
| 12 | 12 | it.effect("keeps released and private migration ids contiguous", () => |
| 13 | 13 | Effect.sync(() => { |
| 14 | 14 | assert.deepStrictEqual( |
| @@ -22,7 +22,7 @@ layer("035_036_OrchestrationV2", (it) => { · 7 unmodified lines | ||
| 22 | 22 | Effect.gen(function* () { |
| 23 | 23 | const sql = yield* SqlClient.SqlClient; |
| 24 | 24 | |
| 25 | − yield* runMigrations({ toMigrationInclusive: 35 }); | |
| 25 | + yield* runMigrations({ toMigrationInclusive: 37 }); | |
| 26 | 26 | |
| 27 | 27 | const migrations = yield* sql<{ |
| 28 | 28 | readonly migration_id: number; |
| @@ -30,20 +30,24 @@ layer("035_036_OrchestrationV2", (it) => { · 1 unmodified line | ||
| 30 | 30 | }>` |
| 31 | 31 | SELECT migration_id, name |
| 32 | 32 | FROM effect_sql_migrations |
| 33 | − WHERE migration_id IN (33, 34, 35) | |
| 33 | + WHERE migration_id IN (34, 35, 36, 37) | |
| 34 | 34 | ORDER BY migration_id |
| 35 | 35 | `; |
| 36 | 36 | assert.deepStrictEqual(migrations, [ |
| 37 | 37 | { |
| 38 | − migration_id: 33, | |
| 39 | − name: "ProjectionThreadsSettled", | |
| 38 | + migration_id: 34, | |
| 39 | + name: "ProjectionThreadsSnoozed", | |
| 40 | 40 | }, |
| 41 | 41 | { |
| 42 | − migration_id: 34, | |
| 42 | + migration_id: 35, | |
| 43 | + name: "ProjectionThreadTitleRegeneration", | |
| 44 | + }, | |
| 45 | + { | |
| 46 | + migration_id: 36, | |
| 43 | 47 | name: "OrchestrationV2", |
| 44 | 48 | }, |
| 45 | 49 | { |
| 46 | − migration_id: 35, | |
| 50 | + migration_id: 37, | |
| 47 | 51 | name: "OrchestrationV2Subagents", |
| 48 | 52 | }, |
| 49 | 53 | ]); |
| @@ -63,7 +67,7 @@ layer("035_036_OrchestrationV2", (it) => { · 13 unmodified lines | ||
| 63 | 67 | it.effect("backfills provider-session thread bindings in migration 036", () => |
| 64 | 68 | Effect.gen(function* () { |
| 65 | 69 | const sql = yield* SqlClient.SqlClient; |
| 66 | − yield* runMigrations({ toMigrationInclusive: 36 }); | |
| 70 | + yield* runMigrations({ toMigrationInclusive: 38 }); | |
| 67 | 71 | yield* sql` |
| 68 | 72 | INSERT INTO orchestration_v2_projection_provider_sessions ( |
| 69 | 73 | provider_session_id, |
| @@ -88,7 +92,7 @@ layer("035_036_OrchestrationV2", (it) => { · 18 unmodified lines | ||
| 88 | 92 | ) |
| 89 | 93 | `; |
| 90 | 94 | |
| 91 | − yield* runMigrations({ toMigrationInclusive: 37 }); | |
| 95 | + yield* runMigrations({ toMigrationInclusive: 39 }); | |
| 92 | 96 | |
| 93 | 97 | const bindings = yield* sql<{ |
| 94 | 98 | readonly provider_session_id: string; |
| @@ -109,7 +113,7 @@ layer("035_036_OrchestrationV2", (it) => { · 14 unmodified lines | ||
| 109 | 113 | it.effect("preserves turn items with colliding ordinals in migration 037", () => |
| 110 | 114 | Effect.gen(function* () { |
| 111 | 115 | const sql = yield* SqlClient.SqlClient; |
| 112 | − yield* runMigrations({ toMigrationInclusive: 36 }); | |
| 116 | + yield* runMigrations({ toMigrationInclusive: 37 }); | |
| 113 | 117 | yield* sql` |
| 114 | 118 | INSERT INTO orchestration_v2_projection_runs ( |
| 115 | 119 | run_id, |
| @@ -146,7 +150,7 @@ layer("035_036_OrchestrationV2", (it) => { · 30 unmodified lines | ||
| 146 | 150 | ('turn-item:d', 'thread:two', NULL, 42, 'assistant_message', 'completed', '2026-01-01T00:00:00.000Z', '{}') |
| 147 | 151 | `; |
| 148 | 152 | |
| 149 | − yield* runMigrations({ toMigrationInclusive: 37 }); | |
| 153 | + yield* runMigrations({ toMigrationInclusive: 38 }); | |
| 150 | 154 | |
| 151 | 155 | const positions = yield* sql<{ |
| 152 | 156 | readonly thread_id: string; |
| @@ -171,14 +175,14 @@ it.effect("upgrades a database already at released main migration 034", () => · 18 unmodified lines | ||
| 171 | 175 | Effect.gen(function* () { |
| 172 | 176 | const sql = yield* SqlClient.SqlClient; |
| 173 | 177 | |
| 174 | − yield* runMigrations({ toMigrationInclusive: 34 }); | |
| 178 | + yield* runMigrations({ toMigrationInclusive: 35 }); | |
| 175 | 179 | const snoozeColumns = yield* sql<{ readonly name: string }>` |
| 176 | 180 | PRAGMA table_info(projection_threads) |
| 177 | 181 | `; |
| 178 | 182 | assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until")); |
| 179 | 183 | assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at")); |
| 180 | 184 | |
| 181 | − yield* runMigrations({ toMigrationInclusive: 43 }); | |
| 185 | + yield* runMigrations({ toMigrationInclusive: 44 }); | |
| 182 | 186 | |
| 183 | 187 | const migrations = yield* sql<{ |
| 184 | 188 | readonly migration_id: number; |
| @@ -186,22 +190,23 @@ it.effect("upgrades a database already at released main migration 034", () => · 1 unmodified line | ||
| 186 | 190 | }>` |
| 187 | 191 | SELECT migration_id, name |
| 188 | 192 | FROM effect_sql_migrations |
| 189 | − WHERE migration_id BETWEEN 34 AND 43 | |
| 193 | + WHERE migration_id BETWEEN 34 AND 44 | |
| 190 | 194 | ORDER BY migration_id |
| 191 | 195 | `; |
| 192 | 196 | assert.deepStrictEqual( |
| 193 | 197 | migrations.map(({ migration_id, name }) => [migration_id, name]), |
| 194 | 198 | [ |
| 195 | 199 | [34, "ProjectionThreadsSnoozed"], |
| 196 | − [35, "OrchestrationV2"], | |
| 197 | − [36, "OrchestrationV2Subagents"], | |
| 198 | − [37, "OrchestrationV2Foundation"], | |
| 199 | − [38, "OrchestrationV2ProviderSessionBindings"], | |
| 200 | − [39, "OrchestrationV2ThreadLaunchWorkflows"], | |
| 201 | − [40, "ApplicationEventSource"], | |
| 202 | − [41, "OrchestrationV2EffectCancellation"], | |
| 203 | − [42, "ScheduledTasks"], | |
| 204 | − [43, "LegacyV1ImportState"], | |
| 200 | + [35, "ProjectionThreadTitleRegeneration"], | |
| 201 | + [36, "OrchestrationV2"], | |
| 202 | + [37, "OrchestrationV2Subagents"], | |
| 203 | + [38, "OrchestrationV2Foundation"], | |
| 204 | + [39, "OrchestrationV2ProviderSessionBindings"], | |
| 205 | + [40, "OrchestrationV2ThreadLaunchWorkflows"], | |
| 206 | + [41, "ApplicationEventSource"], | |
| 207 | + [42, "OrchestrationV2EffectCancellation"], | |
| 208 | + [43, "ScheduledTasks"], | |
| 209 | + [44, "LegacyV1ImportState"], | |
| 205 | 210 | ], |
| 206 | 211 | ); |
| 207 | 212 | |
File renamed without changes.
File renamed without changes.
| @@ -8,7 +8,7 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; · 7 unmodified lines | ||
| 8 | 8 | it.effect("backfills the V2 event driver from payload before the legacy provider column", () => |
| 9 | 9 | Effect.gen(function* () { |
| 10 | 10 | const sql = yield* SqlClient.SqlClient; |
| 11 | − yield* runMigrations({ toMigrationInclusive: 36 }); | |
| 11 | + yield* runMigrations({ toMigrationInclusive: 37 }); | |
| 12 | 12 | |
| 13 | 13 | yield* sql` |
| 14 | 14 | INSERT INTO orchestration_v2_events ( |
| @@ -37,7 +37,7 @@ it.effect("backfills the V2 event driver from payload before the legacy provider · 22 unmodified lines | ||
| 37 | 37 | ) |
| 38 | 38 | `; |
| 39 | 39 | |
| 40 | − yield* runMigrations({ toMigrationInclusive: 37 }); | |
| 40 | + yield* runMigrations({ toMigrationInclusive: 38 }); | |
| 41 | 41 | |
| 42 | 42 | const events = yield* sql<{ |
| 43 | 43 | readonly event_id: string; |
File renamed without changes.
File renamed without changes.
File renamed without changes.
| @@ -12,7 +12,7 @@ layer("039_ApplicationEventSource", (it) => { · 11 unmodified lines | ||
| 12 | 12 | it.effect("moves V2 events and current project state behind one global sequence", () => |
| 13 | 13 | Effect.gen(function* () { |
| 14 | 14 | const sql = yield* SqlClient.SqlClient; |
| 15 | − yield* runMigrations({ toMigrationInclusive: 38 }); | |
| 15 | + yield* runMigrations({ toMigrationInclusive: 40 }); | |
| 16 | 16 | |
| 17 | 17 | yield* sql` |
| 18 | 18 | INSERT INTO orchestration_v2_events ( |
| @@ -84,7 +84,7 @@ layer("039_ApplicationEventSource", (it) => { · 65 unmodified lines | ||
| 84 | 84 | ) |
| 85 | 85 | `; |
| 86 | 86 | |
| 87 | − yield* runMigrations({ toMigrationInclusive: 39 }); | |
| 87 | + yield* runMigrations({ toMigrationInclusive: 41 }); | |
| 88 | 88 | |
| 89 | 89 | const events = yield* sql<{ |
| 90 | 90 | readonly sequence: number; |
File renamed without changes.
| @@ -12,7 +12,7 @@ layer("040_OrchestrationV2EffectCancellation", (it) => { · 11 unmodified lines | ||
| 12 | 12 | it.effect("preserves existing effects and adds the cancelled terminal status", () => |
| 13 | 13 | Effect.gen(function* () { |
| 14 | 14 | const sql = yield* SqlClient.SqlClient; |
| 15 | − yield* runMigrations({ toMigrationInclusive: 39 }); | |
| 15 | + yield* runMigrations({ toMigrationInclusive: 41 }); | |
| 16 | 16 | yield* sql` |
| 17 | 17 | INSERT INTO orchestration_v2_effect_outbox ( |
| 18 | 18 | effect_id, |
| @@ -47,7 +47,7 @@ layer("040_OrchestrationV2EffectCancellation", (it) => { · 28 unmodified lines | ||
| 47 | 47 | ) |
| 48 | 48 | `; |
| 49 | 49 | |
| 50 | − yield* runMigrations({ toMigrationInclusive: 40 }); | |
| 50 | + yield* runMigrations({ toMigrationInclusive: 42 }); | |
| 51 | 51 | yield* sql` |
| 52 | 52 | UPDATE orchestration_v2_effect_outbox |
| 53 | 53 | SET |
File renamed without changes.
File renamed without changes.
File renamed without changes.
| @@ -84,6 +84,8 @@ export interface UpdateThreadMetadataInput extends ThreadCommandInput { · 83 unmodified lines | ||
| 84 | 84 | readonly modelSelection?: ModelSelection; |
| 85 | 85 | readonly branch?: string | null; |
| 86 | 86 | readonly worktreePath?: string | null; |
| 87 | + /** Kick off an async title regeneration for the thread. */ | |
| 88 | + readonly regenerateTitle?: boolean; | |
| 87 | 89 | } |
| 88 | 90 | |
| 89 | 91 | export interface SetThreadRuntimeModeInput extends ThreadCommandInput { |
| @@ -418,7 +420,8 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM · 328 unmodified lines | ||
| 418 | 420 | if ( |
| 419 | 421 | input.title !== undefined || |
| 420 | 422 | input.branch !== undefined || |
| 421 | − input.worktreePath !== undefined | |
| 423 | + input.worktreePath !== undefined || | |
| 424 | + input.regenerateTitle !== undefined | |
| 422 | 425 | ) { |
| 423 | 426 | result = yield* dispatch({ |
| 424 | 427 | type: "thread.metadata.update", |
| @@ -427,6 +430,7 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM · 2 unmodified lines | ||
| 427 | 430 | ...(input.title === undefined ? {} : { title: input.title }), |
| 428 | 431 | ...(input.branch === undefined ? {} : { branch: input.branch }), |
| 429 | 432 | ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }), |
| 433 | + ...(input.regenerateTitle === undefined ? {} : { regenerateTitle: input.regenerateTitle }), | |
| 430 | 434 | }); |
| 431 | 435 | } |
| 432 | 436 | if (input.modelSelection !== undefined) { |
| @@ -101,6 +101,8 @@ export interface EnvironmentThreadShell { · 100 unmodified lines | ||
| 101 | 101 | * local visited state they keep. |
| 102 | 102 | */ |
| 103 | 103 | readonly lastVisitedAt?: string | null; |
| 104 | + /** Pending title regeneration marker; null when no request is in flight. */ | |
| 105 | + readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null; | |
| 104 | 106 | readonly deletedAt: string | null; |
| 105 | 107 | readonly source: OrchestrationV2ThreadShell; |
| 106 | 108 | } |
| @@ -198,6 +200,13 @@ export function presentThreadShell( · 91 unmodified lines | ||
| 198 | 200 | ...(thread.lastVisitedAt === undefined |
| 199 | 201 | ? {} |
| 200 | 202 | : { lastVisitedAt: nullableIso(thread.lastVisitedAt) }), |
| 203 | + titleRegeneration: | |
| 204 | + thread.titleRegeneration == null | |
| 205 | + ? null | |
| 206 | + : { | |
| 207 | + requestId: thread.titleRegeneration.requestId, | |
| 208 | + startedAt: iso(thread.titleRegeneration.startedAt), | |
| 209 | + }, | |
| 201 | 210 | deletedAt: nullableIso(thread.deletedAt), |
| 202 | 211 | source: thread, |
| 203 | 212 | }; |
| @@ -326,6 +326,15 @@ export const OrchestrationV2AppThread = Schema.Struct({ · 325 unmodified lines | ||
| 326 | 326 | lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe( |
| 327 | 327 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 328 | 328 | ), |
| 329 | + /** In-flight title regeneration marker; cleared when a new title lands. */ | |
| 330 | + titleRegeneration: Schema.optional( | |
| 331 | + Schema.NullOr( | |
| 332 | + Schema.Struct({ | |
| 333 | + requestId: CommandId, | |
| 334 | + startedAt: Schema.DateTimeUtc, | |
| 335 | + }), | |
| 336 | + ), | |
| 337 | + ), | |
| 329 | 338 | deletedAt: Schema.NullOr(Schema.DateTimeUtc), |
| 330 | 339 | }); |
| 331 | 340 | export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type; |
| @@ -1222,6 +1231,15 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ · 890 unmodified lines | ||
| 1222 | 1231 | * back to their local visited state when the field is absent. |
| 1223 | 1232 | */ |
| 1224 | 1233 | lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), |
| 1234 | + /** In-flight title regeneration marker; null/absent when no request is pending. */ | |
| 1235 | + titleRegeneration: Schema.optional( | |
| 1236 | + Schema.NullOr( | |
| 1237 | + Schema.Struct({ | |
| 1238 | + requestId: CommandId, | |
| 1239 | + startedAt: Schema.DateTimeUtc, | |
| 1240 | + }), | |
| 1241 | + ), | |
| 1242 | + ), | |
| 1225 | 1243 | deletedAt: Schema.NullOr(Schema.DateTimeUtc), |
| 1226 | 1244 | }); |
| 1227 | 1245 | export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type; |
| @@ -1296,6 +1314,14 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( · 68 unmodified lines | ||
| 1296 | 1314 | lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe( |
| 1297 | 1315 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1298 | 1316 | ), |
| 1317 | + titleRegeneration: Schema.optional( | |
| 1318 | + Schema.NullOr( | |
| 1319 | + Schema.Struct({ | |
| 1320 | + requestId: CommandId, | |
| 1321 | + startedAt: Schema.DateTimeUtcFromString, | |
| 1322 | + }), | |
| 1323 | + ), | |
| 1324 | + ), | |
| 1299 | 1325 | deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString), |
| 1300 | 1326 | })); |
| 1301 | 1327 | export type OrchestrationV2AppThreadJson = typeof OrchestrationV2AppThreadJson.Type; |
| @@ -1903,6 +1929,8 @@ export const OrchestrationV2Command = Schema.Union([ · 601 unmodified lines | ||
| 1903 | 1929 | commandId: CommandId, |
| 1904 | 1930 | threadId: ThreadId, |
| 1905 | 1931 | title: Schema.optional(TrimmedNonEmptyString), |
| 1932 | + /** Kick off (true) or abandon (false) an async title regeneration. */ | |
| 1933 | + regenerateTitle: Schema.optional(Schema.Boolean), | |
| 1906 | 1934 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1907 | 1935 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1908 | 1936 | expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |