Summarize project conversations and retry contended handoffs

Group threads, which hold every project's main conversation, were never
compacted. Runs there saw only the last 200 raw messages, so the original
brief and earlier decisions fell out of context. The lead bot now
summarizes them like a bot thread; the summary stays local.

A group handoff now retries when its transaction hits a deadlock, so a
specialist that finished its work no longer fails at the hand-back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE
This commit is contained in:
Claude 2026-10-22 17:18:21 +00:00
parent aa989f50b3
commit 8f56426ea7
No known key found for this signature in database
3 changed files with 222 additions and 179 deletions

View File

@ -13,6 +13,7 @@ import {
lockOwnedGroup,
type PrismaClient,
touchGroupUpdatedAt,
withTransactionRetry,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { ExecutorDeps } from "./executor.js";
@ -30,7 +31,10 @@ export async function handoffToGroupBot(
input: { bot_id?: string; confirm_name?: string; message: string },
) {
const deliveryKey = `group-handoff:${run.id}`;
const committed = await deps.prisma.$transaction(async (tx) => {
// Runs finishing on the same thread contend for its event sequence; a deadlock here used to
// fail a specialist whose work was already done.
const committed = await withTransactionRetry(() =>
deps.prisma.$transaction(async (tx) => {
try {
await lockOwnedGroup(tx, run, groupId);
} catch (error) {
@ -120,7 +124,8 @@ export async function handoffToGroupBot(
const sourceHandoff = sourceBlocks.find(
(block): block is Extract<MessageBlock, { kind: "handoff" }> => block.kind === "handoff",
);
const leadInvolved = leadBotId !== null && (targetId === leadBotId || run.botId === leadBotId);
const leadInvolved =
leadBotId !== null && (targetId === leadBotId || run.botId === leadBotId);
if (sourceHandoff?.fromBotId === targetId && !leadInvolved) {
return {
error:
@ -203,7 +208,8 @@ export async function handoffToGroupBot(
eventSeq: event.seq,
toLead: targetId === leadBotId,
} as const;
});
}),
);
if ("error" in committed) return committed;
await deps.events.notify(run.threadId, committed.eventSeq).catch((error) => {
getLogger().error("group handoff realtime notification", error);

View File

@ -241,6 +241,7 @@ function compactionHarness(
defaultMemoryScope: string;
} | null;
timeZone?: string | null;
groupLeadBotId?: string | null;
} = {},
) {
const messages: HarnessMessage[] =
@ -252,7 +253,8 @@ function compactionHarness(
}));
const thread = {
id: "thread-1",
botId: "bot-1",
botId: options.groupLeadBotId === undefined ? ("bot-1" as string | null) : null,
group: options.groupLeadBotId === undefined ? null : { leadBotId: options.groupLeadBotId },
spaceId: "workspace-1",
userId: "user-1",
nextEventSeq: 0,
@ -390,6 +392,33 @@ function compactionHarness(
}
describe("compactHistory", () => {
it("summarizes a group thread with its lead and keeps the summary local", async () => {
const harness = compactionHarness({
deploymentModelKey: "openrouter-key",
groupLeadBotId: "lead-bot",
});
await compactHistory(harness.deps, "thread-1");
const [request] = harness.runtime.run.mock.calls[0]!;
expect(request.botId).toBe("lead-bot");
expect(harness.thread.historyCompactedUpToSeq).toBe(49);
expect(harness.thread.historyCompactionSummary).toBe("Summary of 50 messages.");
expect(harness.memoryProviders.resolve).not.toHaveBeenCalled();
expect(harness.saveMemory).not.toHaveBeenCalled();
});
it("skips a group thread without a lead", async () => {
const harness = compactionHarness({
deploymentModelKey: "openrouter-key",
groupLeadBotId: null,
});
await compactHistory(harness.deps, "thread-1");
expect(harness.runtime.run).not.toHaveBeenCalled();
});
it("anchors the summarizer in the thread owner's time zone", async () => {
const harness = compactionHarness({
deploymentModelKey: "openrouter-key",

View File

@ -147,8 +147,14 @@ export interface CompactHistoryDeps {
}
export async function compactHistory(deps: CompactHistoryDeps, threadId: string): Promise<void> {
const thread = await deps.prisma.thread.findUniqueOrThrow({ where: { id: threadId } });
if (!thread.botId) return;
const thread = await deps.prisma.thread.findUniqueOrThrow({
where: { id: threadId },
include: { group: { select: { leadBotId: true } } },
});
// A group thread (a project's main conversation included) is summarized by its lead. Its
// summary stays local: the external memory store is keyed by one bot's own thread.
const summarizerBotId = thread.botId ?? thread.group?.leadBotId;
if (!summarizerBotId) return;
const previousCursor = thread.historyCompactedUpToSeq;
const previousGeneration = thread.historyCompactionGeneration;
const previousSummary = thread.historyCompactionSummary?.trim() || null;
@ -267,7 +273,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
? await deps.resolveModel({
userId: thread.userId,
spaceId: thread.spaceId,
botId: thread.botId,
botId: summarizerBotId,
})
: deps.deploymentModelKey
? {
@ -296,7 +302,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
let runtimeReportedFailure = false;
for await (const event of deps.runtime.run(
{
botId: thread.botId,
botId: summarizerBotId,
threadId,
runId: `compact:${threadId}:${fromSeqExclusive}`,
prompt,
@ -353,6 +359,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
// Local compaction is first-party behavior and must not depend on an optional external store.
// Saving only after the compare-and-set also prevents losing workers from creating duplicates.
let semanticMemory: ConfiguredMemoryProvider | null = null;
if (thread.botId) {
try {
semanticMemory = await deps.memoryProviders.resolve(
memoryProviderResolveRequest(thread.spaceId, thread.userId),
@ -360,13 +367,14 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
} catch (error) {
getLogger().error("Failed to load semantic memory provider for history compaction", error);
}
}
let externalSaveAttempted = false;
const memoryContext = {
operationId: `history-compact:${threadId}`,
traceId: `history-compact:${threadId}`,
spaceId: thread.spaceId,
userId: thread.userId,
botId: thread.botId,
botId: summarizerBotId,
signal: new AbortController().signal,
};
if (semanticMemory) {
@ -376,7 +384,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
{
content: summary,
scope: "isolated",
botId: thread.botId,
botId: summarizerBotId,
source: { kind: "history", generation: previousGeneration },
},
memoryContext,
@ -407,7 +415,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
) {
try {
const removed = await semanticMemory.provider.purgeHistory(
{ botId: thread.botId, generations: [previousGeneration] },
{ botId: summarizerBotId, generations: [previousGeneration] },
memoryContext,
);
if (!removed.ok) {