History

feat(orchestration): port thread title regeneration to the v2 runtime

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.

Julius Marminge committed 3 months ago

Commit 353f3521 parent 607ddc322 files · +271 −48Browse files

Unified · Split

22 files
  1. apps/server/src/environment/ServerEnvironment.ts +1 −0
  2. apps/server/src/orchestration-v2/Orchestrator.ts +7 −0
  3. apps/server/src/orchestration-v2/ProjectionStore.ts +2 −0
  4. apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +160 −0
  5. apps/server/src/orchestration-v2/runtimeLayer.ts +5 −0
  6. apps/server/src/persistence/Migrations.ts +20 −18
  7. apps/server/src/persistence/Migrations/036_037_OrchestrationV2.test.ts +28 −23
  8. apps/server/src/persistence/Migrations/036_OrchestrationV2.ts +0 −0
  9. apps/server/src/persistence/Migrations/037_OrchestrationV2Subagents.ts +0 −0
  10. apps/server/src/persistence/Migrations/038_OrchestrationV2Foundation.test.ts +2 −2
  11. apps/server/src/persistence/Migrations/038_OrchestrationV2Foundation.ts +0 −0
  12. apps/server/src/persistence/Migrations/039_OrchestrationV2ProviderSessionBindings.ts +0 −0
  13. apps/server/src/persistence/Migrations/040_OrchestrationV2ThreadLaunchWorkflows.ts +0 −0
  14. apps/server/src/persistence/Migrations/041_ApplicationEventSource.test.ts +2 −2
  15. apps/server/src/persistence/Migrations/041_ApplicationEventSource.ts +0 −0
  16. apps/server/src/persistence/Migrations/042_OrchestrationV2EffectCancellation.test.ts +2 −2
  17. apps/server/src/persistence/Migrations/042_OrchestrationV2EffectCancellation.ts +0 −0
  18. apps/server/src/persistence/Migrations/043_ScheduledTasks.ts +0 −0
  19. apps/server/src/persistence/Migrations/044_LegacyV1ImportState.ts +0 −0
  20. packages/client-runtime/src/operations/commands.ts +5 −1
  21. packages/client-runtime/src/state/models.ts +9 −0
  22. packages/contracts/src/orchestrationV2.ts +28 −0
