Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";

import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor";
import {
Expand Down Expand Up @@ -44,7 +45,6 @@ import {
} from "../../state/use-composer-drafts";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
Expand Down Expand Up @@ -853,7 +853,10 @@ export function NewTaskDraftScreen(props: {
// -only Activity start. If creation fails, the token registration's replay
// finds no work and ends the card within seconds.
armAgentAwarenessLiveActivityForLocalWork({
threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
threadTitle: deriveThreadTitleSeed({
text: initialMessageText,
attachments: draft.attachments,
}),
projectTitle: selectedProject.title,
});
const result = await createProjectThread({
Expand Down
13 changes: 2 additions & 11 deletions apps/mobile/src/lib/projectThreadStartTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,10 @@ import {
type ProviderInteractionMode,
type RuntimeMode,
} from "@t3tools/contracts";
import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";

import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages";

export function deriveThreadTitleFromPrompt(value: string): string {
const trimmed = value.trim();
if (trimmed.length === 0) {
return "New thread";
}

const compact = trimmed.replace(/\s+/g, " ");
return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`;
}

export interface ProjectThreadStartTurnSpec {
readonly projectId: ProjectId;
readonly projectCwd: string;
Expand All @@ -46,7 +37,7 @@ export interface ProjectThreadStartTurnSpec {
* offline outbox drain so both deliver identical commands.
*/
export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpec) {
const title = deriveThreadTitleFromPrompt(spec.text);
const title = deriveThreadTitleSeed({ text: spec.text, attachments: spec.attachments });
const isWorktree = spec.workspaceMode === "worktree";
return {
commandId: CommandId.make(spec.commandId),
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/state/use-pending-new-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";

import { deriveThreadTitleFromPrompt } from "../lib/projectThreadStartTurn";
import {
flattenQueuedThreadMessages,
type QueuedThreadCreation,
Expand All @@ -26,7 +26,7 @@ export function usePendingNewTasks(): ReadonlyArray<PendingNewTask> {
tasks.push({
message,
creation: message.creation,
title: deriveThreadTitleFromPrompt(message.text),
title: deriveThreadTitleSeed({ text: message.text, attachments: message.attachments }),
});
}
tasks.sort((left, right) => right.message.createdAt.localeCompare(left.message.createdAt));
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/state/use-thread-outbox-drain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
Expand Down Expand Up @@ -231,6 +232,10 @@ export function useThreadOutboxDrain(): void {
attachments: toUploadChatImageAttachments(queuedMessage.attachments),
},
modelSelection: settings.modelSelection,
titleSeed: deriveThreadTitleSeed({
text: queuedMessage.text,
attachments: queuedMessage.attachments,
}),
runtimeMode: settings.runtimeMode,
interactionMode: settings.interactionMode,
createdAt: queuedMessage.createdAt,
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/orchestration-v2/EffectOutbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,13 @@ export const OrchestrationEffectRequestV2 = Schema.Union([
type: Schema.Literal("attachment.cleanup"),
attachmentIds: Schema.Array(Schema.String),
}),
Schema.Struct({
type: Schema.Literal("thread-title.generate"),
kind: Schema.Union([
Schema.Struct({ type: Schema.Literal("initial"), messageId: MessageId }),
Schema.Struct({ type: Schema.Literal("regenerate") }),
]),
}),
]);
export type OrchestrationEffectRequestV2 = typeof OrchestrationEffectRequestV2.Type;

Expand All @@ -99,6 +106,7 @@ export const REPLAY_SAFE_EFFECT_TYPES_AFTER_PROCESS_LOSS = [
"checkpoint.capture",
"terminal.cleanup",
"attachment.cleanup",
"thread-title.generate",
] as const satisfies ReadonlyArray<OrchestrationEffectRequestV2["type"]>;

export const PROCESS_BOUND_EFFECT_TYPES = [
Expand Down Expand Up @@ -265,6 +273,8 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
available,
Array.from({ length: Math.min(64, Math.max(0, Math.floor(count))) }, () => undefined),
).pipe(Effect.asVoid);
// Title generation is correlated metadata work, so it has its own
// per-thread lane and cannot delay provider lifecycle effects.
const claimableCandidatePredicate = (availableBefore?: string) =>
sql`
${
Expand All @@ -278,6 +288,17 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
FROM orchestration_v2_effect_outbox AS active
WHERE active.thread_id = candidate.thread_id
AND active.status = 'running'
AND (
(
candidate.effect_type = 'thread-title.generate'
AND active.effect_type = 'thread-title.generate'
)
OR
(
candidate.effect_type != 'thread-title.generate'
AND active.effect_type != 'thread-title.generate'
)
)
)
`;

Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/orchestration-v2/EffectWorker.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert, it } from "@effect/vitest";
import {
CommandId,
MessageId,
ProviderSessionId,
ProviderThreadId,
ProviderTurnId,
Expand Down Expand Up @@ -36,6 +37,7 @@ import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts";
import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts";
import { ProviderTurnStartError, ProviderTurnStartServiceV2 } from "./ProviderTurnStartService.ts";
import { RuntimeRequestServiceV2 } from "./RuntimeRequestService.ts";
import { ThreadTitleRegenerationService } from "./ThreadTitleRegenerationService.ts";

const threadId = ThreadId.make("thread:effect-worker-restart");
const oldSessionId = ProviderSessionId.make("provider-session:effect-worker-restart:old");
Expand Down Expand Up @@ -142,6 +144,12 @@ function makeExecutorLayer(input: {
RuntimeRequestServiceV2,
RuntimeRequestServiceV2.of({ respond: () => Effect.void }),
),
Layer.succeed(
ThreadTitleRegenerationService,
ThreadTitleRegenerationService.of({
execute: ({ requestId, kind }) => record(`title:${kind.type}:${requestId}`),
}),
),
);
return executorLayer.pipe(Layer.provide(dependencies));
}
Expand Down Expand Up @@ -714,6 +722,39 @@ it.effect("detaches a handed-off session only after the old turn terminalizes",
}),
);

it.effect("executes durable thread title generation effects", () =>
Effect.gen(function* () {
const now = DateTime.formatIso(yield* DateTime.now);
const events = yield* Ref.make<ReadonlyArray<string>>([]);
const commandId = CommandId.make("command:title-generation");
const effect: OrchestrationEffectV2 = {
id: "effect:title-generation",
commandId,
threadId,
request: {
type: "thread-title.generate",
kind: { type: "initial", messageId: MessageId.make("message:title-generation") },
},
status: "running",
attemptCount: 1,
availableAt: now,
leaseOwner: "test-worker",
leaseExpiresAt: now,
createdAt: now,
updatedAt: now,
completedAt: null,
lastError: null,
};

yield* Effect.gen(function* () {
const executor = yield* OrchestrationEffectExecutorV2;
yield* executor.execute(effect);
}).pipe(Effect.provide(makeExecutorLayer({ events })));

assert.deepEqual(yield* Ref.get(events), [`title:initial:${commandId}`]);
}),
);

it.effect("safely retries after replacement cleanup succeeds and start fails", () =>
Effect.gen(function* () {
const now = yield* DateTime.now;
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/orchestration-v2/EffectWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts";
import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts";
import { ProviderTurnStartServiceV2 } from "./ProviderTurnStartService.ts";
import { RuntimeRequestServiceV2 } from "./RuntimeRequestService.ts";
import { ThreadTitleRegenerationService } from "./ThreadTitleRegenerationService.ts";

export class OrchestrationEffectExecutionError extends Schema.TaggedErrorClass<OrchestrationEffectExecutionError>()(
"OrchestrationEffectExecutionError",
Expand Down Expand Up @@ -81,6 +82,7 @@ export const executorLayer: Layer.Layer<
| ProviderTurnControlServiceV2
| ProviderTurnStartServiceV2
| RuntimeRequestServiceV2
| ThreadTitleRegenerationService
> = Layer.effect(
OrchestrationEffectExecutorV2,
Effect.gen(function* () {
Expand All @@ -91,6 +93,7 @@ export const executorLayer: Layer.Layer<
const providerTurnControl = yield* ProviderTurnControlServiceV2;
const providerTurnStart = yield* ProviderTurnStartServiceV2;
const runtimeRequests = yield* RuntimeRequestServiceV2;
const threadTitleRegeneration = yield* ThreadTitleRegenerationService;
return OrchestrationEffectExecutorV2.of({
execute: (effect) => {
switch (effect.request.type) {
Expand Down Expand Up @@ -290,6 +293,23 @@ export const executorLayer: Layer.Layer<
}),
),
);
case "thread-title.generate":
return threadTitleRegeneration
.execute({
threadId: effect.threadId,
requestId: effect.commandId,
kind: effect.request.kind,
})
.pipe(
Effect.mapError(
(cause) =>
new OrchestrationEffectExecutionError({
effectId: effect.id,
effectType: effect.request.type,
cause,
}),
),
);
}
},
});
Expand Down
105 changes: 105 additions & 0 deletions apps/server/src/orchestration-v2/FoundationPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,111 @@ it.layer(TestLayer)("orchestration V2 foundation persistence", (it) => {
}),
);

it.effect("runs title generation beside critical work while serializing each lane", () =>
Effect.gen(function* () {
const outbox = yield* EffectOutboxV2;
const commandId = CommandId.make("command:foundation-title-effect-lane");
const threadId = ThreadId.make("thread:foundation-title-effect-lane");
const titleEffectId = "effect:foundation-title-effect-lane:a-title";
const providerEffectId = "effect:foundation-title-effect-lane:b-provider";
const nextTitleEffectId = "effect:foundation-title-effect-lane:c-title";
const nextCriticalEffectId = "effect:foundation-title-effect-lane:d-critical";
yield* outbox.enqueue([
{
id: titleEffectId,
commandId,
threadId,
request: {
type: "thread-title.generate",
kind: {
type: "initial",
messageId: MessageId.make("message:foundation-title-effect-lane"),
},
},
},
{
id: providerEffectId,
commandId,
threadId,
request: {
type: "provider-turn.start",
runId: RunId.make("run:foundation-title-effect-lane"),
},
},
{
id: nextTitleEffectId,
commandId,
threadId,
request: { type: "thread-title.generate", kind: { type: "regenerate" } },
},
{
id: nextCriticalEffectId,
commandId,
threadId,
request: { type: "terminal.cleanup" },
},
]);

const title = yield* outbox.claimNext({
workerId: "title-lane-worker",
leaseDurationMs: 30_000,
});
assert.isTrue(Option.isSome(title));
if (Option.isNone(title)) return;
assert.equal(title.value.id, titleEffectId);

const provider = yield* outbox.claimNext({
workerId: "critical-lane-worker",
leaseDurationMs: 30_000,
});
assert.isTrue(Option.isSome(provider));
if (Option.isNone(provider)) return;
assert.equal(provider.value.id, providerEffectId);

assert.isTrue(
Option.isNone(
yield* outbox.claimNext({
workerId: "blocked-lanes-worker",
leaseDurationMs: 30_000,
}),
),
);

yield* outbox.succeed({
effectId: provider.value.id,
workerId: "critical-lane-worker",
});
const nextCritical = yield* outbox.claimNext({
workerId: "critical-lane-worker",
leaseDurationMs: 30_000,
});
assert.isTrue(Option.isSome(nextCritical));
if (Option.isNone(nextCritical)) return;
assert.equal(nextCritical.value.id, nextCriticalEffectId);
yield* outbox.succeed({
effectId: nextCritical.value.id,
workerId: "critical-lane-worker",
});

yield* outbox.succeed({
effectId: title.value.id,
workerId: "title-lane-worker",
});
const nextTitle = yield* outbox.claimNext({
workerId: "title-lane-worker",
leaseDurationMs: 30_000,
});
assert.isTrue(Option.isSome(nextTitle));
if (Option.isSome(nextTitle)) {
assert.equal(nextTitle.value.id, nextTitleEffectId);
yield* outbox.succeed({
effectId: nextTitle.value.id,
workerId: "title-lane-worker",
});
}
}),
);

it.effect("ignores deadlines blocked by a running effect on the same thread", () =>
Effect.gen(function* () {
const outbox = yield* EffectOutboxV2;
Expand Down
Loading
Loading