fix: keep supervisor controls usable and prevent overlapping reviews
Allow only one active review across manual starts, automatic runs and resume. Add cancellation that holds admission through runtime cleanup, preserves saved batches and prevents cancelled runs from resuming. Keep review controls usable during execution, show a dismissible progress notice and remove redundant filter labels. Include focused regressions and lifecycle documentation. Release note: Supervision reviews can now be cancelled from Activity records. Starting another review shows the active review instead of duplicating work, while scope, time and refresh controls remain available.
This commit is contained in:
@@ -8543,13 +8543,15 @@ export class AssistantDatabase {
|
||||
UNION ALL
|
||||
SELECT h.id, 'heartbeat', h.trigger,
|
||||
CASE WHEN h.status = 'claimed' THEN 'running'
|
||||
WHEN s.status = 'cancelled' THEN 'cancelled'
|
||||
WHEN h.status = 'failed' OR s.status = 'failed' OR h.projection_status = 'failed' THEN 'failed'
|
||||
WHEN s.status = 'running' OR (h.status IN ('completed', 'no_change') AND h.projection_status = 'running') THEN 'running'
|
||||
WHEN s.status = 'paused' THEN 'paused'
|
||||
WHEN s.status = 'completed' THEN 'completed'
|
||||
ELSE h.status END,
|
||||
COALESCE(s.scope_json, h.activity_scope_json), COALESCE(h.started_at, h.created_at),
|
||||
CASE WHEN s.status = 'running' OR h.status = 'claimed' OR h.projection_status = 'running' THEN NULL
|
||||
CASE WHEN s.status = 'cancelled' THEN s.completed_at
|
||||
WHEN s.status = 'running' OR h.status = 'claimed' OR h.projection_status = 'running' THEN NULL
|
||||
ELSE COALESCE(s.completed_at, h.projection_completed_at, h.completed_at) END,
|
||||
s.time_range_json, COALESCE(s.error, h.projection_error, h.error), COALESCE(r.summary, e.summary), r.id,
|
||||
h.status, s.status, s.id
|
||||
|
||||
@@ -121,10 +121,12 @@ export class SupervisionReviewStore {
|
||||
}
|
||||
|
||||
resume(runId: string): 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: 此回顾已取消,不能继续。请开始新的回顾。')
|
||||
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)
|
||||
if (!changed.changes) throw new Error('Review is already complete or unavailable')
|
||||
if (!changed.changes) throw new Error('SUPERVISION_REVIEW_NOT_RESUMABLE: 此回顾已完成或不存在,请开始新的回顾。')
|
||||
// A changed/deleted source cannot be silently counted as covered. Saved facts remain accessible.
|
||||
let after = ''
|
||||
for (;;) {
|
||||
@@ -144,6 +146,14 @@ export class SupervisionReviewStore {
|
||||
.run(reason, new Date().toISOString(), runId)
|
||||
}
|
||||
|
||||
cancel(runId: string): void {
|
||||
const changed = this.db.prepare(`UPDATE supervision_runs SET status = 'cancelled', error = NULL,
|
||||
completed_at = CASE WHEN status = 'cancelled' THEN completed_at ELSE ? END
|
||||
WHERE id = ? AND status IN ('running', 'paused', 'failed', 'cancelled')`)
|
||||
.run(new Date().toISOString(), runId)
|
||||
if (!changed.changes) throw new Error('SUPERVISION_REVIEW_NOT_CANCELLABLE: 此回顾已完成或不存在,无需取消。')
|
||||
}
|
||||
|
||||
setPhase(runId: string, phase: NonNullable<SupervisionReviewProgress['phase']>): void {
|
||||
this.db.prepare("UPDATE supervision_review_runs SET state_json = json_set(state_json, '$.phase', ?) WHERE run_id = ?").run(phase, runId)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AssistantDatabase } from './assistant-database'
|
||||
import { SupervisorService, type SupervisorSummarizerRequest } from './supervisor-service'
|
||||
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
|
||||
import type { ReviewConfiguration } from './supervision-review-store'
|
||||
import { HeartbeatService } from './heartbeat-service'
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = []
|
||||
afterEach(async () => { vi.useRealTimers(); for (const cleanup of cleanups.splice(0)) await cleanup() })
|
||||
@@ -17,6 +18,165 @@ const request: SupervisionRunRequest = { trigger: 'manual', scope: { kind: 'glob
|
||||
} }
|
||||
const empty = { summary: 'Navigation', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(done => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
it('reserves before setup, rejects manual and resume overlap, and lets automatic work wait for settlement', async () => {
|
||||
const f = await fixture([['Evidence']])
|
||||
const service = f.service()
|
||||
const entered = deferred(), finish = deferred()
|
||||
f.summarize.mockImplementationOnce(async () => { entered.resolve(); await finish.promise; return empty })
|
||||
const first = service.run(request)
|
||||
expect(service.execution()).toMatchObject({ active: true })
|
||||
await expect(service.run(request)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
|
||||
await entered.promise
|
||||
const runId = service.execution().runId!
|
||||
await expect(service.resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
|
||||
const automatic = service.run({ ...request, trigger: 'heartbeat' })
|
||||
const nextAutomatic = service.run({ ...request, trigger: 'heartbeat' })
|
||||
expect(f.db.listSupervisionActivity()).toHaveLength(1)
|
||||
finish.resolve()
|
||||
await expect(first).resolves.toMatchObject({ status: 'completed' })
|
||||
await expect(automatic).resolves.toMatchObject({ status: 'completed' })
|
||||
await expect(nextAutomatic).resolves.toMatchObject({ status: 'no_change' })
|
||||
expect(service.execution().active).toBe(false)
|
||||
expect(f.db.listSupervisionActivity()).toHaveLength(3)
|
||||
expect(f.summarize).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('persists cancellation immediately but holds the slot until every in-flight batch settles', async () => {
|
||||
const f = await fixture([['First'], ['Second']])
|
||||
const service = f.service()
|
||||
const entered = deferred(), finish = deferred()
|
||||
const signals: AbortSignal[] = []
|
||||
f.summarize.mockImplementation(async input => {
|
||||
signals.push(input.signal!)
|
||||
if (signals.length === 2) entered.resolve()
|
||||
await finish.promise
|
||||
return empty // Simulate a provider that returns a late success after abort.
|
||||
})
|
||||
const pending = service.run({ ...request, trigger: 'heartbeat' })
|
||||
await entered.promise
|
||||
const runId = service.execution().runId!
|
||||
let cancelled = false
|
||||
const cancellation = service.cancel(runId).then(() => { cancelled = true })
|
||||
expect(f.db.listSupervisionActivity()[0]).toMatchObject({ status: 'cancelled', supervisionStatus: 'cancelled', resultId: null })
|
||||
expect(signals.every(signal => signal.aborted)).toBe(true)
|
||||
expect(service.execution()).toEqual({ active: true, runId, stopping: 'cancelled' })
|
||||
await expect(service.run(request)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
|
||||
await expect(service.resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
|
||||
expect(cancelled).toBe(false)
|
||||
service.pause(runId)
|
||||
finish.resolve()
|
||||
await cancellation
|
||||
await expect(pending).resolves.toMatchObject({ status: 'cancelled' })
|
||||
expect(service.execution().active).toBe(false)
|
||||
expect(f.db.supervisionReviewStore().batches(runId)).toEqual([])
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
|
||||
expect(f.db.listSupervisionResults()).toEqual([])
|
||||
await expect(service.resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED')
|
||||
expect(f.db.listSupervisionActivity()[0]!.status).toBe('cancelled')
|
||||
await service.cancel(runId)
|
||||
f.summarize.mockResolvedValue(empty)
|
||||
const next = await service.run({ ...request, trigger: 'heartbeat' })
|
||||
expect(next.runId).not.toBe(runId)
|
||||
expect(next.status).toBe('completed')
|
||||
})
|
||||
|
||||
it.each(['paused', 'failed'] as const)('allows a new review after %s and can cancel retained work without publishing checkpoints', async status => {
|
||||
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 })
|
||||
const service = f.service()
|
||||
f.summarize.mockResolvedValueOnce(empty).mockImplementationOnce(async () => {
|
||||
if (status === 'failed') throw new Error('Provider failed')
|
||||
service.pause(service.execution().runId!)
|
||||
return empty
|
||||
})
|
||||
const first = service.run({ ...request, trigger: 'heartbeat' })
|
||||
if (status === 'failed') await expect(first).rejects.toThrow('Provider failed')
|
||||
else await expect(first).resolves.toMatchObject({ status })
|
||||
const runId = f.db.listSupervisionActivity()[0]!.id
|
||||
expect(f.db.supervisionReviewStore().batches(runId)).toHaveLength(1)
|
||||
await expect(service.run(request)).resolves.toMatchObject({ status: 'completed' })
|
||||
await service.cancel(runId)
|
||||
await expect(service.resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED')
|
||||
expect(f.db.supervisionReviewStore().batches(runId)).toHaveLength(1)
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
|
||||
f.db.close(); f.db.initialize(f.directory)
|
||||
expect(f.db.listSupervisionActivity().find(row => row.id === runId)!.status).toBe('cancelled')
|
||||
await expect(f.service().resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED')
|
||||
})
|
||||
|
||||
it('cancels navigation without publishing fully extracted source checkpoints', async () => {
|
||||
const f = await fixture([['x'.repeat(1500)]], { concurrency: 1 })
|
||||
const service = f.service()
|
||||
const entered = deferred(), finish = deferred()
|
||||
f.summarize.mockResolvedValueOnce(empty).mockResolvedValueOnce(empty).mockImplementationOnce(async () => {
|
||||
entered.resolve(); await finish.promise; return empty
|
||||
})
|
||||
const pending = service.run({ ...request, trigger: 'heartbeat' })
|
||||
await entered.promise
|
||||
const runId = service.execution().runId!
|
||||
const cancellation = service.cancel(runId)
|
||||
finish.resolve()
|
||||
await cancellation
|
||||
await expect(pending).resolves.toMatchObject({ status: 'cancelled', coverage: { batches: 2, remainingSources: 0, complete: false } })
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_review_navigation').get()!.n).toBe(0)
|
||||
expect(f.db.listSupervisionResults()).toEqual([])
|
||||
})
|
||||
|
||||
it('projects cancellation into heartbeat activity while runtime cleanup is still pending', async () => {
|
||||
const f = await fixture([['Evidence']])
|
||||
const service = f.service()
|
||||
const entered = deferred(), finish = deferred()
|
||||
f.summarize.mockImplementationOnce(async () => { entered.resolve(); await finish.promise; return empty })
|
||||
const heartbeat = new HeartbeatService(f.db, { summarize: async () => ({ summary: 'Report', highlights: [], proposedMemories: [], followUpTasks: [] }) }, () => {},
|
||||
async ({ run }) => { await service.run({ ...request, trigger: 'heartbeat' }, run.id) })
|
||||
const config = heartbeat.create({ name: 'Review', scope: request.scope, timezone: 'UTC',
|
||||
recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 24, retentionDays: 30 })
|
||||
const pending = heartbeat.runNow({ id: config.id, idempotencyKey: randomUUID() })
|
||||
await entered.promise
|
||||
const runId = service.execution().runId!
|
||||
const cancellation = service.cancel(runId)
|
||||
expect(f.db.listSupervisionActivity()).toEqual([expect.objectContaining({ kind: 'heartbeat', status: 'cancelled',
|
||||
heartbeatStatus: 'completed', supervisionStatus: 'cancelled', completedAt: expect.any(String), reviewProgress: expect.objectContaining({ runId }) })])
|
||||
finish.resolve()
|
||||
await cancellation
|
||||
await pending
|
||||
expect(f.db.listSupervisionActivity()[0]!.status).toBe('cancelled')
|
||||
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints WHERE stage = ?').get('supervisor')!.n).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['paused', 'failed', 'cancelled'] as const)('wakes waiting automatic work after %s without overlapping requests', async status => {
|
||||
const f = await fixture([['Evidence']])
|
||||
const service = f.service()
|
||||
const entered = deferred(), finish = deferred()
|
||||
f.summarize.mockImplementationOnce(async () => {
|
||||
entered.resolve(); await finish.promise
|
||||
if (status === 'failed') throw new Error('Provider failed')
|
||||
return empty
|
||||
})
|
||||
const pending = service.run(request)
|
||||
const outcome = status === 'failed' ? expect(pending).rejects.toThrow('Provider failed')
|
||||
: expect(pending).resolves.toMatchObject({ status })
|
||||
await entered.promise
|
||||
const runId = service.execution().runId!
|
||||
const automatic = service.run({ ...request, trigger: 'heartbeat' })
|
||||
const cancellation = status === 'cancelled' ? service.cancel(runId) : undefined
|
||||
if (status === 'paused') service.pause(runId)
|
||||
expect(f.summarize).toHaveBeenCalledTimes(1)
|
||||
expect(f.db.listSupervisionActivity()).toHaveLength(1)
|
||||
finish.resolve()
|
||||
await cancellation
|
||||
await outcome
|
||||
await expect(automatic).resolves.toMatchObject({ status: 'completed' })
|
||||
expect(f.summarize).toHaveBeenCalledTimes(2)
|
||||
expect(service.execution().active).toBe(false)
|
||||
})
|
||||
|
||||
async function fixture(contents: string[][], overrides: Partial<ReviewConfiguration> = {}) {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-review-'))
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { reviewScope, type ReviewBatch } from './review-checkpoint'
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { SupervisionReviewStore, ReviewConfiguration } from './supervision-review-store'
|
||||
import type { SupervisionReviewProgress } from '../../shared/supervision-review-contracts'
|
||||
import type { SupervisionReviewExecution, SupervisionReviewProgress } from '../../shared/supervision-review-contracts'
|
||||
|
||||
export type SupervisorSummarizerRequest = {
|
||||
signal?: AbortSignal
|
||||
@@ -36,7 +36,7 @@ export interface SupervisorEvidenceCollector {
|
||||
|
||||
export type StoredSupervisionResult = {
|
||||
batch?: ReviewBatch
|
||||
status?: 'completed' | 'no_change' | 'paused'
|
||||
status?: 'completed' | 'no_change' | 'paused' | 'cancelled'
|
||||
coverage?: SupervisionReviewProgress
|
||||
runId?: string
|
||||
candidates?: SupervisionCandidate[]
|
||||
@@ -170,7 +170,7 @@ function validateReferences(
|
||||
}
|
||||
|
||||
export class SupervisorService {
|
||||
private pending: Promise<unknown> = Promise.resolve()
|
||||
private active?: { settled: Promise<unknown>; runId?: string; stopping?: 'paused' | 'cancelled' }
|
||||
private readonly controllers = new Map<string, AbortController>()
|
||||
private readonly activity = new Map<string, { inFlight: number }>()
|
||||
constructor(
|
||||
@@ -181,9 +181,24 @@ export class SupervisorService {
|
||||
) {}
|
||||
|
||||
async run(input: unknown, heartbeatRunId?: string): Promise<StoredSupervisionResult> {
|
||||
const result = this.pending.catch(() => undefined).then(() => this.execute(input, heartbeatRunId))
|
||||
this.pending = result
|
||||
return result
|
||||
const request = supervisionRunRequestSchema.parse(input)
|
||||
return this.admit(() => this.execute(request, heartbeatRunId), request.trigger === 'heartbeat')
|
||||
}
|
||||
|
||||
execution(): SupervisionReviewExecution {
|
||||
return { active: Boolean(this.active), runId: this.active?.runId, stopping: this.active?.stopping }
|
||||
}
|
||||
|
||||
private async admit(operation: () => Promise<StoredSupervisionResult>, wait = false): Promise<StoredSupervisionResult> {
|
||||
while (this.active) {
|
||||
if (!wait) throw new Error('SUPERVISION_REVIEW_BUSY: 已有回顾正在执行或停止中,请等待结束后再开始或继续回顾。')
|
||||
await this.active.settled.catch(() => undefined)
|
||||
}
|
||||
// Reserve before any asynchronous configuration, collection or resume work.
|
||||
const active = { settled: Promise.resolve().then(operation) }
|
||||
this.active = active
|
||||
try { return await active.settled }
|
||||
finally { if (this.active === active) this.active = undefined }
|
||||
}
|
||||
|
||||
private async execute(input: unknown, heartbeatRunId?: string): Promise<StoredSupervisionResult> {
|
||||
@@ -240,18 +255,28 @@ export class SupervisorService {
|
||||
}
|
||||
|
||||
pause(runId: string): void {
|
||||
if (this.active?.runId === runId && this.active.stopping !== 'cancelled') this.active.stopping = 'paused'
|
||||
this.controllers.get(runId)?.abort(new Error('Review paused by user'))
|
||||
}
|
||||
|
||||
async cancel(runId: string): Promise<void> {
|
||||
if (!this.review) throw new Error('SUPERVISION_REVIEW_UNAVAILABLE: 此回顾不支持取消。')
|
||||
this.review.database().cancel(runId)
|
||||
const active = this.active?.runId === runId ? this.active : undefined
|
||||
if (active) {
|
||||
active.stopping = 'cancelled'
|
||||
this.controllers.get(runId)?.abort(new Error('Review cancelled by user'))
|
||||
await active.settled.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
progress(progress: SupervisionReviewProgress): SupervisionReviewProgress {
|
||||
return { ...progress, inFlight: 0, ...this.activity.get(progress.runId) }
|
||||
}
|
||||
|
||||
async resume(runId: string): Promise<StoredSupervisionResult> {
|
||||
if (!this.review) throw new Error('Resumable reviews are unavailable')
|
||||
const operation = this.pending.catch(() => undefined).then(() => this.executeReview(undefined, undefined, runId))
|
||||
this.pending = operation
|
||||
return operation
|
||||
return this.admit(() => this.executeReview(undefined, undefined, runId))
|
||||
}
|
||||
|
||||
private async executeReview(input?: SupervisionRunRequest, heartbeatRunId?: string, resumeId?: string): Promise<StoredSupervisionResult> {
|
||||
@@ -260,14 +285,16 @@ export class SupervisorService {
|
||||
const state = existing ? db.load(existing) : { request: input!, config: await this.review!.configuration() }
|
||||
const { request, config } = state
|
||||
const runId = existing ?? this.store.start!(request, heartbeatRunId)
|
||||
if (this.active) this.active.runId = runId
|
||||
const controller = new AbortController()
|
||||
this.controllers.set(runId, controller)
|
||||
const activity = { inFlight: 0 }
|
||||
this.activity.set(runId, activity)
|
||||
const empty: SupervisionSummaryOutput = { summary: 'No new evidence', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
const paused = () => {
|
||||
db.pause(runId, 'Review paused by user')
|
||||
return { runId, request, evidence: [], output: empty, status: 'paused' as const, coverage: db.progress(runId) }
|
||||
const stopped = (): StoredSupervisionResult => {
|
||||
const status = this.active?.stopping === 'cancelled' ? 'cancelled' : 'paused'
|
||||
if (status === 'paused') db.pause(runId, 'Review paused by user')
|
||||
return { runId, request, evidence: [], output: empty, status, coverage: db.progress(runId) }
|
||||
}
|
||||
try {
|
||||
if (existing) db.resume(runId)
|
||||
@@ -296,7 +323,7 @@ export class SupervisorService {
|
||||
return output
|
||||
}
|
||||
for (;;) {
|
||||
if (controller.signal.aborted) return paused()
|
||||
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 => {
|
||||
@@ -307,7 +334,7 @@ export class SupervisorService {
|
||||
controller.signal.throwIfAborted()
|
||||
db.save(runId, group.projectId, group.conversationId, evidence, output)
|
||||
}))
|
||||
if (controller.signal.aborted) return paused()
|
||||
if (controller.signal.aborted) return stopped()
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
}
|
||||
@@ -325,6 +352,7 @@ export class SupervisorService {
|
||||
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 }))
|
||||
const output = await summarize(evidence, true)
|
||||
controller.signal.throwIfAborted()
|
||||
db.saveNavigation(runId, id, [left.id, right.id], output)
|
||||
return { id, output }
|
||||
}
|
||||
@@ -375,7 +403,7 @@ export class SupervisorService {
|
||||
await this.store.save(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return paused()
|
||||
if (controller.signal.aborted) return stopped()
|
||||
this.store.fail?.(runId, error instanceof Error ? error.message : 'Review failed')
|
||||
throw error
|
||||
} finally { this.controllers.delete(runId); this.activity.delete(runId) }
|
||||
|
||||
Reference in New Issue
Block a user