apps/server/src/environment/ServerEnvironment.ts+1−0
@@ -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 },
apps/server/src/orchestration-v2/Orchestrator.ts+7−0
@@ -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":
apps/server/src/orchestration-v2/ProjectionStore.ts+2−0
@@ -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}
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts+160−0
@@ -0,0 +1,160 @@
1import { type ChatAttachment, CommandId, type ThreadId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6
7import * as ProjectService from "../project/ProjectService.ts";
8import * as TextGeneration from "../textGeneration/TextGeneration.ts";
9import { ThreadManagementService } from "./ThreadManagementService.ts";
10
11const MAX_REGENERATION_ATTACHMENTS = 4;
12const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;
13const 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 */
21export 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 */
79export 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);
apps/server/src/orchestration-v2/runtimeLayer.ts+5−0
@@ -27,6 +27,7 @@ import { layer as projectionMaintenanceLayer } from "./ProjectionMaintenance.ts" · 26 unmodified lines
27import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./ProviderAdapterRegistry.ts";27import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./ProviderAdapterRegistry.ts";
28import { layer as providerContinuationRequestsLayer } from "./ProviderContinuationRequests.ts";28import { layer as providerContinuationRequestsLayer } from "./ProviderContinuationRequests.ts";
29import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts";29import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts";
30import { workerLive as threadTitleRegenerationWorkerLive } from "./ThreadTitleRegenerationService.ts";
30import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts";31import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts";
31import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts";32import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts";
32import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts";33import { 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);
247const threadTitleRegenerationWorkerProvided = threadTitleRegenerationWorkerLive.pipe(
248 Layer.provide(Layer.merge(threadManagementProvided, ProjectServiceLayerLive)),
249);
246 250
247export const OrchestrationV2LayerLive = Layer.mergeAll(251export const OrchestrationV2LayerLive = Layer.mergeAll(
248 orchestratorProvided,252 orchestratorProvided,
@@ -257,6 +261,7 @@ export const OrchestrationV2LayerLive = Layer.mergeAll( · 8 unmodified lines
257export const OrchestrationV2ProductionLayerLive = Layer.mergeAll(261export 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,
apps/server/src/persistence/Migrations.ts+20−18
@@ -47,15 +47,16 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; · 46 unmodified lines
47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";48import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";49import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
50import Migration0035 from "./Migrations/035_OrchestrationV2.ts";50import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";
51import Migration0036 from "./Migrations/036_OrchestrationV2Subagents.ts";51import Migration0036 from "./Migrations/036_OrchestrationV2.ts";
52import Migration0037 from "./Migrations/037_OrchestrationV2Foundation.ts";52import Migration0037 from "./Migrations/037_OrchestrationV2Subagents.ts";
53import Migration0038 from "./Migrations/038_OrchestrationV2ProviderSessionBindings.ts";53import Migration0038 from "./Migrations/038_OrchestrationV2Foundation.ts";
54import Migration0039 from "./Migrations/039_OrchestrationV2ThreadLaunchWorkflows.ts";54import Migration0039 from "./Migrations/039_OrchestrationV2ProviderSessionBindings.ts";
55import Migration0040 from "./Migrations/040_ApplicationEventSource.ts";55import Migration0040 from "./Migrations/040_OrchestrationV2ThreadLaunchWorkflows.ts";
56import Migration0041 from "./Migrations/041_OrchestrationV2EffectCancellation.ts";56import Migration0041 from "./Migrations/041_ApplicationEventSource.ts";
57import Migration0042 from "./Migrations/042_ScheduledTasks.ts";57import Migration0042 from "./Migrations/042_OrchestrationV2EffectCancellation.ts";
58import Migration0043 from "./Migrations/043_LegacyV1ImportState.ts";58import Migration0043 from "./Migrations/043_ScheduledTasks.ts";
59import 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
116export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);118export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/036_037_OrchestrationV2.test.tsrenamed from apps/server/src/persistence/Migrations/034_035_OrchestrationV2.test.ts+28−23
@@ -8,7 +8,7 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; · 7 unmodified lines
8 8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10 10
11layer("035_036_OrchestrationV2", (it) => {11layer("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, name31 SELECT migration_id, name
32 FROM effect_sql_migrations32 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_id34 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, name191 SELECT migration_id, name
188 FROM effect_sql_migrations192 FROM effect_sql_migrations
189 WHERE migration_id BETWEEN 34 AND 43193 WHERE migration_id BETWEEN 34 AND 44
190 ORDER BY migration_id194 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
apps/server/src/persistence/Migrations/036_OrchestrationV2.tsrenamed from apps/server/src/persistence/Migrations/034_OrchestrationV2.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/037_OrchestrationV2Subagents.tsrenamed from apps/server/src/persistence/Migrations/035_OrchestrationV2Subagents.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/038_OrchestrationV2Foundation.test.tsrenamed from apps/server/src/persistence/Migrations/037_OrchestrationV2Foundation.test.ts+2−2
@@ -8,7 +8,7 @@ import * as NodeSqliteClient from "../NodeSqliteClient.ts"; · 7 unmodified lines
8it.effect("backfills the V2 event driver from payload before the legacy provider column", () =>8it.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;
apps/server/src/persistence/Migrations/038_OrchestrationV2Foundation.tsrenamed from apps/server/src/persistence/Migrations/036_OrchestrationV2Foundation.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/039_OrchestrationV2ProviderSessionBindings.tsrenamed from apps/server/src/persistence/Migrations/037_OrchestrationV2ProviderSessionBindings.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/040_OrchestrationV2ThreadLaunchWorkflows.tsrenamed from apps/server/src/persistence/Migrations/038_OrchestrationV2ThreadLaunchWorkflows.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/041_ApplicationEventSource.test.tsrenamed from apps/server/src/persistence/Migrations/039_ApplicationEventSource.test.ts+2−2
@@ -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;
apps/server/src/persistence/Migrations/041_ApplicationEventSource.tsrenamed from apps/server/src/persistence/Migrations/039_ApplicationEventSource.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/042_OrchestrationV2EffectCancellation.test.tsrenamed from apps/server/src/persistence/Migrations/040_OrchestrationV2EffectCancellation.test.ts+2−2
@@ -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_outbox52 UPDATE orchestration_v2_effect_outbox
53 SET53 SET
apps/server/src/persistence/Migrations/042_OrchestrationV2EffectCancellation.tsrenamed from apps/server/src/persistence/Migrations/040_OrchestrationV2EffectCancellation.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/043_ScheduledTasks.tsrenamed from apps/server/src/persistence/Migrations/041_ScheduledTasks.ts+0−0

File renamed without changes.

apps/server/src/persistence/Migrations/044_LegacyV1ImportState.tsrenamed from apps/server/src/persistence/Migrations/043_LegacyV1ImportState.ts+0−0

File renamed without changes.

packages/client-runtime/src/operations/commands.ts+5−1
@@ -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
89export interface SetThreadRuntimeModeInput extends ThreadCommandInput {91export 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 !== undefined423 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) {
packages/client-runtime/src/state/models.ts+9−0
@@ -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 === undefined200 ...(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 };
packages/contracts/src/orchestrationV2.ts+28−0
@@ -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});
331export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type;340export 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});
1227export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type;1245export 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}));
1301export type OrchestrationV2AppThreadJson = typeof OrchestrationV2AppThreadJson.Type;1327export 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)),