fix: resume reviews after conversation deletion and update release docs
This commit is contained in:
@@ -3806,6 +3806,11 @@ export class AssistantDatabase {
|
||||
)`
|
||||
)
|
||||
.run(conversationId)
|
||||
// Older pending manifests grouped tasks without their conversation owner.
|
||||
database.prepare(`UPDATE supervision_review_sources SET conversation_id = ?
|
||||
WHERE source IN (SELECT 'task:' || id FROM tasks WHERE conversation_id = ?)
|
||||
AND run_id IN (SELECT id FROM supervision_runs WHERE status IN ('running', 'paused', 'failed'))`)
|
||||
.run(conversationId, conversationId)
|
||||
database
|
||||
.prepare('DELETE FROM tasks WHERE conversation_id = ?')
|
||||
.run(conversationId)
|
||||
@@ -3814,6 +3819,12 @@ export class AssistantDatabase {
|
||||
'DELETE FROM conversations WHERE id = ? AND channel IS NULL'
|
||||
)
|
||||
.run(conversationId).changes === 1
|
||||
if (deleted) {
|
||||
const reviews = this.supervisionReviewStore()
|
||||
for (const row of database.prepare('SELECT DISTINCT run_id FROM supervision_review_sources WHERE conversation_id = ?').all(conversationId)) {
|
||||
reviews.omitDeletedConversations(String(row.run_id))
|
||||
}
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
if (deleted) this.executionTiming?.changed()
|
||||
return deleted
|
||||
@@ -9117,6 +9128,7 @@ export class AssistantDatabase {
|
||||
const timestamp = new Date().toISOString()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (result.coverage && result.runId) this.supervisionReviewStore().assertComplete(result.runId, result.coverage.omittedSources ?? 0)
|
||||
let story = database.prepare(
|
||||
'SELECT id FROM story_lines WHERE scope_json = ? LIMIT 1'
|
||||
).get(JSON.stringify(result.request.scope)) as { id: string } | undefined
|
||||
|
||||
@@ -9,7 +9,9 @@ import type { SupervisionReviewBatch, SupervisionReviewProgress, SupervisionRevi
|
||||
export const TIMELINE_CHECKPOINT_SCOPE = 'timeline'
|
||||
|
||||
export type ReviewConfiguration = SupervisionReviewSettings & { timeoutSeconds: number; concurrency: number; version: 1 }
|
||||
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
|
||||
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
|
||||
|
||||
export class ReviewSourcesOmitted extends Error {}
|
||||
|
||||
// Only the manifest is materialized. Source bodies are read in bounded substrings.
|
||||
export const supervisionReviewMigration = `
|
||||
@@ -98,7 +100,8 @@ export class SupervisionReviewStore {
|
||||
this.db.exec('DROP TABLE IF EXISTS temp.review_manifest; BEGIN')
|
||||
try {
|
||||
this.db.prepare(`CREATE TEMP TABLE review_manifest AS
|
||||
SELECT s.source, review_revision(s.context) AS revision, s.project_id, s.conversation_id, s.sequence,
|
||||
SELECT s.source, review_revision(s.context) AS revision, s.project_id,
|
||||
CASE WHEN s.type = 'task' THEN COALESCE((SELECT t.conversation_id FROM tasks t JOIN conversations c ON c.id = t.conversation_id WHERE t.id = s.record_id), '') ELSE s.conversation_id END AS conversation_id, s.sequence,
|
||||
length(s.body) AS length, CASE WHEN c.revision = review_revision(s.context) THEN c.processed_offset ELSE 0 END AS initial_offset
|
||||
FROM supervision_review_current s LEFT JOIN review_checkpoints c
|
||||
ON ? = 1 AND c.stage = 'supervisor' AND c.scope = '${TIMELINE_CHECKPOINT_SCOPE}' AND c.source = s.source
|
||||
@@ -130,6 +133,7 @@ export class SupervisionReviewStore {
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
this.db.prepare("UPDATE supervision_review_runs SET state_json = json_remove(state_json, '$.initializing') WHERE run_id = ?").run(runId)
|
||||
this.omitDeletedConversations(runId)
|
||||
} finally { this.db.exec('DROP TABLE IF EXISTS temp.review_manifest') }
|
||||
}
|
||||
|
||||
@@ -144,13 +148,17 @@ export class SupervisionReviewStore {
|
||||
unfinished(request: SupervisionRunRequest): string | undefined {
|
||||
return this.db.prepare(`SELECT s.id FROM supervision_runs s JOIN supervision_review_runs r ON r.run_id = s.id
|
||||
WHERE s.trigger = 'heartbeat' AND s.scope_json = ? AND s.status IN ('failed', 'running')
|
||||
AND COALESCE(json_extract(r.state_json, '$.restartRequired'), 0) = 0
|
||||
AND (COALESCE(json_extract(r.state_json, '$.restartRequired'), 0) = 0 OR EXISTS (
|
||||
SELECT 1 FROM supervision_review_sources source WHERE source.run_id = s.id AND source.conversation_id != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = source.conversation_id)
|
||||
AND instr(s.error, '(' || source.source || ');') > 0))
|
||||
ORDER BY s.created_at LIMIT 1`).get(JSON.stringify(request.scope))?.id as string | undefined
|
||||
}
|
||||
|
||||
resume(runId: string, signal?: AbortSignal): void {
|
||||
const status = this.db.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)?.status
|
||||
if (status === 'cancelled') throw new Error('SUPERVISION_REVIEW_CANCELLED: 此回顾已取消,不能继续。请开始新的回顾。')
|
||||
this.omitDeletedConversations(runId)
|
||||
if (this.load(runId).restartRequired) throw new Error('Review source changed or was removed; start a new review')
|
||||
const changed = this.db.prepare(`UPDATE supervision_runs SET status = 'running', error = NULL, completed_at = NULL
|
||||
WHERE id = ? AND status IN ('paused', 'failed', 'running')`).run(runId)
|
||||
@@ -159,7 +167,7 @@ export class SupervisionReviewStore {
|
||||
this.initializeSources(runId, this.load(runId), signal)
|
||||
return
|
||||
}
|
||||
// A changed/deleted source cannot be silently counted as covered. Saved facts remain accessible.
|
||||
// Existing sources must still match their frozen versions.
|
||||
let after = ''
|
||||
for (;;) {
|
||||
const rows = this.db.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source > ? ORDER BY source LIMIT 200').all(runId, after)
|
||||
@@ -167,6 +175,7 @@ export class SupervisionReviewStore {
|
||||
for (const row of rows) {
|
||||
signal?.throwIfAborted()
|
||||
if (this.currentSource(String(row.source), 0, 0)?.current_revision !== row.revision) {
|
||||
if (this.omitDeletedConversations(runId)) return this.resume(runId, signal)
|
||||
this.sourceChanged(runId, String(row.source))
|
||||
}
|
||||
after = String(row.source)
|
||||
@@ -209,6 +218,7 @@ export class SupervisionReviewStore {
|
||||
}
|
||||
|
||||
chunk(runId: string, projectId: string, conversationId: string, config: ReviewConfiguration): SupervisionEvidence[] {
|
||||
this.omitDeletedConversations(runId)
|
||||
const { request } = this.load(runId)
|
||||
const evidence: SupervisionEvidence[] = []
|
||||
let remaining = config.batchCharacters
|
||||
@@ -226,7 +236,10 @@ export class SupervisionReviewStore {
|
||||
if (!rows.length) break
|
||||
for (const metadata of rows) {
|
||||
const current = this.currentSource(metadata.source, metadata.processed_offset, Math.min(remaining, 8000))
|
||||
if (!current || current.current_revision !== metadata.revision) this.sourceChanged(runId, metadata.source)
|
||||
if (!current || current.current_revision !== metadata.revision) {
|
||||
if (this.omitDeletedConversations(runId)) return this.chunk(runId, projectId, conversationId, config)
|
||||
this.sourceChanged(runId, metadata.source)
|
||||
}
|
||||
const row = { ...metadata, ...current }
|
||||
const locator = JSON.parse(row.locator) as Record<string, unknown>
|
||||
const messageId = String(locator.messageId ?? row.source)
|
||||
@@ -278,10 +291,13 @@ export class SupervisionReviewStore {
|
||||
save(runId: string, projectId: string, conversationId: string, evidence: SupervisionEvidence[], output: SupervisionSummaryOutput): void {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.omitDeletedConversations(runId)
|
||||
if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return }
|
||||
const advance = this.db.prepare(`UPDATE supervision_review_sources SET processed_offset = ?
|
||||
WHERE run_id = ? AND source = ? AND revision = ? AND processed_offset = ?`)
|
||||
for (const item of evidence) {
|
||||
const locator = item.locator!
|
||||
if (this.currentSource(String(locator.source), 0, 0)?.current_revision !== locator.revision) this.sourceChanged(runId, String(locator.source))
|
||||
if (advance.run(Number(locator.end), runId, String(locator.source), String(locator.revision), Number(locator.start)).changes !== 1) {
|
||||
throw new Error('Review coverage has a gap or duplicate batch')
|
||||
}
|
||||
@@ -293,6 +309,11 @@ export class SupervisionReviewStore {
|
||||
} catch (error) { this.db.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
|
||||
hasSources(runId: string, evidence: SupervisionEvidence[]): boolean {
|
||||
const source = this.db.prepare('SELECT 1 FROM supervision_review_sources WHERE run_id = ? AND source = ?')
|
||||
return evidence.every(item => source.get(runId, String(item.locator!.source)))
|
||||
}
|
||||
|
||||
batches(runId: string, limit = 10, offset = 0): SupervisionReviewBatch[] {
|
||||
return this.db.prepare(`SELECT * FROM supervision_review_batches WHERE run_id = ? ORDER BY project_id, conversation_id, rowid LIMIT ? OFFSET ?`)
|
||||
.all(runId, limit, offset).map(row => ({ id: String(row.id), projectId: String(row.project_id), conversationId: String(row.conversation_id),
|
||||
@@ -305,16 +326,76 @@ export class SupervisionReviewStore {
|
||||
const batches = this.db.prepare(`SELECT COUNT(*) AS batches, COALESCE(SUM(characters), 0) AS characters
|
||||
FROM supervision_review_batches WHERE run_id = ?`).get(runId)!
|
||||
const state = this.load(runId)
|
||||
const error = String(this.db.prepare('SELECT error FROM supervision_runs WHERE id = ?').get(runId)?.error ?? '')
|
||||
const failedSource = /Review source changed or was removed \((.+)\);/.exec(error)?.[1]
|
||||
const deletedFailure = failedSource && this.db.prepare(`SELECT 1 FROM supervision_review_sources s
|
||||
WHERE s.run_id = ? AND s.source = ? AND s.conversation_id != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = s.conversation_id)`).get(runId, failedSource)
|
||||
const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)!
|
||||
return { runId, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, restartRequired: state.restartRequired, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
|
||||
return { runId, omittedSources: state.omittedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, restartRequired: deletedFailure ? false : state.restartRequired, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
|
||||
remainingSources: Number(sources.remaining), complete: this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('completed', 'no_change')").get(runId) !== undefined }
|
||||
}
|
||||
|
||||
assertComplete(runId: string): void {
|
||||
assertComplete(runId: string, expectedOmissions?: number): void {
|
||||
if (this.omitDeletedConversations(runId) || (expectedOmissions !== undefined && (this.load(runId).omittedSources ?? 0) !== expectedOmissions)) {
|
||||
throw new ReviewSourcesOmitted('Deleted conversation sources omitted; rebuild review navigation')
|
||||
}
|
||||
if (this.load(runId).initializing) throw new Error('Cannot publish an incomplete source manifest')
|
||||
if (this.progress(runId).remainingSources) throw new Error('Cannot publish incomplete coverage')
|
||||
}
|
||||
|
||||
/** Only unpublished staging is disposable. No source bodies are read or restored here. */
|
||||
omitDeletedConversations(runId: string): boolean {
|
||||
if (!this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('running', 'paused', 'failed')").get(runId)) return false
|
||||
if (this.load(runId).initializing) return false
|
||||
const deleted = this.db.prepare(`SELECT s.source FROM supervision_review_sources s
|
||||
WHERE s.run_id = ? AND s.conversation_id != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = s.conversation_id)`).all(runId)
|
||||
if (!deleted.length) return false
|
||||
this.db.exec('SAVEPOINT omit_review_sources')
|
||||
try {
|
||||
const removed = new Set(deleted.map(row => String(row.source)))
|
||||
const affected = new Set(removed)
|
||||
const invalid = new Set<string>()
|
||||
// A mixed leaf cannot be filtered safely: its prose may combine every input.
|
||||
// Reset its surviving sources and discard their other leaves to avoid duplicate coverage.
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (let offset = 0;; offset += 10) {
|
||||
const batches = this.db.prepare(`SELECT b.id, json_group_array(json_extract(item.value, '$.locator.source')) AS sources
|
||||
FROM (SELECT id, evidence_json FROM supervision_review_batches WHERE run_id = ? ORDER BY rowid LIMIT 10 OFFSET ?) b,
|
||||
json_each(b.evidence_json) item GROUP BY b.id`).all(runId, offset)
|
||||
if (!batches.length) break
|
||||
for (const batch of batches) {
|
||||
const sources = JSON.parse(String(batch.sources)) as string[]
|
||||
if (invalid.has(String(batch.id)) || !sources.some(source => affected.has(source))) continue
|
||||
invalid.add(String(batch.id))
|
||||
for (const source of sources) affected.add(source)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const invalidIds = JSON.stringify([...invalid])
|
||||
this.db.prepare(`WITH RECURSIVE invalid(id) AS (
|
||||
SELECT value FROM json_each(?) UNION
|
||||
SELECT n.id FROM supervision_review_navigation n, json_each(n.children_json) child
|
||||
JOIN invalid i ON i.id = child.value WHERE n.run_id = ?
|
||||
) DELETE FROM supervision_review_navigation WHERE run_id = ? AND id IN (SELECT id FROM invalid)`)
|
||||
.run(invalidIds, runId, runId)
|
||||
this.db.prepare('DELETE FROM supervision_review_batches WHERE run_id = ? AND id IN (SELECT value FROM json_each(?))').run(runId, invalidIds)
|
||||
for (const source of affected) this.db.prepare('UPDATE supervision_review_sources SET processed_offset = initial_offset WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
for (const source of removed) this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
const error = String(this.db.prepare('SELECT error FROM supervision_runs WHERE id = ?').get(runId)?.error ?? '')
|
||||
const failedSource = /Review source changed or was removed \((.+)\);/.exec(error)?.[1]
|
||||
if (failedSource && removed.has(failedSource)) this.db.prepare("UPDATE supervision_review_runs SET state_json = json_remove(state_json, '$.restartRequired') WHERE run_id = ?").run(runId)
|
||||
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(state_json,
|
||||
'$.omittedSources', COALESCE(json_extract(state_json, '$.omittedSources'), 0) + ?, '$.phase', 'extracting') WHERE run_id = ?`).run(removed.size, runId)
|
||||
this.db.exec('RELEASE omit_review_sources')
|
||||
return true
|
||||
} catch (error) { this.db.exec('ROLLBACK TO omit_review_sources; RELEASE omit_review_sources'); throw error }
|
||||
}
|
||||
|
||||
commitCheckpoints(runId: string, request: SupervisionRunRequest): void {
|
||||
this.assertComplete(runId)
|
||||
if (!this.db.isTransaction) throw new Error('Review publication must be transactional')
|
||||
|
||||
@@ -433,3 +433,128 @@ it('does not let a heartbeat silently resume a review the user paused', async ()
|
||||
expect(next.runId).not.toBe(pausedId)
|
||||
expect(f.db.listSupervisionActivity().find(row => row.id === pausedId)?.status).toBe('paused')
|
||||
})
|
||||
|
||||
it('omits a deleted conversation, its knowledge and tasks before resume without changing published history', async () => {
|
||||
const f = await fixture([['Delete me'], ['Keep me']])
|
||||
const deleted = f.conversations[0]!
|
||||
f.sql.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?').run(JSON.stringify({ sourceReferences: [
|
||||
{ documentId: 'doc', documentName: 'Reference', snippet: 'Deleted knowledge', chunkId: 'chunk' }
|
||||
] }), deleted.messages[0]!.id)
|
||||
const taskId = randomUUID()
|
||||
f.db.createTask({ id: taskId, conversationId: deleted.id, projectId: deleted.projectId,
|
||||
title: 'Deleted task', instructions: 'Test', origin: 'user', visible: true })
|
||||
f.db.updateTaskStatus(taskId, 'completed')
|
||||
f.sql.prepare('UPDATE tasks SET created_at = ?, completed_at = ? WHERE id = ?').run(new Date(time).toISOString(), new Date(time).toISOString(), taskId)
|
||||
await f.service().run(request)
|
||||
const history = f.sql.prepare('SELECT * FROM supervision_results').all()
|
||||
const historySources = f.sql.prepare('SELECT * FROM supervision_sources ORDER BY id').all()
|
||||
const historyBatches = f.sql.prepare('SELECT * FROM supervision_review_batches ORDER BY id').all()
|
||||
f.sql.exec("CREATE TRIGGER fail_publication BEFORE INSERT ON supervision_results BEGIN SELECT RAISE(ABORT, 'Publication failed'); END")
|
||||
await expect(f.service().run(request)).rejects.toThrow('Publication failed')
|
||||
const runId = String(f.sql.prepare("SELECT id FROM supervision_runs WHERE status = 'failed'").get()!.id)
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const kept = store.batches(runId).filter(batch => batch.conversationId !== deleted.id)
|
||||
expect(f.db.deleteLocalConversation(deleted.id)).toBe(true)
|
||||
expect(store.progress(runId)).toMatchObject({ omittedSources: 3, sources: 1, batches: kept.length, restartRequired: undefined })
|
||||
expect(store.batches(runId)).toEqual(kept)
|
||||
f.sql.exec('DROP TRIGGER fail_publication')
|
||||
const calls = f.summarize.mock.calls.length
|
||||
f.db.close(); f.db.initialize(f.directory)
|
||||
const result = await f.service().resume(runId)
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { omittedSources: 3, sources: 1, complete: true } })
|
||||
expect(f.summarize).toHaveBeenCalledTimes(calls)
|
||||
expect(f.sql.prepare('SELECT * FROM supervision_results WHERE id = ?').get(String(history[0]!.id))).toEqual(history[0])
|
||||
expect(f.sql.prepare('SELECT * FROM supervision_sources WHERE result_id = ? ORDER BY id').all(String(history[0]!.id))).toEqual(historySources)
|
||||
expect(f.sql.prepare('SELECT * FROM supervision_review_batches WHERE run_id = ? ORDER BY id').all(String(history[0]!.run_id))).toEqual(historyBatches)
|
||||
expect(() => f.db.getConversation(deleted.id)).toThrow('对话不存在')
|
||||
expect(f.db.listSupervisionActivity().find(row => row.id === runId)?.reviewProgress?.omittedSources).toBe(3)
|
||||
})
|
||||
|
||||
it.each(['leaf', 'navigation', 'publication', 'publication from another connection'] as const)('continues after deletion during %s without publishing deleted input', async phase => {
|
||||
const f = await fixture([['Delete me'], ['Keep me']], { concurrency: 2 })
|
||||
const base = f.summarize.getMockImplementation()!
|
||||
f.summarize.mockImplementation(async input => ({ ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }))
|
||||
let deletedId: string | undefined
|
||||
const remove = () => {
|
||||
deletedId = f.conversations[0]!.id
|
||||
if (phase === 'publication from another connection') f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(deletedId)
|
||||
else f.db.deleteLocalConversation(deletedId)
|
||||
}
|
||||
if (phase.startsWith('publication')) {
|
||||
const save = f.db.saveSupervisionResult.bind(f.db)
|
||||
vi.spyOn(f.db, 'saveSupervisionResult').mockImplementationOnce(result => { remove(); save(result) })
|
||||
} else f.summarize.mockImplementation(async input => {
|
||||
if (!deletedId && (phase === 'leaf' ? input.evidence.some(item => item.locator?.conversationId === f.conversations[0]!.id) : input.evidence[0]?.sourceType === 'note')) remove()
|
||||
return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }
|
||||
})
|
||||
const result = await f.service().run({ ...request, reanalyze: undefined })
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { omittedSources: 1, sources: 1, batches: 1, complete: true } })
|
||||
expect(result.output.summary).toBe('Keep me')
|
||||
expect(f.db.listSupervisionResults()[0]!.summary).toBe('Keep me')
|
||||
const sources = f.sql.prepare('SELECT locator_json FROM supervision_sources').all()
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(JSON.parse(String(sources[0]!.locator_json)).conversationId).toBe(f.conversations[1]!.id)
|
||||
expect(f.sql.prepare('SELECT source FROM review_checkpoints').all()).toEqual([{ source: `message:${f.conversations[1]!.messages[0]!.id}` }])
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_review_navigation').get()!.n).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['before resume', 'in flight'] as const)('finishes with an explicit omission and no empty report when every source is deleted %s', async when => {
|
||||
const f = await fixture([['Only source']], { concurrency: 1 })
|
||||
const service = f.service()
|
||||
let runId: string
|
||||
if (when === 'before resume') {
|
||||
f.summarize.mockRejectedValueOnce(new Error('Provider unavailable'))
|
||||
await expect(service.run(request)).rejects.toThrow('Provider unavailable')
|
||||
runId = f.db.listSupervisionActivity()[0]!.id
|
||||
f.db.deleteLocalConversation(f.conversations[0]!.id)
|
||||
} else {
|
||||
f.summarize.mockImplementationOnce(async () => { f.db.deleteLocalConversation(f.conversations[0]!.id); return empty })
|
||||
const result = await service.run(request)
|
||||
runId = result.runId!
|
||||
expect(result).toMatchObject({ status: 'no_change', coverage: { omittedSources: 1, sources: 0, complete: true } })
|
||||
}
|
||||
if (when === 'before resume') await expect(service.resume(runId)).resolves.toMatchObject({ status: 'no_change', coverage: { omittedSources: 1, sources: 0, complete: true } })
|
||||
expect(f.db.listSupervisionResults()).toEqual([])
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('resets mixed-leaf survivors only and invalidates their dependent navigation transitively', async () => {
|
||||
const f = await fixture([['Deleted'], ['Survivor'], ['Unrelated']], { concurrency: 1 })
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const runId = f.db.startSupervisionRun(request)
|
||||
store.initialize(runId, { request, config: f.config })
|
||||
const evidence = f.conversations.map(c => store.chunk(runId, c.projectId, c.id, f.config))
|
||||
store.save(runId, f.conversations[0]!.projectId, '', [...evidence[0]!, ...evidence[1]!], empty)
|
||||
store.save(runId, f.conversations[2]!.projectId, f.conversations[2]!.id, evidence[2]!, empty)
|
||||
const batches = store.batches(runId)
|
||||
const mixed = batches.find(b => b.evidence.length === 2)!, kept = batches.find(b => b.evidence.length === 1)!
|
||||
store.saveNavigation(runId, 'affected', [mixed.id], empty)
|
||||
store.saveNavigation(runId, 'unaffected', [kept.id], empty)
|
||||
store.saveNavigation(runId, 'root', ['affected', 'unaffected'], empty)
|
||||
f.db.deleteLocalConversation(f.conversations[0]!.id)
|
||||
expect(store.batches(runId)).toEqual([kept])
|
||||
expect(store.navigation(runId, 'affected')).toBeUndefined()
|
||||
expect(store.navigation(runId, 'root')).toBeUndefined()
|
||||
expect(store.navigation(runId, 'unaffected')).toEqual(empty)
|
||||
expect(store.progress(runId)).toMatchObject({ sources: 2, remainingSources: 1, omittedSources: 1 })
|
||||
await f.service().resume(runId)
|
||||
expect(f.summarize.mock.calls.filter(([input]) => input.evidence[0]?.sourceType !== 'note').map(([input]) => input.evidence.map(e => e.content))).toEqual([['Survivor']])
|
||||
expect(store.batches(runId)).toContainEqual(kept)
|
||||
})
|
||||
|
||||
it('recovers the earlier deletion failure but keeps archived and modified existing conversations as explicit failures', async () => {
|
||||
const f = await fixture([['Source']])
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const id = f.db.startSupervisionRun(request)
|
||||
store.initialize(id, { request, config: f.config })
|
||||
f.sql.prepare("UPDATE supervision_review_runs SET state_json = json_set(state_json, '$.restartRequired', json('true')) WHERE run_id = ?").run(id)
|
||||
f.db.failSupervisionRun(id, `Review source changed or was removed (message:${f.conversations[0]!.messages[0]!.id}); start a new review`)
|
||||
f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(f.conversations[0]!.id)
|
||||
expect(store.progress(id).restartRequired).toBe(false)
|
||||
await expect(f.service().resume(id)).resolves.toMatchObject({ status: 'no_change', coverage: { omittedSources: 1 } })
|
||||
const g = await fixture([['Archived source']])
|
||||
g.summarize.mockRejectedValueOnce(new Error('Pause'))
|
||||
await expect(g.service().run(request)).rejects.toThrow('Pause')
|
||||
g.sql.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?").run(g.conversations[0]!.id)
|
||||
await expect(g.service().resume(g.db.listSupervisionActivity()[0]!.id)).rejects.toThrow('source changed')
|
||||
})
|
||||
|
||||
@@ -64,7 +64,8 @@ it('preserves every source, revision, Unicode length and checkpoint for global,
|
||||
const id = f.db.startSupervisionRun(state.request)
|
||||
await f.db.initializeSupervisionReview(id, state, new AbortController().signal)
|
||||
const expected = rows.filter(row => scope.kind === 'global' || row.project_id === f.project.id).map(row => ({
|
||||
source: row.source, revision: hash(row.context), project_id: row.project_id, conversation_id: row.conversation_id,
|
||||
source: row.source, revision: hash(row.context), project_id: row.project_id,
|
||||
conversation_id: row.type === 'task' ? f.conversations[0]!.id : row.conversation_id,
|
||||
sequence: row.sequence, length: Array.from(String(row.body)).length,
|
||||
initial_offset: !reanalyze && row.source === source.source ? 3 : 0,
|
||||
processed_offset: !reanalyze && row.source === source.source ? 3 : 0
|
||||
@@ -137,6 +138,28 @@ it('fails explicitly when the worker entry is missing instead of running the sca
|
||||
expect(f.db.supervisionReviewStore().progress(id).sources).toBe(0)
|
||||
})
|
||||
|
||||
it('worker resume omits hard-deleted owners including knowledge references and tasks', async () => {
|
||||
const f = await fixture()
|
||||
const id = f.db.startSupervisionRun(f.state.request)
|
||||
await f.db.initializeSupervisionReview(id, f.state, new AbortController().signal)
|
||||
f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(f.conversations[0]!.id)
|
||||
await f.db.resumeSupervisionReview(id, new AbortController().signal)
|
||||
expect(f.db.supervisionReviewStore().progress(id)).toMatchObject({ sources: 210, remainingSources: 210, omittedSources: 212 })
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_review_sources WHERE run_id = ? AND conversation_id = ?').get(id, f.conversations[0]!.id)!.n).toBe(0)
|
||||
})
|
||||
|
||||
it('drops conversation sources deleted while the worker copies its initialization manifest', async () => {
|
||||
const f = await fixture()
|
||||
const id = f.db.startSupervisionRun(f.state.request)
|
||||
f.sql.exec(`CREATE TRIGGER delete_during_manifest AFTER INSERT ON supervision_review_sources
|
||||
WHEN (SELECT COUNT(*) FROM supervision_review_sources) = 200 BEGIN
|
||||
DELETE FROM conversations WHERE id = '${f.conversations[0]!.id}';
|
||||
END`)
|
||||
await f.db.initializeSupervisionReview(id, f.state, new AbortController().signal)
|
||||
expect(f.db.supervisionReviewStore().progress(id)).toMatchObject({ sources: 210, omittedSources: 212, remainingSources: 210 })
|
||||
expect(() => f.db.getConversation(f.conversations[0]!.id)).toThrow('对话不存在')
|
||||
})
|
||||
|
||||
it('publishes full worker-selected coverage atomically and skips it on the next production review', async () => {
|
||||
const f = await fixture()
|
||||
const pool = new SupervisionModelPool()
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user