authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-20 01:42:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 10:42:02+02:00
log749baec353d5675f1acac81f22aa15da573e71c7
tree9c3f5297f785948819fe8bad14641f77ffe0aa66
parentd7baa37e5c25a1335a117ee3c9f5b5b0d5bbd832
signature Signed by PGP key B5690EEEBB952194

feat(server): title background-task work-log rows with the task name (#3751)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 289 insertions(+), 3 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+190
......@@ -2947,6 +2947,196 @@ describe("ProviderRuntimeIngestion", () => {
29472947 ).toBe("# Plan title");
29482948 });
29492949
2950 it("titles task activities with the task description, including on completion", async () => {
2951 const harness = await createHarness();
2952 const now = "2026-01-01T00:00:00.000Z";
2953
2954 harness.emit({
2955 type: "task.started",
2956 eventId: asEventId("evt-named-task-started"),
2957 provider: ProviderDriverKind.make("claudeAgent"),
2958 createdAt: now,
2959 threadId: asThreadId("thread-1"),
2960 turnId: asTurnId("turn-named-task"),
2961 payload: {
2962 taskId: "named-task-1",
2963 description: "Typecheck mobile app",
2964 taskType: "local_bash",
2965 },
2966 });
2967
2968 harness.emit({
2969 type: "task.progress",
2970 eventId: asEventId("evt-named-task-progress"),
2971 provider: ProviderDriverKind.make("claudeAgent"),
2972 createdAt: now,
2973 threadId: asThreadId("thread-1"),
2974 turnId: asTurnId("turn-named-task"),
2975 payload: {
2976 taskId: "named-task-1",
2977 description: "Typecheck mobile app",
2978 summary: "Running tsc across the mobile workspace.",
2979 },
2980 });
2981
2982 harness.emit({
2983 type: "task.completed",
2984 eventId: asEventId("evt-named-task-completed"),
2985 provider: ProviderDriverKind.make("claudeAgent"),
2986 createdAt: now,
2987 threadId: asThreadId("thread-1"),
2988 turnId: asTurnId("turn-named-task"),
2989 payload: {
2990 taskId: "named-task-1",
2991 status: "completed",
2992 summary: "Typecheck finished without errors.",
2993 },
2994 });
2995
2996 const thread = await waitForThread(harness.readModel, (entry) =>
2997 entry.activities.some(
2998 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed",
2999 ),
3000 );
3001
3002 const progress = thread.activities.find(
3003 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-progress",
3004 );
3005 const completed = thread.activities.find(
3006 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed",
3007 );
3008
3009 const progressPayload =
3010 progress?.payload && typeof progress.payload === "object"
3011 ? (progress.payload as Record<string, unknown>)
3012 : undefined;
3013 const completedPayload =
3014 completed?.payload && typeof completed.payload === "object"
3015 ? (completed.payload as Record<string, unknown>)
3016 : undefined;
3017
3018 expect(progress?.summary).toBe("Typecheck mobile app");
3019 expect(progressPayload?.title).toBe("Typecheck mobile app");
3020 expect(completed?.summary).toBe("Task completed");
3021 expect(completedPayload?.title).toBe("Typecheck mobile app");
3022 expect(completedPayload?.summary).toBe("Typecheck finished without errors.");
3023 expect(completedPayload?.detail).toBe("Typecheck finished without errors.");
3024 });
3025
3026 it("titles task completion from task.started when no progress event carried the name", async () => {
3027 const harness = await createHarness();
3028 const now = "2026-01-01T00:00:00.000Z";
3029
3030 harness.emit({
3031 type: "task.started",
3032 eventId: asEventId("evt-fast-task-started"),
3033 provider: ProviderDriverKind.make("claudeAgent"),
3034 createdAt: now,
3035 threadId: asThreadId("thread-1"),
3036 turnId: asTurnId("turn-fast-task"),
3037 payload: {
3038 taskId: "fast-task-1",
3039 description: "wait for codex review to finish",
3040 taskType: "local_bash",
3041 },
3042 });
3043
3044 harness.emit({
3045 type: "task.completed",
3046 eventId: asEventId("evt-fast-task-completed"),
3047 provider: ProviderDriverKind.make("claudeAgent"),
3048 createdAt: now,
3049 threadId: asThreadId("thread-1"),
3050 turnId: asTurnId("turn-fast-task"),
3051 payload: {
3052 taskId: "fast-task-1",
3053 status: "completed",
3054 },
3055 });
3056
3057 const thread = await waitForThread(harness.readModel, (entry) =>
3058 entry.activities.some(
3059 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed",
3060 ),
3061 );
3062
3063 const completed = thread.activities.find(
3064 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-fast-task-completed",
3065 );
3066 const completedPayload =
3067 completed?.payload && typeof completed.payload === "object"
3068 ? (completed.payload as Record<string, unknown>)
3069 : undefined;
3070
3071 expect(completedPayload?.title).toBe("wait for codex review to finish");
3072 });
3073
3074 it("titles task completion from persisted activities after the description cache is swept", async () => {
3075 const harness = await createHarness();
3076 const now = "2026-01-01T00:00:00.000Z";
3077
3078 harness.emit({
3079 type: "task.progress",
3080 eventId: asEventId("evt-swept-task-progress"),
3081 provider: ProviderDriverKind.make("claudeAgent"),
3082 createdAt: now,
3083 threadId: asThreadId("thread-1"),
3084 turnId: asTurnId("turn-swept-task"),
3085 payload: {
3086 taskId: "swept-task-1",
3087 description: "Watch round-3 CI and bots",
3088 summary: "Polling CI checks.",
3089 },
3090 });
3091
3092 await waitForThread(harness.readModel, (entry) =>
3093 entry.activities.some(
3094 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-progress",
3095 ),
3096 );
3097
3098 // session.exited sweeps the in-memory description cache; the completion
3099 // that follows must recover the name from persisted activities.
3100 harness.emit({
3101 type: "session.exited",
3102 eventId: asEventId("evt-swept-task-session-exited"),
3103 provider: ProviderDriverKind.make("claudeAgent"),
3104 createdAt: now,
3105 threadId: asThreadId("thread-1"),
3106 payload: {},
3107 });
3108
3109 harness.emit({
3110 type: "task.completed",
3111 eventId: asEventId("evt-swept-task-completed"),
3112 provider: ProviderDriverKind.make("claudeAgent"),
3113 createdAt: now,
3114 threadId: asThreadId("thread-1"),
3115 turnId: asTurnId("turn-swept-task"),
3116 payload: {
3117 taskId: "swept-task-1",
3118 status: "completed",
3119 summary: "CI is green.",
3120 },
3121 });
3122
3123 const thread = await waitForThread(harness.readModel, (entry) =>
3124 entry.activities.some(
3125 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed",
3126 ),
3127 );
3128
3129 const completed = thread.activities.find(
3130 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed",
3131 );
3132 const completedPayload =
3133 completed?.payload && typeof completed.payload === "object"
3134 ? (completed.payload as Record<string, unknown>)
3135 : undefined;
3136
3137 expect(completedPayload?.title).toBe("Watch round-3 CI and bots");
3138 });
3139
29503140 it("projects structured user input request and resolution as thread activities", async () => {
29513141 const harness = await createHarness();
29523142 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+99-3
......@@ -40,6 +40,42 @@ import {
4040import { ServerSettingsService } from "../../serverSettings.ts";
4141
4242const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
43const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;
44
45// Fallback when the in-memory description cache no longer has the task name
46// (server restart, session-exit sweep, TTL/capacity eviction): earlier
47// task.started/task.progress activities for the task are persisted with it.
48function findTaskTitleInActivities(
49 activities: ReadonlyArray<OrchestrationThreadActivity> | undefined,
50 taskId: string,
51): string | undefined {
52 if (!activities) {
53 return undefined;
54 }
55 for (let index = activities.length - 1; index >= 0; index -= 1) {
56 const activity = activities[index];
57 if (!activity || (activity.kind !== "task.started" && activity.kind !== "task.progress")) {
58 continue;
59 }
60 const payload =
61 activity.payload && typeof activity.payload === "object"
62 ? (activity.payload as { taskId?: unknown; title?: unknown; detail?: unknown })
63 : undefined;
64 if (payload?.taskId !== taskId) {
65 continue;
66 }
67 const title =
68 typeof payload.title === "string"
69 ? payload.title
70 : activity.kind === "task.started" && typeof payload.detail === "string"
71 ? payload.detail
72 : undefined;
73 if (title && title.trim().length > 0) {
74 return title;
75 }
76 }
77 return undefined;
78}
4379
4480interface AssistantSegmentState {
4581 baseKey: string;
......@@ -53,6 +89,8 @@ const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000;
5389const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL = Duration.minutes(120);
5490const BUFFERED_PROPOSED_PLAN_BY_ID_CACHE_CAPACITY = 10_000;
5591const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120);
92const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000;
93const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120);
5694const MAX_BUFFERED_ASSISTANT_CHARS = 24_000;
5795const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0";
5896
......@@ -264,6 +302,7 @@ function requestKindFromCanonicalRequestType(
264302
265303function runtimeEventToActivities(
266304 event: ProviderRuntimeEvent,
305 taskTitle?: string,
267306): ReadonlyArray<OrchestrationThreadActivity> {
268307 const maybeSequence = (() => {
269308 const eventWithSequence = event as ProviderRuntimeEvent & { sessionSequence?: number };
......@@ -473,9 +512,15 @@ function runtimeEventToActivities(
473512 createdAt: event.createdAt,
474513 tone: "info",
475514 kind: "task.progress",
476 summary: "Reasoning update",
515 summary:
516 event.payload.description.trim().length > 0
517 ? truncateDetail(event.payload.description, 120)
518 : "Reasoning update",
477519 payload: {
478520 taskId: event.payload.taskId,
521 ...(event.payload.description.trim().length > 0
522 ? { title: truncateDetail(event.payload.description, 120) }
523 : {}),
479524 detail: truncateDetail(event.payload.summary ?? event.payload.description),
480525 ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),
481526 ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),
......@@ -503,7 +548,15 @@ function runtimeEventToActivities(
503548 payload: {
504549 taskId: event.payload.taskId,
505550 status: event.payload.status,
506 ...(event.payload.summary ? { detail: truncateDetail(event.payload.summary) } : {}),
551 ...(taskTitle ? { title: truncateDetail(taskTitle, 120) } : {}),
552 // summary + detail mirror task.progress: clients label the row from
553 // summary and keep detail for the preview/expanded body.
554 ...(event.payload.summary
555 ? {
556 summary: truncateDetail(event.payload.summary),
557 detail: truncateDetail(event.payload.summary),
558 }
559 : {}),
507560 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
508561 },
509562 turnId: toTurnId(event.turnId) ?? null,
......@@ -666,6 +719,27 @@ const make = Effect.gen(function* () {
666719 lookup: () => Effect.succeed({ text: "", createdAt: "" }),
667720 });
668721
722 // Task names arrive on task.started/task.progress but not on task.completed,
723 // so remember them per task to title the completion activity.
724 const taskDescriptionByTaskKey = yield* Cache.make<string, string>({
725 capacity: TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY,
726 timeToLive: TASK_DESCRIPTION_BY_TASK_TTL,
727 lookup: () => Effect.succeed(""),
728 });
729
730 const rememberTaskDescription = (threadId: ThreadId, taskId: string, description: string) =>
731 Cache.set(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId), description);
732
733 // Entries are left in place after completion so replayed or duplicate
734 // terminal events stay titled; TTL, capacity, and the session-exit sweep
735 // bound the cache.
736 const lookupTaskDescription = (threadId: ThreadId, taskId: string) =>
737 Cache.getOption(taskDescriptionByTaskKey, providerTaskKey(threadId, taskId)).pipe(
738 Effect.map((description) =>
739 Option.filter(description, (value) => value.length > 0).pipe(Option.getOrUndefined),
740 ),
741 );
742
669743 const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* (threadId: ThreadId) {
670744 return yield* projectionSnapshotQuery
671745 .getThreadDetailById(threadId)
......@@ -1090,6 +1164,7 @@ const make = Effect.gen(function* () {
10901164 const turnKeys = Array.from(yield* Cache.keys(turnMessageIdsByTurnKey));
10911165 const assistantSegmentKeys = Array.from(yield* Cache.keys(assistantSegmentStateByTurnKey));
10921166 const proposedPlanKeys = Array.from(yield* Cache.keys(bufferedProposedPlanById));
1167 const taskDescriptionKeys = Array.from(yield* Cache.keys(taskDescriptionByTaskKey));
10931168 yield* Effect.forEach(
10941169 turnKeys,
10951170 (key) =>
......@@ -1125,6 +1200,12 @@ const make = Effect.gen(function* () {
11251200 : Effect.void,
11261201 { concurrency: 1 },
11271202 ).pipe(Effect.asVoid);
1203 yield* Effect.forEach(
1204 taskDescriptionKeys,
1205 (key) =>
1206 key.startsWith(prefix) ? Cache.invalidate(taskDescriptionByTaskKey, key) : Effect.void,
1207 { concurrency: 1 },
1208 ).pipe(Effect.asVoid);
11281209 });
11291210
11301211 const getSourceProposedPlanReferenceForPendingTurnStart = Effect.fn(
......@@ -1654,7 +1735,22 @@ const make = Effect.gen(function* () {
16541735 }
16551736 }
16561737
1657 const activities = runtimeEventToActivities(event);
1738 if (event.type === "task.started" || event.type === "task.progress") {
1739 const description = event.payload.description?.trim();
1740 if (description) {
1741 yield* rememberTaskDescription(thread.id, event.payload.taskId, description);
1742 }
1743 }
1744 let taskTitle: string | undefined;
1745 if (event.type === "task.completed") {
1746 taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId);
1747 if (!taskTitle) {
1748 const threadDetail = yield* getLoadedThreadDetail();
1749 taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId);
1750 }
1751 }
1752
1753 const activities = runtimeEventToActivities(event, taskTitle);
16581754 yield* Effect.forEach(activities, (activity) =>
16591755 providerCommandId(event, "thread-activity-append").pipe(
16601756 Effect.flatMap((commandId) =>