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 | threadSettlement: true, | 146 | threadSettlement: true, |
| 147 | threadSnooze: true, | 147 | threadSnooze: true, |
| 148 | threadVisitedTracking: true, | 148 | threadVisitedTracking: true, |
| 149 | threadTitleRegeneration: true, | ||
| 149 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), | 150 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 150 | ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), | 151 | ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), |
| 151 | }, | 152 | }, |
| @@ -1138,6 +1138,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1137 unmodified lines | |||
| 1138 | ...(command.title === undefined ? {} : { title: command.title }), | 1138 | ...(command.title === undefined ? {} : { title: command.title }), |
| 1139 | ...(command.branch === undefined ? {} : { branch: command.branch }), | 1139 | ...(command.branch === undefined ? {} : { branch: command.branch }), |
| 1140 | ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), | 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 | updatedAt: now, | 1148 | updatedAt: now, |
| 1142 | }; | 1149 | }; |
| 1143 | case "thread.runtime-mode.set": | 1150 | case "thread.runtime-mode.set": |
| @@ -843,6 +843,7 @@ export function threadShellFromProjection( · 842 unmodified lines | |||
| 843 | snoozedUntil: projection.thread.snoozedUntil ?? null, | 843 | snoozedUntil: projection.thread.snoozedUntil ?? null, |
| 844 | snoozedAt: projection.thread.snoozedAt ?? null, | 844 | snoozedAt: projection.thread.snoozedAt ?? null, |
| 845 | lastVisitedAt: projection.thread.lastVisitedAt, | 845 | lastVisitedAt: projection.thread.lastVisitedAt, |
| 846 | titleRegeneration: projection.thread.titleRegeneration ?? null, | ||
| 846 | deletedAt: projection.thread.deletedAt, | 847 | deletedAt: projection.thread.deletedAt, |
| 847 | }; | 848 | }; |
| 848 | } | 849 | } |
| @@ -1011,6 +1012,7 @@ function shellFromState(input: { · 162 unmodified lines | |||
| 1011 | snoozedUntil: input.state.thread.snoozedUntil ?? null, | 1012 | snoozedUntil: input.state.thread.snoozedUntil ?? null, |
| 1012 | snoozedAt: input.state.thread.snoozedAt ?? null, | 1013 | snoozedAt: input.state.thread.snoozedAt ?? null, |
| 1013 | lastVisitedAt: input.state.thread.lastVisitedAt, | 1014 | lastVisitedAt: input.state.thread.lastVisitedAt, |
| 1015 | titleRegeneration: input.state.thread.titleRegeneration ?? null, | ||
| 1014 | deletedAt: input.state.thread.deletedAt, | 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 | import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./ProviderAdapterRegistry.ts"; | 27 | import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./ProviderAdapterRegistry.ts"; |
| 28 | import { layer as providerContinuationRequestsLayer } from "./ProviderContinuationRequests.ts"; | 28 | import { layer as providerContinuationRequestsLayer } from "./ProviderContinuationRequests.ts"; |
| 29 | import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts"; | 29 | import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts"; |
| 30 | import { workerLive as threadTitleRegenerationWorkerLive } from "./ThreadTitleRegenerationService.ts"; | ||
| 30 | import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts"; | 31 | import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts"; |
| 31 | import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts"; | 32 | import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts"; |
| 32 | import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts"; | 33 | import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts"; |
| @@ -243,6 +244,9 @@ const providerContinuationWorkerProvided = providerContinuationWorkerLive.pipe( · 210 unmodified lines | |||
| 243 | Layer.mergeAll(providerContinuationRequestsLayer, threadManagementProvided, idAllocatorLayer), | 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 | export const OrchestrationV2LayerLive = Layer.mergeAll( | 251 | export const OrchestrationV2LayerLive = Layer.mergeAll( |
| 248 | orchestratorProvided, | 252 | orchestratorProvided, |
| @@ -257,6 +261,7 @@ export const OrchestrationV2LayerLive = Layer.mergeAll( · 8 unmodified lines | |||
| 257 | export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( | 261 | export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( |
| 258 | OrchestrationLayerLive, | 262 | OrchestrationLayerLive, |
| 259 | OrchestrationV2LayerLive, | 263 | OrchestrationV2LayerLive, |
| 264 | threadTitleRegenerationWorkerProvided, | ||
| 260 | ProjectServiceLayerLive, | 265 | ProjectServiceLayerLive, |
| 261 | threadLaunchProvided, | 266 | threadLaunchProvided, |
| 262 | threadLifecycleProvided, | 267 | threadLifecycleProvided, |
| @@ -47,15 +47,16 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; · 46 unmodified lines | |||
| 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; | 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; |
| 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; | 48 | import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; |
| 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; | 49 | import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; |
| 50 | import Migration0035 from "./Migrations/035_OrchestrationV2.ts"; | 50 | import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; |
| 51 | import Migration0036 from "./Migrations/036_OrchestrationV2Subagents.ts"; | 51 | import Migration0036 from "./Migrations/036_OrchestrationV2.ts"; |
| 52 | import Migration0037 from "./Migrations/037_OrchestrationV2Foundation.ts"; | 52 | import Migration0037 from "./Migrations/037_OrchestrationV2Subagents.ts"; |
| 53 | import Migration0038 from "./Migrations/038_OrchestrationV2ProviderSessionBindings.ts"; | 53 | import Migration0038 from "./Migrations/038_OrchestrationV2Foundation.ts"; |
| 54 | import Migration0039 from "./Migrations/039_OrchestrationV2ThreadLaunchWorkflows.ts"; | 54 | import Migration0039 from "./Migrations/039_OrchestrationV2ProviderSessionBindings.ts"; |
| 55 | import Migration0040 from "./Migrations/040_ApplicationEventSource.ts"; | 55 | import Migration0040 from "./Migrations/040_OrchestrationV2ThreadLaunchWorkflows.ts"; |
| 56 | import Migration0041 from "./Migrations/041_OrchestrationV2EffectCancellation.ts"; | 56 | import Migration0041 from "./Migrations/041_ApplicationEventSource.ts"; |
| 57 | import Migration0042 from "./Migrations/042_ScheduledTasks.ts"; | 57 | import Migration0042 from "./Migrations/042_OrchestrationV2EffectCancellation.ts"; |
| 58 | import Migration0043 from "./Migrations/043_LegacyV1ImportState.ts"; | 58 | import Migration0043 from "./Migrations/043_ScheduledTasks.ts"; |
| 59 | import Migration0044 from "./Migrations/044_LegacyV1ImportState.ts"; | ||
| 59 | 60 | ||
| 60 | /** | 61 | /** |
| 61 | * Migration loader with all migrations defined inline. | 62 | * Migration loader with all migrations defined inline. |
| @@ -102,15 +103,16 @@ export const migrationEntries = [ · 40 unmodified lines | |||
| 102 | [32, "AuthPairingProofKeyThumbprint", Migration0032], | 103 | [32, "AuthPairingProofKeyThumbprint", Migration0032], |
| 103 | [33, "ProjectionThreadsSettled", Migration0033], | 104 | [33, "ProjectionThreadsSettled", Migration0033], |
| 104 | [34, "ProjectionThreadsSnoozed", Migration0034], | 105 | [34, "ProjectionThreadsSnoozed", Migration0034], |
| 105 | [35, "OrchestrationV2", Migration0035], | 106 | [35, "ProjectionThreadTitleRegeneration", Migration0035], |
| 106 | [36, "OrchestrationV2Subagents", Migration0036], | 107 | [36, "OrchestrationV2", Migration0036], |
| 107 | [37, "OrchestrationV2Foundation", Migration0037], | 108 | [37, "OrchestrationV2Subagents", Migration0037], |
| 108 | [38, "OrchestrationV2ProviderSessionBindings", Migration0038], | 109 | [38, "OrchestrationV2Foundation", Migration0038], |
| 109 | [39, "OrchestrationV2ThreadLaunchWorkflows", Migration0039], | 110 | [39, "OrchestrationV2ProviderSessionBindings", Migration0039], |
| 110 | [40, "ApplicationEventSource", Migration0040], | 111 | [40, "OrchestrationV2ThreadLaunchWorkflows", Migration0040], |
| 111 | [41, "OrchestrationV2EffectCancellation", Migration0041], | 112 | [41, "ApplicationEventSource", Migration0041], |
| 112 | [42, "ScheduledTasks", Migration0042], | 113 | [42, "OrchestrationV2EffectCancellation", Migration0042], |
| 113 | [43, "LegacyV1ImportState", Migration0043], | 114 | [43, "ScheduledTasks", Migration0043], |
| 115 | [44, "LegacyV1ImportState", Migration0044], | ||
| 114 | ] as const; | 116 | ] as const; |
| 115 | 117 | ||
| 116 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 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 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | 9 | const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); |
| 10 | 10 | ||
| 11 | layer("035_036_OrchestrationV2", (it) => { | 11 | layer("036_037_OrchestrationV2", (it) => { |
| 12 | it.effect("keeps released and private migration ids contiguous", () => | 12 | it.effect("keeps released and private migration ids contiguous", () => |
| 13 | Effect.sync(() => { | 13 | Effect.sync(() => { |
| 14 | assert.deepStrictEqual( | 14 | assert.deepStrictEqual( |
| @@ -22,7 +22,7 @@ layer("035_036_OrchestrationV2", (it) => { · 7 unmodified lines | |||
| 22 | Effect.gen(function* () { | 22 | Effect.gen(function* () { |
| 23 | const sql = yield* SqlClient.SqlClient; | 23 | const sql = yield* SqlClient.SqlClient; |
| 24 | 24 | ||
| 25 | yield* runMigrations({ toMigrationInclusive: 35 }); | 25 | yield* runMigrations({ toMigrationInclusive: 37 }); |
| 26 | 26 | ||
| 27 | const migrations = yield* sql<{ | 27 | const migrations = yield* sql<{ |
| 28 | readonly migration_id: number; | 28 | readonly migration_id: number; |
| @@ -30,20 +30,24 @@ layer("035_036_OrchestrationV2", (it) => { · 1 unmodified line | |||
| 30 | }>` | 30 | }>` |
| 31 | SELECT migration_id, name | 31 | SELECT migration_id, name |
| 32 | FROM effect_sql_migrations | 32 | FROM effect_sql_migrations |
| 33 | WHERE migration_id IN (33, 34, 35) | 33 | WHERE migration_id IN (34, 35, 36, 37) |
| 34 | ORDER BY migration_id | 34 | ORDER BY migration_id |
| 35 | `; | 35 | `; |
| 36 | assert.deepStrictEqual(migrations, [ | 36 | assert.deepStrictEqual(migrations, [ |
| 37 | { | 37 | { |
| 38 | migration_id: 33, | 38 | migration_id: 34, |
| 39 | name: "ProjectionThreadsSettled", | 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 | name: "OrchestrationV2", | 47 | name: "OrchestrationV2", |
| 44 | }, | 48 | }, |
| 45 | { | 49 | { |
| 46 | migration_id: 35, | 50 | migration_id: 37, |
| 47 | name: "OrchestrationV2Subagents", | 51 | name: "OrchestrationV2Subagents", |
| 48 | }, | 52 | }, |
| 49 | ]); | 53 | ]); |
| @@ -63,7 +67,7 @@ layer("035_036_OrchestrationV2", (it) => { · 13 unmodified lines | |||
| 63 | it.effect("backfills provider-session thread bindings in migration 036", () => | 67 | it.effect("backfills provider-session thread bindings in migration 036", () => |
| 64 | Effect.gen(function* () { | 68 | Effect.gen(function* () { |
| 65 | const sql = yield* SqlClient.SqlClient; | 69 | const sql = yield* SqlClient.SqlClient; |
| 66 | yield* runMigrations({ toMigrationInclusive: 36 }); | 70 | yield* runMigrations({ toMigrationInclusive: 38 }); |
| 67 | yield* sql` | 71 | yield* sql` |
| 68 | INSERT INTO orchestration_v2_projection_provider_sessions ( | 72 | INSERT INTO orchestration_v2_projection_provider_sessions ( |
| 69 | provider_session_id, | 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 | const bindings = yield* sql<{ | 97 | const bindings = yield* sql<{ |
| 94 | readonly provider_session_id: string; | 98 | readonly provider_session_id: string; |
| @@ -109,7 +113,7 @@ layer("035_036_OrchestrationV2", (it) => { · 14 unmodified lines | |||
| 109 | it.effect("preserves turn items with colliding ordinals in migration 037", () => | 113 | it.effect("preserves turn items with colliding ordinals in migration 037", () => |
| 110 | Effect.gen(function* () { | 114 | Effect.gen(function* () { |
| 111 | const sql = yield* SqlClient.SqlClient; | 115 | const sql = yield* SqlClient.SqlClient; |
| 112 | yield* runMigrations({ toMigrationInclusive: 36 }); | 116 | yield* runMigrations({ toMigrationInclusive: 37 }); |
| 113 | yield* sql` | 117 | yield* sql` |
| 114 | INSERT INTO orchestration_v2_projection_runs ( | 118 | INSERT INTO orchestration_v2_projection_runs ( |
| 115 | run_id, | 119 | run_id, |
| @@ -146,7 +150,7 @@ layer("035_036_OrchestrationV2", (it) => { · 30 unmodified lines | |||
| 146 | ('turn-item:d', 'thread:two', NULL, 42, 'assistant_message', 'completed', '2026-01-01T00:00:00.000Z', '{}') | 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 | const positions = yield* sql<{ | 155 | const positions = yield* sql<{ |
| 152 | readonly thread_id: string; | 156 | readonly thread_id: string; |
| @@ -171,14 +175,14 @@ it.effect("upgrades a database already at released main migration 034", () => · 18 unmodified lines | |||
| 171 | Effect.gen(function* () { | 175 | Effect.gen(function* () { |
| 172 | const sql = yield* SqlClient.SqlClient; | 176 | const sql = yield* SqlClient.SqlClient; |
| 173 | 177 | ||
| 174 | yield* runMigrations({ toMigrationInclusive: 34 }); | 178 | yield* runMigrations({ toMigrationInclusive: 35 }); |
| 175 | const snoozeColumns = yield* sql<{ readonly name: string }>` | 179 | const snoozeColumns = yield* sql<{ readonly name: string }>` |
| 176 | PRAGMA table_info(projection_threads) | 180 | PRAGMA table_info(projection_threads) |
| 177 | `; | 181 | `; |
| 178 | assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until")); | 182 | assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until")); |
| 179 | assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at")); | 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 | const migrations = yield* sql<{ | 187 | const migrations = yield* sql<{ |
| 184 | readonly migration_id: number; | 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 | SELECT migration_id, name | 191 | SELECT migration_id, name |
| 188 | FROM effect_sql_migrations | 192 | FROM effect_sql_migrations |
| 189 | WHERE migration_id BETWEEN 34 AND 43 | 193 | WHERE migration_id BETWEEN 34 AND 44 |
| 190 | ORDER BY migration_id | 194 | ORDER BY migration_id |
| 191 | `; | 195 | `; |
| 192 | assert.deepStrictEqual( | 196 | assert.deepStrictEqual( |
| 193 | migrations.map(({ migration_id, name }) => [migration_id, name]), | 197 | migrations.map(({ migration_id, name }) => [migration_id, name]), |
| 194 | [ | 198 | [ |
| 195 | [34, "ProjectionThreadsSnoozed"], | 199 | [34, "ProjectionThreadsSnoozed"], |
| 196 | [35, "OrchestrationV2"], | 200 | [35, "ProjectionThreadTitleRegeneration"], |
| 197 | [36, "OrchestrationV2Subagents"], | 201 | [36, "OrchestrationV2"], |
| 198 | [37, "OrchestrationV2Foundation"], | 202 | [37, "OrchestrationV2Subagents"], |
| 199 | [38, "OrchestrationV2ProviderSessionBindings"], | 203 | [38, "OrchestrationV2Foundation"], |
| 200 | [39, "OrchestrationV2ThreadLaunchWorkflows"], | 204 | [39, "OrchestrationV2ProviderSessionBindings"], |
| 201 | [40, "ApplicationEventSource"], | 205 | [40, "OrchestrationV2ThreadLaunchWorkflows"], |
| 202 | [41, "OrchestrationV2EffectCancellation"], | 206 | [41, "ApplicationEventSource"], |
| 203 | [42, "ScheduledTasks"], | 207 | [42, "OrchestrationV2EffectCancellation"], |
| 204 | [43, "LegacyV1ImportState"], | 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 | it.effect("backfills the V2 event driver from payload before the legacy provider column", () => | 8 | it.effect("backfills the V2 event driver from payload before the legacy provider column", () => |
| 9 | Effect.gen(function* () { | 9 | Effect.gen(function* () { |
| 10 | const sql = yield* SqlClient.SqlClient; | 10 | const sql = yield* SqlClient.SqlClient; |
| 11 | yield* runMigrations({ toMigrationInclusive: 36 }); | 11 | yield* runMigrations({ toMigrationInclusive: 37 }); |
| 12 | 12 | ||
| 13 | yield* sql` | 13 | yield* sql` |
| 14 | INSERT INTO orchestration_v2_events ( | 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 | const events = yield* sql<{ | 42 | const events = yield* sql<{ |
| 43 | readonly event_id: string; | 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 | it.effect("moves V2 events and current project state behind one global sequence", () => | 12 | it.effect("moves V2 events and current project state behind one global sequence", () => |
| 13 | Effect.gen(function* () { | 13 | Effect.gen(function* () { |
| 14 | const sql = yield* SqlClient.SqlClient; | 14 | const sql = yield* SqlClient.SqlClient; |
| 15 | yield* runMigrations({ toMigrationInclusive: 38 }); | 15 | yield* runMigrations({ toMigrationInclusive: 40 }); |
| 16 | 16 | ||
| 17 | yield* sql` | 17 | yield* sql` |
| 18 | INSERT INTO orchestration_v2_events ( | 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 | const events = yield* sql<{ | 89 | const events = yield* sql<{ |
| 90 | readonly sequence: number; | 90 | readonly sequence: number; |
File renamed without changes.
| @@ -12,7 +12,7 @@ layer("040_OrchestrationV2EffectCancellation", (it) => { · 11 unmodified lines | |||
| 12 | it.effect("preserves existing effects and adds the cancelled terminal status", () => | 12 | it.effect("preserves existing effects and adds the cancelled terminal status", () => |
| 13 | Effect.gen(function* () { | 13 | Effect.gen(function* () { |
| 14 | const sql = yield* SqlClient.SqlClient; | 14 | const sql = yield* SqlClient.SqlClient; |
| 15 | yield* runMigrations({ toMigrationInclusive: 39 }); | 15 | yield* runMigrations({ toMigrationInclusive: 41 }); |
| 16 | yield* sql` | 16 | yield* sql` |
| 17 | INSERT INTO orchestration_v2_effect_outbox ( | 17 | INSERT INTO orchestration_v2_effect_outbox ( |
| 18 | effect_id, | 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 | yield* sql` | 51 | yield* sql` |
| 52 | UPDATE orchestration_v2_effect_outbox | 52 | UPDATE orchestration_v2_effect_outbox |
| 53 | SET | 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 | readonly modelSelection?: ModelSelection; | 84 | readonly modelSelection?: ModelSelection; |
| 85 | readonly branch?: string | null; | 85 | readonly branch?: string | null; |
| 86 | readonly worktreePath?: string | null; | 86 | readonly worktreePath?: string | null; |
| 87 | /** Kick off an async title regeneration for the thread. */ | ||
| 88 | readonly regenerateTitle?: boolean; | ||
| 87 | } | 89 | } |
| 88 | 90 | ||
| 89 | export interface SetThreadRuntimeModeInput extends ThreadCommandInput { | 91 | export interface SetThreadRuntimeModeInput extends ThreadCommandInput { |
| @@ -418,7 +420,8 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM · 328 unmodified lines | |||
| 418 | if ( | 420 | if ( |
| 419 | input.title !== undefined || | 421 | input.title !== undefined || |
| 420 | input.branch !== undefined || | 422 | input.branch !== undefined || |
| 421 | input.worktreePath !== undefined | 423 | input.worktreePath !== undefined || |
| 424 | input.regenerateTitle !== undefined | ||
| 422 | ) { | 425 | ) { |
| 423 | result = yield* dispatch({ | 426 | result = yield* dispatch({ |
| 424 | type: "thread.metadata.update", | 427 | type: "thread.metadata.update", |
| @@ -427,6 +430,7 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM · 2 unmodified lines | |||
| 427 | ...(input.title === undefined ? {} : { title: input.title }), | 430 | ...(input.title === undefined ? {} : { title: input.title }), |
| 428 | ...(input.branch === undefined ? {} : { branch: input.branch }), | 431 | ...(input.branch === undefined ? {} : { branch: input.branch }), |
| 429 | ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }), | 432 | ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }), |
| 433 | ...(input.regenerateTitle === undefined ? {} : { regenerateTitle: input.regenerateTitle }), | ||
| 430 | }); | 434 | }); |
| 431 | } | 435 | } |
| 432 | if (input.modelSelection !== undefined) { | 436 | if (input.modelSelection !== undefined) { |
| @@ -101,6 +101,8 @@ export interface EnvironmentThreadShell { · 100 unmodified lines | |||
| 101 | * local visited state they keep. | 101 | * local visited state they keep. |
| 102 | */ | 102 | */ |
| 103 | readonly lastVisitedAt?: string | null; | 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 | readonly deletedAt: string | null; | 106 | readonly deletedAt: string | null; |
| 105 | readonly source: OrchestrationV2ThreadShell; | 107 | readonly source: OrchestrationV2ThreadShell; |
| 106 | } | 108 | } |
| @@ -198,6 +200,13 @@ export function presentThreadShell( · 91 unmodified lines | |||
| 198 | ...(thread.lastVisitedAt === undefined | 200 | ...(thread.lastVisitedAt === undefined |
| 199 | ? {} | 201 | ? {} |
| 200 | : { lastVisitedAt: nullableIso(thread.lastVisitedAt) }), | 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 | deletedAt: nullableIso(thread.deletedAt), | 210 | deletedAt: nullableIso(thread.deletedAt), |
| 202 | source: thread, | 211 | source: thread, |
| 203 | }; | 212 | }; |
| @@ -326,6 +326,15 @@ export const OrchestrationV2AppThread = Schema.Struct({ · 325 unmodified lines | |||
| 326 | lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe( | 326 | lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe( |
| 327 | Schema.withDecodingDefault(Effect.succeed(null)), | 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 | deletedAt: Schema.NullOr(Schema.DateTimeUtc), | 338 | deletedAt: Schema.NullOr(Schema.DateTimeUtc), |
| 330 | }); | 339 | }); |
| 331 | export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type; | 340 | export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type; |
| @@ -1222,6 +1231,15 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ · 890 unmodified lines | |||
| 1222 | * back to their local visited state when the field is absent. | 1231 | * back to their local visited state when the field is absent. |
| 1223 | */ | 1232 | */ |
| 1224 | lastVisitedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), | 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 | deletedAt: Schema.NullOr(Schema.DateTimeUtc), | 1243 | deletedAt: Schema.NullOr(Schema.DateTimeUtc), |
| 1226 | }); | 1244 | }); |
| 1227 | export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type; | 1245 | export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type; |
| @@ -1296,6 +1314,14 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( · 68 unmodified lines | |||
| 1296 | lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe( | 1314 | lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe( |
| 1297 | Schema.withDecodingDefault(Effect.succeed(null)), | 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 | deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | 1325 | deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString), |
| 1300 | })); | 1326 | })); |
| 1301 | export type OrchestrationV2AppThreadJson = typeof OrchestrationV2AppThreadJson.Type; | 1327 | export type OrchestrationV2AppThreadJson = typeof OrchestrationV2AppThreadJson.Type; |
| @@ -1903,6 +1929,8 @@ export const OrchestrationV2Command = Schema.Union([ · 601 unmodified lines | |||
| 1903 | commandId: CommandId, | 1929 | commandId: CommandId, |
| 1904 | threadId: ThreadId, | 1930 | threadId: ThreadId, |
| 1905 | title: Schema.optional(TrimmedNonEmptyString), | 1931 | title: Schema.optional(TrimmedNonEmptyString), |
| 1932 | /** Kick off (true) or abandon (false) an async title regeneration. */ | ||
| 1933 | regenerateTitle: Schema.optional(Schema.Boolean), | ||
| 1906 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 1934 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1907 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 1935 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 1908 | expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 1936 | expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |