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
146146 threadSettlement: true,
147147 threadSnooze: true,
148148 threadVisitedTracking: true,
149+ threadTitleRegeneration: true,
149150 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
150151 ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}),
151152 },
apps/server/src/orchestration-v2/Orchestrator.ts+7−0
@@ -1138,6 +1138,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1137 unmodified lines
11381138 ...(command.title === undefined ? {} : { title: command.title }),
11391139 ...(command.branch === undefined ? {} : { branch: command.branch }),
11401140 ...(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+ : {}),
11411148 updatedAt: now,
11421149 };
11431150 case "thread.runtime-mode.set":
apps/server/src/orchestration-v2/ProjectionStore.ts+2−0
@@ -843,6 +843,7 @@ export function threadShellFromProjection( · 842 unmodified lines
843843 snoozedUntil: projection.thread.snoozedUntil ?? null,
844844 snoozedAt: projection.thread.snoozedAt ?? null,
845845 lastVisitedAt: projection.thread.lastVisitedAt,
846+ titleRegeneration: projection.thread.titleRegeneration ?? null,
846847 deletedAt: projection.thread.deletedAt,
847848 };
848849 }
@@ -1011,6 +1012,7 @@ function shellFromState(input: { · 162 unmodified lines
10111012 snoozedUntil: input.state.thread.snoozedUntil ?? null,
10121013 snoozedAt: input.state.thread.snoozedAt ?? null,
10131014 lastVisitedAt: input.state.thread.lastVisitedAt,
1015+ titleRegeneration: input.state.thread.titleRegeneration ?? null,
10141016 deletedAt: input.state.thread.deletedAt,
10151017 };
10161018 }
apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts+160−0
@@ -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+);
apps/server/src/orchestration-v2/runtimeLayer.ts+5−0
@@ -27,6 +27,7 @@ import { layer as projectionMaintenanceLayer } from "./ProjectionMaintenance.ts" · 26 unmodified lines
2727 import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./ProviderAdapterRegistry.ts";
2828 import { layer as providerContinuationRequestsLayer } from "./ProviderContinuationRequests.ts";
2929 import { workerLive as providerContinuationWorkerLive } from "./ProviderContinuationService.ts";
30+import { workerLive as threadTitleRegenerationWorkerLive } from "./ThreadTitleRegenerationService.ts";
3031 import { layer as providerEventIngestorLayer } from "./ProviderEventIngestor.ts";
3132 import { layer as providerSessionManagerLayer } from "./ProviderSessionManager.ts";
3233 import { layer as providerRuntimeRecoveryLayer } from "./ProviderRuntimeRecoveryService.ts";
@@ -243,6 +244,9 @@ const providerContinuationWorkerProvided = providerContinuationWorkerLive.pipe( · 210 unmodified lines
243244 Layer.mergeAll(providerContinuationRequestsLayer, threadManagementProvided, idAllocatorLayer),
244245 ),
245246 );
247+const threadTitleRegenerationWorkerProvided = threadTitleRegenerationWorkerLive.pipe(
248+ Layer.provide(Layer.merge(threadManagementProvided, ProjectServiceLayerLive)),
249+);
246250
247251 export const OrchestrationV2LayerLive = Layer.mergeAll(
248252 orchestratorProvided,
@@ -257,6 +261,7 @@ export const OrchestrationV2LayerLive = Layer.mergeAll( · 8 unmodified lines
257261 export const OrchestrationV2ProductionLayerLive = Layer.mergeAll(
258262 OrchestrationLayerLive,
259263 OrchestrationV2LayerLive,
264+ threadTitleRegenerationWorkerProvided,
260265 ProjectServiceLayerLive,
261266 threadLaunchProvided,
262267 threadLifecycleProvided,
apps/server/src/persistence/Migrations.ts+20−18
@@ -47,15 +47,16 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; · 46 unmodified lines
4747 import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
4848 import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
4949 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";
5960
6061 /**
6162 * Migration loader with all migrations defined inline.
@@ -102,15 +103,16 @@ export const migrationEntries = [ · 40 unmodified lines
102103 [32, "AuthPairingProofKeyThumbprint", Migration0032],
103104 [33, "ProjectionThreadsSettled", Migration0033],
104105 [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],
114116 ] as const;
115117
116118 export 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
88
99 const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
11−layer("035_036_OrchestrationV2", (it) => {
11+layer("036_037_OrchestrationV2", (it) => {
1212 it.effect("keeps released and private migration ids contiguous", () =>
1313 Effect.sync(() => {
1414 assert.deepStrictEqual(
@@ -22,7 +22,7 @@ layer("035_036_OrchestrationV2", (it) => { · 7 unmodified lines
2222 Effect.gen(function* () {
2323 const sql = yield* SqlClient.SqlClient;
2424
25− yield* runMigrations({ toMigrationInclusive: 35 });
25+ yield* runMigrations({ toMigrationInclusive: 37 });
2626
2727 const migrations = yield* sql<{
2828 readonly migration_id: number;
@@ -30,20 +30,24 @@ layer("035_036_OrchestrationV2", (it) => { · 1 unmodified line
3030 }>`
3131 SELECT migration_id, name
3232 FROM effect_sql_migrations
33− WHERE migration_id IN (33, 34, 35)
33+ WHERE migration_id IN (34, 35, 36, 37)
3434 ORDER BY migration_id
3535 `;
3636 assert.deepStrictEqual(migrations, [
3737 {
38− migration_id: 33,
39− name: "ProjectionThreadsSettled",
38+ migration_id: 34,
39+ name: "ProjectionThreadsSnoozed",
4040 },
4141 {
42− migration_id: 34,
42+ migration_id: 35,
43+ name: "ProjectionThreadTitleRegeneration",
44+ },
45+ {
46+ migration_id: 36,
4347 name: "OrchestrationV2",
4448 },
4549 {
46− migration_id: 35,
50+ migration_id: 37,
4751 name: "OrchestrationV2Subagents",
4852 },
4953 ]);
@@ -63,7 +67,7 @@ layer("035_036_OrchestrationV2", (it) => { · 13 unmodified lines
6367 it.effect("backfills provider-session thread bindings in migration 036", () =>
6468 Effect.gen(function* () {
6569 const sql = yield* SqlClient.SqlClient;
66− yield* runMigrations({ toMigrationInclusive: 36 });
70+ yield* runMigrations({ toMigrationInclusive: 38 });
6771 yield* sql`
6872 INSERT INTO orchestration_v2_projection_provider_sessions (
6973 provider_session_id,
@@ -88,7 +92,7 @@ layer("035_036_OrchestrationV2", (it) => { · 18 unmodified lines
8892 )
8993 `;
9094
91− yield* runMigrations({ toMigrationInclusive: 37 });
95+ yield* runMigrations({ toMigrationInclusive: 39 });
9296
9397 const bindings = yield* sql<{
9498 readonly provider_session_id: string;
@@ -109,7 +113,7 @@ layer("035_036_OrchestrationV2", (it) => { · 14 unmodified lines
109113 it.effect("preserves turn items with colliding ordinals in migration 037", () =>
110114 Effect.gen(function* () {
111115 const sql = yield* SqlClient.SqlClient;
112− yield* runMigrations({ toMigrationInclusive: 36 });
116+ yield* runMigrations({ toMigrationInclusive: 37 });
113117 yield* sql`
114118 INSERT INTO orchestration_v2_projection_runs (
115119 run_id,
@@ -146,7 +150,7 @@ layer("035_036_OrchestrationV2", (it) => { · 30 unmodified lines
146150 ('turn-item:d', 'thread:two', NULL, 42, 'assistant_message', 'completed', '2026-01-01T00:00:00.000Z', '{}')
147151 `;
148152
149− yield* runMigrations({ toMigrationInclusive: 37 });
153+ yield* runMigrations({ toMigrationInclusive: 38 });
150154
151155 const positions = yield* sql<{
152156 readonly thread_id: string;
@@ -171,14 +175,14 @@ it.effect("upgrades a database already at released main migration 034", () => · 18 unmodified lines
171175 Effect.gen(function* () {
172176 const sql = yield* SqlClient.SqlClient;
173177
174− yield* runMigrations({ toMigrationInclusive: 34 });
178+ yield* runMigrations({ toMigrationInclusive: 35 });
175179 const snoozeColumns = yield* sql<{ readonly name: string }>`
176180 PRAGMA table_info(projection_threads)
177181 `;
178182 assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until"));
179183 assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at"));
180184
181− yield* runMigrations({ toMigrationInclusive: 43 });
185+ yield* runMigrations({ toMigrationInclusive: 44 });
182186
183187 const migrations = yield* sql<{
184188 readonly migration_id: number;
@@ -186,22 +190,23 @@ it.effect("upgrades a database already at released main migration 034", () => · 1 unmodified line
186190 }>`
187191 SELECT migration_id, name
188192 FROM effect_sql_migrations
189− WHERE migration_id BETWEEN 34 AND 43
193+ WHERE migration_id BETWEEN 34 AND 44
190194 ORDER BY migration_id
191195 `;
192196 assert.deepStrictEqual(
193197 migrations.map(({ migration_id, name }) => [migration_id, name]),
194198 [
195199 [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"],
205210 ],
206211 );
207212
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
88 it.effect("backfills the V2 event driver from payload before the legacy provider column", () =>
99 Effect.gen(function* () {
1010 const sql = yield* SqlClient.SqlClient;
11− yield* runMigrations({ toMigrationInclusive: 36 });
11+ yield* runMigrations({ toMigrationInclusive: 37 });
1212
1313 yield* sql`
1414 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
3737 )
3838 `;
3939
40− yield* runMigrations({ toMigrationInclusive: 37 });
40+ yield* runMigrations({ toMigrationInclusive: 38 });
4141
4242 const events = yield* sql<{
4343 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
1212 it.effect("moves V2 events and current project state behind one global sequence", () =>
1313 Effect.gen(function* () {
1414 const sql = yield* SqlClient.SqlClient;
15− yield* runMigrations({ toMigrationInclusive: 38 });
15+ yield* runMigrations({ toMigrationInclusive: 40 });
1616
1717 yield* sql`
1818 INSERT INTO orchestration_v2_events (
@@ -84,7 +84,7 @@ layer("039_ApplicationEventSource", (it) => { · 65 unmodified lines
8484 )
8585 `;
8686
87− yield* runMigrations({ toMigrationInclusive: 39 });
87+ yield* runMigrations({ toMigrationInclusive: 41 });
8888
8989 const events = yield* sql<{
9090 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
1212 it.effect("preserves existing effects and adds the cancelled terminal status", () =>
1313 Effect.gen(function* () {
1414 const sql = yield* SqlClient.SqlClient;
15− yield* runMigrations({ toMigrationInclusive: 39 });
15+ yield* runMigrations({ toMigrationInclusive: 41 });
1616 yield* sql`
1717 INSERT INTO orchestration_v2_effect_outbox (
1818 effect_id,
@@ -47,7 +47,7 @@ layer("040_OrchestrationV2EffectCancellation", (it) => { · 28 unmodified lines
4747 )
4848 `;
4949
50− yield* runMigrations({ toMigrationInclusive: 40 });
50+ yield* runMigrations({ toMigrationInclusive: 42 });
5151 yield* sql`
5252 UPDATE orchestration_v2_effect_outbox
5353 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
8484 readonly modelSelection?: ModelSelection;
8585 readonly branch?: string | null;
8686 readonly worktreePath?: string | null;
87+ /** Kick off an async title regeneration for the thread. */
88+ readonly regenerateTitle?: boolean;
8789 }
8890
8991 export interface SetThreadRuntimeModeInput extends ThreadCommandInput {
@@ -418,7 +420,8 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM · 328 unmodified lines
418420 if (
419421 input.title !== undefined ||
420422 input.branch !== undefined ||
421− input.worktreePath !== undefined
423+ input.worktreePath !== undefined ||
424+ input.regenerateTitle !== undefined
422425 ) {
423426 result = yield* dispatch({
424427 type: "thread.metadata.update",
@@ -427,6 +430,7 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM · 2 unmodified lines
427430 ...(input.title === undefined ? {} : { title: input.title }),
428431 ...(input.branch === undefined ? {} : { branch: input.branch }),
429432 ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }),
433+ ...(input.regenerateTitle === undefined ? {} : { regenerateTitle: input.regenerateTitle }),
430434 });
431435 }
432436 if (input.modelSelection !== undefined) {
packages/client-runtime/src/state/models.ts+9−0
@@ -101,6 +101,8 @@ export interface EnvironmentThreadShell { · 100 unmodified lines
101101 * local visited state they keep.
102102 */
103103 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;
104106 readonly deletedAt: string | null;
105107 readonly source: OrchestrationV2ThreadShell;
106108 }
@@ -198,6 +200,13 @@ export function presentThreadShell( · 91 unmodified lines
198200 ...(thread.lastVisitedAt === undefined
199201 ? {}
200202 : { 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+ },
201210 deletedAt: nullableIso(thread.deletedAt),
202211 source: thread,
203212 };
packages/contracts/src/orchestrationV2.ts+28−0
@@ -326,6 +326,15 @@ export const OrchestrationV2AppThread = Schema.Struct({ · 325 unmodified lines
326326 lastVisitedAt: Schema.NullOr(Schema.DateTimeUtc).pipe(
327327 Schema.withDecodingDefault(Effect.succeed(null)),
328328 ),
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+ ),
329338 deletedAt: Schema.NullOr(Schema.DateTimeUtc),
330339 });
331340 export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type;
@@ -1222,6 +1231,15 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ · 890 unmodified lines
12221231 * back to their local visited state when the field is absent.
12231232 */
12241233 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+ ),
12251243 deletedAt: Schema.NullOr(Schema.DateTimeUtc),
12261244 });
12271245 export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type;
@@ -1296,6 +1314,14 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( · 68 unmodified lines
12961314 lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe(
12971315 Schema.withDecodingDefault(Effect.succeed(null)),
12981316 ),
1317+ titleRegeneration: Schema.optional(
1318+ Schema.NullOr(
1319+ Schema.Struct({
1320+ requestId: CommandId,
1321+ startedAt: Schema.DateTimeUtcFromString,
1322+ }),
1323+ ),
1324+ ),
12991325 deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
13001326 }));
13011327 export type OrchestrationV2AppThreadJson = typeof OrchestrationV2AppThreadJson.Type;
@@ -1903,6 +1929,8 @@ export const OrchestrationV2Command = Schema.Union([ · 601 unmodified lines
19031929 commandId: CommandId,
19041930 threadId: ThreadId,
19051931 title: Schema.optional(TrimmedNonEmptyString),
1932+ /** Kick off (true) or abandon (false) an async title regeneration. */
1933+ regenerateTitle: Schema.optional(Schema.Boolean),
19061934 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
19071935 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
19081936 expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),