fix: resume reviews after conversation deletion and update release docs

This commit is contained in:
mesalogo
2026-10-05 14:22:16 +08:00
parent b90ff50f2a
commit 2be57492e6
23 changed files with 570 additions and 146 deletions
+145 -114
View File
@@ -9,7 +9,7 @@ import {
} from '../../shared/supervision-contracts'
import { reviewScope, type ReviewBatch } from './review-checkpoint'
import { createHash } from 'node:crypto'
import type { SupervisionReviewStore, ReviewConfiguration, ReviewState } from './supervision-review-store'
import { ReviewSourcesOmitted, type SupervisionReviewStore, type ReviewConfiguration, type ReviewState } from './supervision-review-store'
import type { SupervisionReviewExecution, SupervisionReviewProgress } from '../../shared/supervision-review-contracts'
export type SupervisorSummarizerRequest = {
@@ -287,7 +287,7 @@ export class SupervisorService {
const includedBatch = batch ? { ...batch, checkpoints: batch.checkpoints.filter((checkpoint) =>
batch.evidence.some((source) => source.locator?.source === checkpoint.source &&
source.locator?.end === checkpoint.offset && boundedEvidence.some((included) =>
included.id === source.id && included.content === source.content))) } : undefined
included.id === source.id && included.content === source.content))) } : undefined
const result = { runId, request, evidence: boundedEvidence, output, candidates, batch: includedBatch }
await this.store.save(result)
return result
@@ -361,19 +361,23 @@ export class SupervisorService {
controller.signal.throwIfAborted()
const context = !navigation && this.review!.context ? await this.review!.context(request, controller.signal) : undefined
controller.signal.throwIfAborted()
if (!navigation) {
db.omitDeletedConversations(runId)
if (!db.hasSources(runId, evidence)) throw new ReviewSourcesOmitted('Deleted conversation sources omitted')
}
activity.inFlight++
let raw: unknown
try {
raw = await this.summarizer.summarize({ request, evidence, candidates: navigation ? [] : candidates,
signal: controller.signal, timeoutSeconds: config.timeoutSeconds,
systemInstruction: systemInstruction + (navigation
? '\nThese inputs are navigation summaries, not original evidence. Return summary, changeDigest and openItems. Omit events, entities, entityChanges and relations or return them as empty arrays. All original leaf results are retained separately.'
: '\nRetain atomic decisions, constraints, corrections, conflicting statements and unresolved items with source references. Attribute assistant proposals as proposals. Source locators use Unicode code points; split task JSON is a source fragment, not a complete object.') +
raw = await this.summarizer.summarize({ request, evidence, candidates: navigation ? [] : candidates,
signal: controller.signal, timeoutSeconds: config.timeoutSeconds,
systemInstruction: systemInstruction + (navigation
? '\nThese inputs are navigation summaries, not original evidence. Return summary, changeDigest and openItems. Omit events, entities, entityChanges and relations or return them as empty arrays. All original leaf results are retained separately.'
: '\nRetain atomic decisions, constraints, corrections, conflicting statements and unresolved items with source references. Attribute assistant proposals as proposals. Source locators use Unicode code points; split task JSON is a source fragment, not a complete object.') +
'\nWrite concise navigation while retaining decisions, qualifications and unresolved items. Entity IDs use only ASCII letters, numbers, underscore or hyphen (1..120 characters).',
previousSummary: navigation ? undefined : context ? context.summary : this.store.summary?.(request),
outputContract: navigation ? navigationContract : entityContract(candidates),
background: navigation ? [] : context ? context.background : this.store.background?.(request),
authorizeTool: async name => { throw new Error(`Supervisor tools are disabled: ${name}`) } })
previousSummary: navigation ? undefined : context ? context.summary : this.store.summary?.(request),
outputContract: navigation ? navigationContract : entityContract(candidates),
background: navigation ? [] : context ? context.background : this.store.background?.(request),
authorizeTool: async name => { throw new Error(`Supervisor tools are disabled: ${name}`) } })
} finally { activity.inFlight-- }
controller.signal.throwIfAborted()
try {
@@ -385,115 +389,142 @@ export class SupervisorService {
}
}
for (;;) {
if (controller.signal.aborted) return stopped()
const groups = db.groups(runId, config.concurrency)
if (!groups.length) break
const results = await Promise.allSettled(groups.map(async group => {
controller.signal.throwIfAborted()
const candidates = await candidatesFor(group.projectId)
// An unusable answer is retried on a smaller prefix of the same pending text; nothing is skipped,
// the rest of the text stays pending for the next batch.
for (let attempt = 0; ; attempt++) {
const scale = 2 ** attempt
const sized = attempt ? { ...config, batchCharacters: Math.max(500, Math.floor(config.batchCharacters / scale)),
batchMessages: Math.max(1, Math.ceil(config.batchMessages / scale)) } : config
const evidence = db.chunk(runId, group.projectId, group.conversationId, sized)
if (!evidence.length) throw new Error('Pending source produced no reviewable content')
try {
const output = await summarize(evidence, false, candidates)
const omittedBefore = db.progress(runId).omittedSources ?? 0
try {
db.omitDeletedConversations(runId)
if (db.progress(runId).remainingSources) db.setPhase(runId, 'extracting')
for (;;) {
if (controller.signal.aborted) return stopped()
const groups = db.groups(runId, config.concurrency)
if (!groups.length) break
const results = await Promise.allSettled(groups.map(async group => {
controller.signal.throwIfAborted()
db.save(runId, group.projectId, group.conversationId, evidence, output)
return
} catch (error) {
if (!(error instanceof SupervisionOutputError) || attempt >= outputRetries || controller.signal.aborted) throw error
const candidates = await candidatesFor(group.projectId)
// An unusable answer is retried on a smaller prefix of the same pending text; nothing is skipped,
// the rest of the text stays pending for the next batch.
for (let attempt = 0; ; attempt++) {
const scale = 2 ** attempt
const sized = attempt ? { ...config, batchCharacters: Math.max(500, Math.floor(config.batchCharacters / scale)),
batchMessages: Math.max(1, Math.ceil(config.batchMessages / scale)) } : config
const evidence = db.chunk(runId, group.projectId, group.conversationId, sized)
if (!evidence.length) return
try {
const output = await summarize(evidence, false, candidates)
controller.signal.throwIfAborted()
db.save(runId, group.projectId, group.conversationId, evidence, output)
return
} catch (error) {
db.omitDeletedConversations(runId)
if (!db.hasSources(runId, evidence)) return
if (!(error instanceof SupervisionOutputError) || attempt >= outputRetries || controller.signal.aborted) throw error
}
}
}))
if (controller.signal.aborted) return stopped()
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
if (db.omitDeletedConversations(runId)) continue
const progress = db.progress(runId)
if (progress.remainingSources) continue
const checkOmissions = () => {
db.omitDeletedConversations(runId)
if ((db.progress(runId).omittedSources ?? 0) !== (progress.omittedSources ?? 0)) {
throw new ReviewSourcesOmitted('Deleted conversation sources omitted; rebuild review navigation')
}
}
}))
if (controller.signal.aborted) return stopped()
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
const progress = db.progress(runId)
if (!progress.batches) {
this.store.noChange?.(runId)
// Earlier events may still await assignment (an upgrade backlog or a failed attempt).
await this.organizeStories(db, runId, request, config, controller.signal)
return { runId, request, evidence: [], output: empty, status: 'no_change', coverage: db.progress(runId) }
}
type Card = { id: string; output: SupervisionSummaryOutput }
db.setPhase(runId, 'summarizing')
const merge = async (left: Card, right: Card): Promise<Card> => {
const id = createHash('sha256').update(JSON.stringify([left.id, right.id])).digest('hex')
const cached = db.navigation(runId, id)
if (cached) return { id, output: cached }
const evidence = [left, right].map(card => ({ id: card.id, sourceType: 'note' as const, sourceId: card.id,
title: 'Retained review navigation', content: card.output.summary, occurredAt: request.timeRange.to }))
// A navigation merge has fixed inputs; an unusable answer is asked again once before the run fails.
let output: SupervisionSummaryOutput
try { output = await summarize(evidence, true) } catch (error) {
if (!(error instanceof SupervisionOutputError) || controller.signal.aborted) throw error
output = await summarize(evidence, true)
}
controller.signal.throwIfAborted()
db.saveNavigation(runId, id, [left.id, right.id], output)
return { id, output }
}
// Reduce conversations, then projects, then scope. Binary carry stacks are logarithmic;
// singleton groups pass through, so the whole tree still uses exactly N-1 merges.
type Stack = Array<Card | undefined>
const conversationStack: Stack = [], projectStack: Stack = [], scopeStack: Stack = []
const push = async (stack: Stack, input: Card) => {
let card = input, depth = 0
while (stack[depth]) {
card = await merge(stack[depth]!, card)
stack[depth++] = undefined
}
stack[depth] = card
}
const finish = async (stack: Stack) => {
let root: Card | undefined
for (const card of stack.reverse()) if (card) root = root ? await merge(root, card) : card
stack.length = 0
return root
}
let previousProject: string | undefined, previousConversation: string | undefined
let first: ReturnType<SupervisionReviewStore['batches']>[number] | undefined
for (let offset = 0;; offset += 10) {
const batches = db.batches(runId, 10, offset)
if (!batches.length) break
for (const batch of batches) {
validateReferences(batch.output, batch.evidence)
first ??= batch
if (previousConversation !== undefined && (batch.conversationId !== previousConversation || batch.projectId !== previousProject)) {
await push(projectStack, (await finish(conversationStack))!)
if (!progress.batches) {
this.store.noChange?.(runId)
// Earlier events may still await assignment (an upgrade backlog or a failed attempt).
await this.organizeStories(db, runId, request, config, controller.signal)
return { runId, request, evidence: [], output: empty, status: 'no_change', coverage: db.progress(runId) }
}
if (previousProject !== undefined && batch.projectId !== previousProject) await push(scopeStack, (await finish(projectStack))!)
await push(conversationStack, batch)
previousProject = batch.projectId
previousConversation = batch.conversationId
type Card = { id: string; output: SupervisionSummaryOutput }
db.setPhase(runId, 'summarizing')
const merge = async (left: Card, right: Card): Promise<Card> => {
checkOmissions()
const id = createHash('sha256').update(JSON.stringify([left.id, right.id])).digest('hex')
const cached = db.navigation(runId, id)
if (cached) return { id, output: cached }
const evidence = [left, right].map(card => ({ id: card.id, sourceType: 'note' as const, sourceId: card.id,
title: 'Retained review navigation', content: card.output.summary, occurredAt: request.timeRange.to }))
// A navigation merge has fixed inputs; an unusable answer is asked again once before the run fails.
let output: SupervisionSummaryOutput
try { output = await summarize(evidence, true) } catch (error) {
checkOmissions()
if (!(error instanceof SupervisionOutputError) || controller.signal.aborted) throw error
output = await summarize(evidence, true)
}
controller.signal.throwIfAborted()
checkOmissions()
db.saveNavigation(runId, id, [left.id, right.id], output)
return { id, output }
}
// Reduce conversations, then projects, then scope. Binary carry stacks are logarithmic;
// singleton groups pass through, so the whole tree still uses exactly N-1 merges.
type Stack = Array<Card | undefined>
const conversationStack: Stack = [], projectStack: Stack = [], scopeStack: Stack = []
const push = async (stack: Stack, input: Card) => {
let card = input, depth = 0
while (stack[depth]) {
card = await merge(stack[depth]!, card)
stack[depth++] = undefined
}
stack[depth] = card
}
const finish = async (stack: Stack) => {
let root: Card | undefined
for (const card of stack.reverse()) if (card) root = root ? await merge(root, card) : card
stack.length = 0
return root
}
let previousProject: string | undefined, previousConversation: string | undefined
let first: ReturnType<SupervisionReviewStore['batches']>[number] | undefined
for (let offset = 0;; offset += 10) {
checkOmissions()
const batches = db.batches(runId, 10, offset)
if (!batches.length) break
for (const batch of batches) {
validateReferences(batch.output, batch.evidence)
first ??= batch
if (previousConversation !== undefined && (batch.conversationId !== previousConversation || batch.projectId !== previousProject)) {
await push(projectStack, (await finish(conversationStack))!)
}
if (previousProject !== undefined && batch.projectId !== previousProject) await push(scopeStack, (await finish(projectStack))!)
await push(conversationStack, batch)
previousProject = batch.projectId
previousConversation = batch.conversationId
}
}
await push(projectStack, (await finish(conversationStack))!)
await push(scopeStack, (await finish(projectStack))!)
const root = await finish(scopeStack)
controller.signal.throwIfAborted()
checkOmissions()
db.assertComplete(runId)
db.setPhase(runId, 'saving')
// A resumed run reuses leaves saved earlier; offer their projects' candidates too.
const savedProjects = new Set<string>()
for (let offset = 0;; offset += 10) {
const saved = db.batches(runId, 10, offset)
if (!saved.length) break
for (const batch of saved) savedProjects.add(batch.projectId)
}
for (const projectId of savedProjects) await candidatesFor(projectId)
controller.signal.throwIfAborted()
checkOmissions()
const result: StoredSupervisionResult = { runId, request, candidates: [...offered.values()],
evidence: progress.batches === 1 ? first!.evidence : [], output: root!.output,
status: 'completed', coverage: { ...db.progress(runId), complete: true } }
await this.store.save(result)
await this.organizeStories(db, runId, request, config, controller.signal)
return { ...result, coverage: { ...db.progress(runId), complete: true } }
} catch (error) {
db.omitDeletedConversations(runId)
if (error instanceof ReviewSourcesOmitted || (db.progress(runId).omittedSources ?? 0) !== omittedBefore) continue
throw error
}
}
await push(projectStack, (await finish(conversationStack))!)
await push(scopeStack, (await finish(projectStack))!)
const root = await finish(scopeStack)
controller.signal.throwIfAborted()
db.assertComplete(runId)
db.setPhase(runId, 'saving')
// A resumed run reuses leaves saved earlier; offer their projects' candidates too.
const savedProjects = new Set<string>()
for (let offset = 0;; offset += 10) {
const saved = db.batches(runId, 10, offset)
if (!saved.length) break
for (const batch of saved) savedProjects.add(batch.projectId)
}
for (const projectId of savedProjects) await candidatesFor(projectId)
controller.signal.throwIfAborted()
const result: StoredSupervisionResult = { runId, request, candidates: [...offered.values()],
evidence: progress.batches === 1 ? first!.evidence : [], output: root!.output,
status: 'completed', coverage: { ...db.progress(runId), complete: true } }
await this.store.save(result)
await this.organizeStories(db, runId, request, config, controller.signal)
return { ...result, coverage: { ...db.progress(runId), complete: true } }
} catch (error) {
if (controller.signal.aborted) return stopped()
this.store.fail?.(runId, error instanceof Error ? error.message : 'Review failed')