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:
mesalogo
2026-09-26 15:55:50 +08:00
parent 0759d5dcff
commit 5116613615
30 changed files with 653 additions and 92 deletions
+3 -1
View File
@@ -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
+11 -1
View File
@@ -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')
+43 -15
View File
@@ -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) }
+48
View File
@@ -5896,6 +5896,54 @@ describe('registerIpcHandlers agent terminal state', () => {
} finally { finish(); await harness.dispose(); database.close(); vi.useRealTimers() }
})
it('production supervision IPC cancels the runtime and holds admission until conversation cleanup settles', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const now = Date.now()
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Cancel review', updatedAt: now },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this', createdAt: now }] }])
let entered!: () => void, cleaning!: () => void, finish!: () => void
const started = new Promise<void>(resolve => { entered = resolve })
const cleanupStarted = new Promise<void>(resolve => { cleaning = resolve })
const cleanup = new Promise<void>(resolve => { finish = resolve })
const run = vi.fn(async function* (_input: AgentExecutionRequest, signal: AbortSignal) {
entered()
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
signal.throwIfAborted()
yield { type: 'done' as const, requestId: _input.requestId }
})
const releaseConversation = vi.fn(async () => { cleaning(); await cleanup })
const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, 'always', undefined, false,
undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
const event = trustedEvent(harness.webContents)
const request = { trigger: 'manual', scope: { kind: 'global' }, timeRange: {
from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString()
} }
const pending = electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, request)
await started
const runId = database.listSupervisionActivity()[0]!.id
const input = { runId }
await expect(electronMocks.handlers.get(ipcChannels.supervisionCancel)!({ sender: {} }, input)).rejects.toThrow()
await expect(electronMocks.handlers.get(ipcChannels.supervisionCancel)!(event, { runId: 'invalid' })).rejects.toThrow()
const cancellation = electronMocks.handlers.get(ipcChannels.supervisionCancel)!(event, input)
await cleanupStarted
expect(database.listSupervisionActivity()[0]!.status).toBe('cancelled')
expect(electronMocks.handlers.get(ipcChannels.supervisionExecution)!(event)).toEqual({ active: true, runId, stopping: 'cancelled' })
await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, request)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).rejects.toThrow('SUPERVISION_REVIEW_BUSY')
finish()
await cancellation
await expect(pending).resolves.toMatchObject({ status: 'cancelled' })
expect(electronMocks.handlers.get(ipcChannels.supervisionExecution)!(event)).toMatchObject({ active: false })
await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED')
expect(database.listSupervisionResults()).toEqual([])
expect(run).toHaveBeenCalledTimes(1)
expect(releaseConversation).toHaveBeenCalledTimes(1)
} finally { finish(); await harness.dispose(); database.close() }
})
it('production supervision IPC pauses in-flight work and resumes saved batches without replaying them', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
+9
View File
@@ -7840,6 +7840,15 @@ export function registerIpcHandlers(
const { runId } = supervisionReviewIdSchema.parse(input)
supervisorService.pause(runId)
})
registerHandler(ipcChannels.supervisionExecution, (event) => {
assertTrustedSender(event, window)
return supervisorService.execution()
})
registerHandler(ipcChannels.supervisionCancel, async (event, input: unknown) => {
assertTrustedSender(event, window)
const { runId } = supervisionReviewIdSchema.parse(input)
await supervisorService.cancel(runId)
})
registerHandler(ipcChannels.supervisionResume, async (event, input: unknown) => {
assertTrustedSender(event, window)
const { runId } = supervisionReviewIdSchema.parse(input)
+2
View File
@@ -1382,6 +1382,8 @@ const desktopApi: DesktopApi = {
supervision: {
activity: (input) => ipcRenderer.invoke(ipcChannels.supervisionActivity, input),
pause: (input) => ipcRenderer.invoke(ipcChannels.supervisionPause, input),
cancel: (input) => ipcRenderer.invoke(ipcChannels.supervisionCancel, input),
execution: () => ipcRenderer.invoke(ipcChannels.supervisionExecution),
resume: (input) => ipcRenderer.invoke(ipcChannels.supervisionResume, input),
batches: (input) => ipcRenderer.invoke(ipcChannels.supervisionBatches, input),
overview: (input) => ipcRenderer.invoke(ipcChannels.supervisionOverview, input),
+4
View File
@@ -21,6 +21,10 @@ it('reads activity and exact review results through the explicit bridge', async
expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionOverview, { resultId: 'old-result' })
await api.supervision.pause({ runId: 'run' })
expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionPause, { runId: 'run' })
await api.supervision.cancel({ runId: 'run' })
expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionCancel, { runId: 'run' })
await api.supervision.execution()
expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionExecution)
await api.supervision.resume({ runId: 'run' })
expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionResume, { runId: 'run' })
await api.supervision.batches({ runId: 'run', offset: 10, limit: 10 })
+2
View File
@@ -691,6 +691,8 @@ const api: DesktopApi = {
history: vi.fn(async () => ({ runs: [], entries: [] })),
},
supervision: {
cancel: vi.fn(async () => undefined),
execution: vi.fn(async () => ({ active: false })),
pause: vi.fn(async () => undefined),
resume: vi.fn(async () => undefined),
batches: vi.fn(async () => []),
+4 -2
View File
@@ -151,7 +151,7 @@ function createProps(
it('graph navigation reopens the graph tab on repeated requests and preserves normal tab changes', async () => {
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
window.goodbuddy = { supervision: { overview: async () => [], graph } } as never
window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [], graph } } as never
const props = createProps({ graphNavigation: { resultId: 'A' } })
const view = renderComponent(<HeartbeatCenter {...props} />)
const tab = screen.getByRole('tab', { name: '故事线图谱' })
@@ -171,6 +171,7 @@ it('graph navigation reopens the graph tab on repeated requests and preserves no
describe('HeartbeatCenter', () => {
beforeEach(() => {
vi.stubGlobal('goodbuddy', { supervision: {
execution: vi.fn(async () => ({ active: false })),
overview: vi.fn(async () => []),
graph: vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
} })
@@ -189,6 +190,7 @@ describe('HeartbeatCenter', () => {
await i18n.changeLanguage(language)
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
vi.stubGlobal('goodbuddy', { supervision: {
execution: vi.fn(async () => ({ active: false })),
overview: vi.fn(async () => [{ id: 'result', storyLineId: 'story', sourceId: null, summary: 'Review summary', changeDigest: '', createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' }, openItems: [] }]),
activity: vi.fn(async () => []),
graph: vi.fn(async () => ({ storyLine: { id: 'story', scope_json: JSON.stringify({ kind: 'global' }) }, events: [
@@ -242,7 +244,7 @@ describe('HeartbeatCenter', () => {
const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId === 'old' ? [old] : [])
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
const activity = vi.fn(async () => [{ id: 'run', kind: 'supervision', trigger: 'manual', status: 'completed', scope: old.scope, startedAt: old.createdAt, completedAt: old.createdAt, timeRange: old.timeRange, error: null, summary: 'Activity summary', resultId: 'old', heartbeatStatus: null, supervisionStatus: 'completed' }])
vi.stubGlobal('goodbuddy', { supervision: { overview, graph, activity } })
vi.stubGlobal('goodbuddy', { supervision: { execution: async () => ({ active: false }), overview, graph, activity } })
renderComponent(<HeartbeatCenter {...createProps()} />)
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['工作回顾', '故事线图谱', '自动监督', '活动记录', '设置'])
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
+3 -3
View File
@@ -3,7 +3,7 @@ import type { SupervisionActivity } from '../../shared/supervision-contracts'
const phases = ['collecting', 'extracting', 'summarizing', 'saving'] as const
export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
export function SupervisionReviewStages({ row, stopping }: { row: SupervisionActivity; stopping?: 'paused' | 'cancelled' }) {
const { t } = useTranslation('heartbeat')
const progress = row.reviewProgress!
const current = progress.phase ? phases.indexOf(progress.phase) : -1
@@ -14,7 +14,7 @@ export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
const state = progress.complete ? (row.supervisionStatus === 'no_change' && index > 0 ? 'skipped' : 'completed')
: index === 0 && current !== 0 ? 'completed'
: current > index ? 'completed'
: current === index ? (row.supervisionStatus ?? row.status)
: current === index ? (stopping === 'cancelled' ? 'cancelling' : stopping === 'paused' ? 'pausing' : row.supervisionStatus ?? row.status)
: current < 0 ? 'unknown' : 'pending'
return <li key={phase} data-phase={phase} data-state={state} aria-current={current === index && !progress.complete ? 'step' : undefined}>
<span className="supervisor-activity__step-number" aria-hidden="true">{index + 1}</span>
@@ -24,6 +24,6 @@ export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {
</ol>
<p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
{row.supervisionStatus === 'running' && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
{row.supervisionStatus === 'running' && !stopping && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
</div>
}
+104 -2
View File
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom/vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { SupervisionActivity as Activity } from '../../shared/supervision-contracts'
import { SupervisorActivity } from './SupervisorActivity'
@@ -11,6 +11,10 @@ const row: Activity = {
startedAt: '2026-09-23T10:00:00Z', completedAt: null, timeRange: null, error: null,
summary: 'Saved heartbeat', resultId: null, heartbeatStatus: 'completed', supervisionStatus: 'running'
}
const render = (ui: React.ReactNode) => {
if (window.goodbuddy?.supervision) window.goodbuddy.supervision.execution ??= vi.fn().mockResolvedValue({ active: false })
return renderComponent(ui)
}
afterEach(async () => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); await changeUiLocale('zh-CN') })
it.each(['zh-CN', 'en-US'] as const)('retains operational failure details and recovery in %s', async locale => {
@@ -46,7 +50,7 @@ it('shows durable progress, pauses and continues the same run, and expands retai
fireEvent.click(await screen.findByRole('button', { name: '暂停回顾' }))
fireEvent.click(await screen.findByRole('button', { name: '继续已保存回顾' }))
expect(pause).toHaveBeenCalledWith({ runId: 'saved-run' })
expect(resume).toHaveBeenCalledWith({ runId: 'saved-run' })
await waitFor(() => expect(resume).toHaveBeenCalledWith({ runId: 'saved-run' }))
await waitFor(() => expect(activity).toHaveBeenCalledTimes(3))
fireEvent.click(await screen.findByRole('button', { name: '已保留事实与来源' }))
expect(await screen.findByText('Retained fact')).toBeInTheDocument()
@@ -54,6 +58,42 @@ it('shows durable progress, pauses and continues the same run, and expands retai
expect(screen.getAllByText(/message-id \[0, 18\)/).length).toBeGreaterThan(0)
})
it('allows cancelling a resumed review before the resume request settles', async () => {
vi.useFakeTimers()
const progress = { runId: 'saved-run', phase: 'extracting', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
const activity = vi.fn().mockResolvedValue([{ ...row, status: 'paused', supervisionStatus: 'paused', reviewProgress: progress }])
const execution = vi.fn().mockResolvedValue({ active: false })
let finishResume!: () => void, finishCancel!: () => void
const resume = vi.fn(() => {
activity.mockResolvedValue([{ ...row, reviewProgress: progress }])
execution.mockResolvedValue({ active: true, runId: progress.runId })
return new Promise<void>(resolve => { finishResume = resolve })
})
const cancel = vi.fn(() => {
activity.mockResolvedValue([{ ...row, status: 'cancelled', supervisionStatus: 'cancelled', reviewProgress: progress }])
execution.mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' })
return new Promise<void>(resolve => { finishCancel = resolve })
})
vi.stubGlobal('goodbuddy', { supervision: { activity, execution, resume, cancel } })
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(0))
await act(async () => fireEvent.click(screen.getByRole('button', { name: '继续已保存回顾' })))
await act(() => vi.advanceTimersByTimeAsync(10000))
expect(resume).toHaveBeenCalledExactlyOnceWith({ runId: progress.runId })
expect(screen.getByRole('button', { name: '暂停回顾' })).toBeEnabled()
expect(screen.getByRole('button', { name: '取消回顾' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '取消回顾' }))
expect(cancel).toHaveBeenCalledExactlyOnceWith({ runId: progress.runId })
await act(async () => finishResume())
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(screen.getByRole('button', { name: '取消回顾' })).toBeDisabled()
execution.mockResolvedValue({ active: false })
await act(async () => finishCancel())
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '继续已保存回顾' })).not.toBeInTheDocument()
})
it('shows no-change execution without a new result action', async () => {
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{
...row, status: 'no_change', heartbeatStatus: 'no_change', supervisionStatus: 'no_change',
@@ -65,6 +105,68 @@ it('shows no-change execution without a new result action', async () => {
expect(screen.queryByRole('button', { name: '查看回顾' })).not.toBeInTheDocument()
})
it('cancels the automatic review ID and keeps cancelling visible until execution cleanup finishes', async () => {
vi.useFakeTimers()
const progress = { runId: 'review-id', phase: 'extracting', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
const activity = vi.fn().mockResolvedValue([{ ...row, reviewProgress: progress }])
const execution = vi.fn().mockResolvedValue({ active: true, runId: progress.runId })
let finish!: () => void
const cancel = vi.fn(() => {
activity.mockResolvedValue([{ ...row, status: 'cancelled', supervisionStatus: 'cancelled', error: 'Cancelled', reviewProgress: progress }])
execution.mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' })
return new Promise<void>(resolve => { finish = resolve })
})
const pause = vi.fn()
vi.stubGlobal('goodbuddy', { supervision: { activity, execution, cancel, pause } })
const { container } = render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} onPlanChange={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.getByLabelText('监督计划')).toBeEnabled()
expect(screen.queryByText('监督计划')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '暂停回顾' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '取消回顾' }))
expect(cancel).toHaveBeenCalledExactlyOnceWith({ runId: 'review-id' })
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(screen.getByRole('button', { name: '暂停回顾' })).toBeDisabled()
expect(screen.getByRole('button', { name: '取消回顾' })).toBeDisabled()
await act(() => vi.advanceTimersByTimeAsync(2000))
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(container.querySelector('[data-phase="extracting"]')).toHaveAttribute('data-state', 'cancelling')
expect(screen.queryByText('已取消')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '继续已保存回顾' })).not.toBeInTheDocument()
execution.mockResolvedValue({ active: false })
await act(async () => finish())
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getAllByText('已取消')).toHaveLength(2)
expect(screen.queryByRole('button', { name: /取消回顾|暂停回顾|继续已保存回顾/ })).not.toBeInTheDocument()
expect(screen.queryByText('本次运行失败,请查看错误详情后继续。已保存的批次仍保留。')).not.toBeInTheDocument()
expect(pause).not.toHaveBeenCalled()
})
it('rediscovers cancellation cleanup on mount and blocks resuming another saved run', async () => {
vi.useFakeTimers()
const progress = { runId: 'review-id', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false }
const activity = vi.fn().mockResolvedValue([
{ ...row, status: 'cancelled', supervisionStatus: 'cancelled', reviewProgress: progress },
{ ...row, id: 'paused', status: 'paused', supervisionStatus: 'paused', reviewProgress: { ...progress, runId: 'paused-review' } }
])
const execution = vi.fn().mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' })
const resume = vi.fn()
vi.stubGlobal('goodbuddy', { supervision: { activity, execution, resume } })
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(screen.getByRole('button', { name: '继续已保存回顾' })).toBeDisabled()
execution.mockResolvedValue({ active: false })
await act(() => vi.advanceTimersByTimeAsync(2000))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '继续已保存回顾' })).toBeEnabled()
execution.mockResolvedValue({ active: true, runId: 'new-automatic' })
await act(async () => fireEvent.click(screen.getByRole('button', { name: '继续已保存回顾' })))
expect(resume).not.toHaveBeenCalled()
expect(screen.getByRole('alert')).toHaveTextContent('新回顾正在整理')
})
it('polls sequentially only while active and ignores late responses after leaving', async () => {
vi.useFakeTimers()
let resolve!: (value: Activity[]) => void
+58 -17
View File
@@ -3,6 +3,7 @@ import { History, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { AssistantHeartbeatConfig, AssistantProject } from '../../shared/assistant-contracts'
import type { SupervisionActivity } from '../../shared/supervision-contracts'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState, ScopeBadge } from './WorkspacePrimitives'
import { SupervisionBatchDetails } from './SupervisionBatchDetails'
import { SupervisionReviewStages } from './SupervisionReviewStages'
@@ -24,6 +25,9 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
const [error, setError] = useState<string>()
const [expanded, setExpanded] = useState<string>()
const [pending, setPending] = useState<string>()
const [execution, setExecution] = useState<SupervisionReviewExecution>({ active: false })
const [stopping, setStopping] = useState<{ runId: string; kind: 'paused' | 'cancelled' }>()
const actionPending = useRef(false)
const filterRef = useRef<HTMLSelectElement>(null)
const [appliedConfigId, setAppliedConfigId] = useState(configId)
if (appliedConfigId !== configId) {
@@ -43,12 +47,16 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
setReading(true)
try {
if (!api?.activity) throw new Error(t('supervisor.unavailable'))
const result = await api.activity({ limit: 50, offset, ...(configId ? { configId } : {}) })
const [result, current] = await Promise.all([
api.activity({ limit: 50, offset, ...(configId ? { configId } : {}) }),
api.execution()
])
if (disposed) return
setRows(result)
setExecution(current)
setError(undefined)
// One request at a time, only while this page is active.
timer = setTimeout(() => void load(), result.some((row) => row.status === 'running') ? 2000 : 10000)
timer = setTimeout(() => void load(), current.active || result.some((row) => row.status === 'running') ? 2000 : 10000)
} catch (reason) {
if (!disposed) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
} finally {
@@ -68,14 +76,46 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
setRefresh((value) => value + 1)
}
const date = (value: string) => new Date(value).toLocaleString(i18n.resolvedLanguage)
const control = async (row: SupervisionActivity, action: 'pause' | 'cancel' | 'resume') => {
if (actionPending.current || !row.reviewProgress) return
const runId = row.reviewProgress.runId
actionPending.current = true
setPending(row.id)
setError(undefined)
if (action !== 'resume') setStopping({ runId, kind: action === 'cancel' ? 'cancelled' : 'paused' })
try {
if (action === 'resume') {
const current = await api.execution()
setExecution(current)
if (current.active) {
setError(t('supervisor.runningHint'))
return
}
// Resume resolves only when execution stops; keep its pause/cancel controls available.
void api.resume({ runId }).then(
() => setRefresh(value => value + 1),
reason => setError(reason instanceof Error ? reason.message : String(reason))
)
return
}
await api[action]({ runId })
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
actionPending.current = false
setPending(undefined)
setStopping(undefined)
setRefresh(value => value + 1)
}
}
const planScope = configs.find(config => config.id === configId)?.scope
return <section className="supervisor-activity" aria-label={t('activity.title')} aria-busy={loading}>
<div className="supervisor-workspace__action-bar">
<ScopeBadge scope={planScope?.kind === 'global' ? { kind: 'global' }
: planScope?.kind === 'projects' ? { kind: 'projects', projectCount: planScope.projectIds.length }
: { kind: 'mixed' }} />
{onPlanChange && <label className="heartbeat-settings__field">{t('activity.planFilter')}
<select ref={filterRef} value={configId ?? ''} onChange={(event) => onPlanChange(event.target.value)}>
{onPlanChange && <label className="heartbeat-settings__field">
<select aria-label={t('activity.planFilter')} ref={filterRef} value={configId ?? ''} onChange={(event) => onPlanChange(event.target.value)}>
<option value="">{t('activity.allPlans')}</option>
{configId && !configs.some(config => config.id === configId) && <option value={configId}>{t('activity.unavailablePlan')}</option>}
{configs.map(config => <option key={config.id} value={config.id}>{config.name}</option>)}
@@ -94,10 +134,14 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
</div>}
{loading ? <EmptyState icon={<RefreshCw size={24} />} variant="loading" title={t('activity.loading')} description={t('activity.loadingHint')} />
: !error && rows.length === 0 ? <EmptyState icon={<History size={24} />} title={t(configId ? 'activity.filteredEmpty' : 'activity.empty')} description={t(configId ? 'activity.filteredEmptyHint' : 'activity.emptyHint')} />
: <ol className="supervisor-activity__list">{rows.map((row) => <li key={`${row.kind}:${row.id}`}>
: <ol className="supervisor-activity__list">{rows.map((row) => {
const runId = row.reviewProgress?.runId
const stoppingKind = runId && stopping?.runId === runId ? stopping.kind
: runId && execution.active && execution.runId === runId ? execution.stopping : undefined
return <li key={`${row.kind}:${row.id}`}>
<article className="supervisor-activity__item">
<header><strong>{t(`activity.kind.${row.kind}`)}</strong>
<span className={`supervisor-activity__status supervisor-activity__status--${row.status}`}>{t(`activity.status.${row.status}`)}</span>
<span className={`supervisor-activity__status supervisor-activity__status--${stoppingKind ? 'running' : row.status}`} role={stoppingKind ? 'status' : undefined}>{t(stoppingKind === 'cancelled' ? 'reviewSettings.cancelling' : stoppingKind === 'paused' ? 'reviewSettings.pausing' : `activity.status.${row.status}`)}</span>
<time dateTime={row.startedAt} title={row.startedAt}>{date(row.startedAt)}</time>
</header>
<p className="supervisor-activity__scope">{!row.scope ? t('activity.unknownScope') : row.scope.kind === 'global' ? t('center.scope.global') : t('supervisor.projectScope', { names: row.scope.projectIds.map((id) => projects.find((project) => project.id === id)?.name ?? t('settings.scope.unavailableProject')).join(', ') })} · {t(`activity.triggers.${row.trigger}`)}</p>
@@ -110,19 +154,16 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
{row.reviewProgress && <>
{row.reviewProgress.restartRequired && <p>{t('reviewSettings.restartRequired')}</p>}
<div className="supervisor-workspace__actions">
{row.supervisionStatus === 'running' && <button type="button" className="secondary-button" disabled={pending === row.id} onClick={() => {
setPending(row.id)
void api.pause({ runId: row.reviewProgress!.runId }).then(() => setRefresh(value => value + 1), reason => setError(String(reason))).finally(() => setPending(undefined))
}}>{t('reviewSettings.pause')}</button>}
{!row.reviewProgress.complete && !row.reviewProgress.restartRequired && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && <button type="button" className="secondary-button" disabled={pending === row.id} onClick={() => {
setPending(row.id)
void api.resume({ runId: row.reviewProgress!.runId }).then(() => setRefresh(value => value + 1), reason => setError(String(reason))).finally(() => setPending(undefined))
}}>{t(row.reviewProgress.phase === 'summarizing' ? 'activity.resumeMerge' : row.reviewProgress.phase === 'saving' ? 'activity.resumeSave' : 'reviewSettings.resume')}</button>}
{(row.supervisionStatus === 'running' || stoppingKind) && <>
<button type="button" className="secondary-button" disabled={!!pending || !!stoppingKind} onClick={() => void control(row, 'pause')}>{t('reviewSettings.pause')}</button>
<button type="button" className="danger-ghost" disabled={!!pending || !!stoppingKind} onClick={() => void control(row, 'cancel')}>{t('reviewSettings.cancel')}</button>
</>}
{!stoppingKind && !row.reviewProgress.complete && !row.reviewProgress.restartRequired && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && <button type="button" className="secondary-button" disabled={!!pending || execution.active} onClick={() => void control(row, 'resume')}>{t(row.reviewProgress.phase === 'summarizing' ? 'activity.resumeMerge' : row.reviewProgress.phase === 'saving' ? 'activity.resumeSave' : 'reviewSettings.resume')}</button>}
<button type="button" className="secondary-button" aria-expanded={expanded === row.id} aria-controls={`batches-${row.id}`} onClick={() => setExpanded(expanded === row.id ? undefined : row.id)}>{t('reviewSettings.facts')}</button>
</div>
</>}
{row.reviewProgress && <SupervisionReviewStages row={row} />}
{row.error && <div className="supervisor-activity__failure">
{row.reviewProgress && <SupervisionReviewStages row={row} stopping={stoppingKind || undefined} />}
{row.error && row.supervisionStatus !== 'cancelled' && !stoppingKind && <div className="supervisor-activity__failure">
<p>{t(row.status === 'paused' ? 'reviewSettings.pausedHint' : /persistedId|candidateRef|entity identity/.test(row.error)
? 'reviewSettings.identityError' : 'reviewSettings.runError')}</p>
<details className="supervisor-activity__diagnostics"><summary>{t('reviewSettings.diagnostics')}</summary>
@@ -149,7 +190,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
</details>}
</details>
</article>
</li>)}</ol>}
</li>})}</ol>}
<nav className="supervisor-workspace__actions" aria-label={t('activity.pagination')}>
<button className="secondary-button" type="button" disabled={loading || reading || !active || offset === 0} onClick={() => reload(Math.max(0, offset - 50))}>{t('activity.previous')}</button>
<span>{t('activity.page', { page: offset / 50 + 1 })}</span>
+57 -3
View File
@@ -1,9 +1,13 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { SupervisorWorkspace } from './SupervisorWorkspace'
const result = { id: 'result-1', storyLineId: 'story', sourceId: null, summary: 'Recap', changeDigest: '', createdAt: '2026-09-22T00:00:00.000Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-20T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' }, openItems: [] }
const render = (ui: React.ReactNode) => {
if (window.goodbuddy?.supervision) window.goodbuddy.supervision.execution ??= vi.fn().mockResolvedValue({ active: false })
return renderComponent(ui)
}
describe('SupervisorWorkspace', () => {
it('keeps dated historical content and exact graph selection when configuring and running a new review', async () => {
@@ -34,15 +38,65 @@ describe('SupervisorWorkspace', () => {
expect(onTabChange).toHaveBeenCalledWith('graph')
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(recap.textContent).toBe(frozenText)
expect(screen.getByText('Important final conclusion.')).toBeVisible()
expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理')
expect(screen.getByRole('button', { name: '回顾整理中…' })).toBeDisabled()
expect(screen.getByRole('button', { name: '回顾当前进展' })).toBeEnabled()
expect(screen.getByLabelText('关注范围')).toBeEnabled()
expect(screen.getByLabelText('时间范围')).toBeEnabled()
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
expect(screen.queryByText('关注范围')).not.toBeInTheDocument()
expect(screen.queryByText('时间范围')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
expect(screen.getByRole('status')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '活动记录' }))
expect(onOpenActivity).toHaveBeenCalledOnce()
expect(run).toHaveBeenCalledOnce()
expect(run).toHaveBeenCalledWith(expect.objectContaining({ scope: { kind: 'projects', projectIds: [project.id] } }))
await act(async () => finish())
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('discovers automatic execution on mount, preserves dismissal through polling, and guards start until cleanup', async () => {
vi.useFakeTimers()
const execution = vi.fn().mockResolvedValue({ active: true, runId: 'automatic', stopping: 'cancelled' })
const run = vi.fn().mockResolvedValue(undefined)
const cancel = vi.fn()
window.goodbuddy = { supervision: { execution, overview: async () => [], run, cancel } } as never
const view = render(<SupervisorWorkspace onOpenActivity={vi.fn()} />)
await act(() => vi.advanceTimersByTimeAsync(0))
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
fireEvent.click(screen.getByRole('button', { name: '关闭提示' }))
await act(() => vi.advanceTimersByTimeAsync(2000))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(cancel).not.toHaveBeenCalled()
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' })))
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
expect(run).not.toHaveBeenCalled()
execution.mockResolvedValue({ active: false })
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' })))
expect(run).toHaveBeenCalledOnce()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
view.unmount()
const calls = execution.mock.calls.length
await act(() => vi.advanceTimersByTimeAsync(10000))
expect(execution).toHaveBeenCalledTimes(calls)
vi.useRealTimers()
})
it('turns a backend busy race into a dismissible ongoing notice', async () => {
const run = vi.fn().mockRejectedValue(new Error('SUPERVISION_REVIEW_BUSY'))
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
render(<SupervisorWorkspace onOpenActivity={vi.fn()} />)
await screen.findByText('还没有成功回顾')
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理'))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '回顾当前进展' })).toBeEnabled()
})
it('graph navigation discards a late overview before it can request the old graph', async () => {
let resolveOverview!: (value: unknown) => void
@@ -138,7 +192,7 @@ describe('SupervisorWorkspace', () => {
expect(tokens, token).toContain(`${token}:`)
}
})
afterEach(() => { cleanup(); window.goodbuddy = {} as never })
afterEach(() => { cleanup(); vi.useRealTimers(); window.goodbuddy = {} as never })
it('shows unavailable immediately and does not call supervision APIs when the bridge is absent', () => {
window.goodbuddy = {} as never
+54 -18
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BookOpen, Network, RefreshCw } from 'lucide-react'
import { BookOpen, Network, RefreshCw, X } from 'lucide-react'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState } from './WorkspacePrimitives'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
@@ -70,6 +71,10 @@ export function SupervisorWorkspace({
const [days, setDays] = useState(7)
const [lastRequest, setLastRequest] = useState<SupervisionRunRequest>()
const [pausedReview, setPausedReview] = useState(false)
const [execution, setExecution] = useState<SupervisionReviewExecution>({ active: false })
const [running, setRunning] = useState(false)
const runPending = useRef(false)
const [dismissedNotice, setDismissedNotice] = useState(false)
const [resultId, setResultId] = useState<string | undefined>(graphNavigation?.resultId)
const [appliedNavigation, setAppliedNavigation] = useState(graphNavigation)
if (appliedNavigation !== graphNavigation) {
@@ -174,8 +179,34 @@ export function SupervisorWorkspace({
return () => { window.clearTimeout(task); invalidate() }
}, [refresh, graphNavigation])
useEffect(() => {
if (!api) return
let disposed = false
let wasActive = false
let timer: ReturnType<typeof setTimeout>
const poll = async () => {
try {
const next = await api.execution()
if (!disposed) {
setExecution(next)
if (next.active && !wasActive && !runPending.current) setDismissedNotice(false)
wasActive = next.active
}
} catch (reason) {
if (!disposed) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
} finally {
if (!disposed) timer = setTimeout(() => void poll(), 2000)
}
}
void poll()
return () => { disposed = true; clearTimeout(timer) }
}, [api, t])
const run = async () => {
if (!api || pending || loading) return
if (!api) return
setDismissedNotice(false)
if (runPending.current) return
runPending.current = true
const generation = loadGeneration.current
const to = new Date()
const request: SupervisionRunRequest = {
@@ -189,23 +220,31 @@ export function SupervisorWorkspace({
to: to.toISOString()
}
}
setLastRequest(request)
setPausedReview(false)
setPending('run')
setErrorAction('run')
setError(undefined)
try {
const current = await api.execution()
setExecution(current)
if (current.active) return
setLastRequest(request)
setPausedReview(false)
setRunning(true)
const outcome = await api.run(request) as { status?: string } | undefined
if (generation !== loadGeneration.current) return
setExecution(await api.execution())
setPausedReview(outcome?.status === 'paused')
if (generation !== loadGeneration.current) return
selectedResult.current = undefined
setPending(undefined)
await refresh()
} catch (reason) {
if (/SUPERVISION_REVIEW_BUSY/.test(String(reason))) {
setExecution({ active: true })
return
}
if (generation !== loadGeneration.current) return
setError(errorText(reason))
} finally {
if (generation === loadGeneration.current) setPending(undefined)
runPending.current = false
setRunning(false)
}
}
@@ -415,6 +454,7 @@ export function SupervisorWorkspace({
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
: undefined
const busy = !!api && (loading || pending !== undefined)
const showRunNotice = (execution.active || running || pausedReview) && !dismissedNotice
return (
<div className="supervisor-workspace" data-view={tab} aria-busy={busy}>
@@ -449,9 +489,10 @@ export function SupervisorWorkspace({
</button>
</div>
)}
{(pending === 'run' || pausedReview) && <div className="supervisor-workspace__run-status" role="status">
<span>{t(pausedReview ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span>
{showRunNotice && <div className="supervisor-workspace__run-status" role="status">
<span>{t(execution.stopping === 'cancelled' ? 'reviewSettings.cancelling' : execution.stopping === 'paused' ? 'reviewSettings.pausing' : pausedReview && !running && !execution.active ? 'reviewSettings.pausedHint' : 'supervisor.runningHint')}</span>
{onOpenActivity && <button className="link-button" onClick={onOpenActivity}>{t('activity.title')}</button>}
<button type="button" className="icon-button" aria-label={t('supervisor.dismiss')} title={t('supervisor.dismiss')} onClick={() => setDismissedNotice(true)}><X size={16} aria-hidden="true" /></button>
</div>}
{error && (
<div className="supervisor-workspace__inline-error" role="alert">
@@ -481,10 +522,9 @@ export function SupervisorWorkspace({
<>
<div className="supervisor-workspace__toolbar" role="group" aria-label={t('supervisor.newReview')}>
<label>
{t('supervisor.scope')}
<select
aria-label={t('supervisor.scope')}
value={projectId}
disabled={busy}
onChange={(event) => setProjectId(event.target.value)}
>
<option value="global">{t('center.scope.global')}</option>
@@ -498,10 +538,9 @@ export function SupervisorWorkspace({
</select>
</label>
<label>
{t('supervisor.period')}
<select
aria-label={t('supervisor.period')}
value={days}
disabled={busy}
onChange={(event) => setDays(Number(event.target.value))}
>
{[1, 7, 30].map((value) => (
@@ -513,12 +552,9 @@ export function SupervisorWorkspace({
</label>
<button
className="primary-button"
disabled={busy}
onClick={() => void run()}
>
{pending === 'run'
? t('supervisor.running')
: t('supervisor.run')}
{t('supervisor.run')}
</button>
<button
className="secondary-button"
@@ -12,6 +12,7 @@ export const heartbeat = {
pauseHelp: 'Reviews run continuously until complete and save each batch. Pause and continue from Activity records.',
invalid: 'Enter whole numbers within the displayed ranges.',
pause: 'Pause review', resume: 'Continue saved review', facts: 'Retained facts and sources',
cancel: 'Cancel review', cancelling: 'Cancelling review…', pausing: 'Pausing review…',
pausedHint: 'Review is incomplete. Successful batches are saved. Open Activity to inspect progress and continue.',
restartRequired: 'A frozen source changed or was removed. Saved facts remain available. Start a new review from Work review; the next automatic check will also use a new run.',
factsHelp: 'Use summaries to navigate, then expand saved batches for detailed records and source text. Check the model\'s findings against the sources.',
@@ -39,7 +40,7 @@ export const heartbeat = {
activity: {
stages: 'Review stages',
phases: { collecting: 'Collect sources', extracting: 'Review batches', summarizing: 'Merge summaries', saving: 'Publish review' },
stageStates: { completed: 'Complete', running: 'In progress', failed: 'Failed', paused: 'Paused', pending: 'Not started', unknown: 'Not recorded', skipped: 'Not needed', no_change: 'No changes' },
stageStates: { completed: 'Complete', running: 'In progress', failed: 'Failed', paused: 'Paused', cancelled: 'Cancelled', cancelling: 'Cancelling', pausing: 'Pausing', pending: 'Not started', unknown: 'Not recorded', skipped: 'Not needed', no_change: 'No changes' },
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
navigationSaved: '{{count}} navigation merges saved',
@@ -57,7 +58,7 @@ export const heartbeat = {
title: 'Activity records', description: 'Saved manual reviews and automatic supervision runs. Updates while this tab is open.',
loading: 'Loading activity', loadingHint: 'Reading saved execution records.', empty: 'No activity recorded', emptyHint: 'Run a manual review or create a plan in Automatic supervision.',
kind: { supervision: 'Work review', heartbeat: 'Automatic supervision' },
status: { running: 'Running', completed: 'Completed', failed: 'Failed', skipped: 'Skipped', no_change: 'No changes (no model call)', paused: 'Incomplete, ready to continue' },
status: { running: 'Running', completed: 'Completed', failed: 'Failed', skipped: 'Skipped', no_change: 'No changes (no model call)', paused: 'Incomplete, ready to continue', cancelled: 'Cancelled' },
triggers: { manual: 'User', scheduled: 'Schedule', heartbeat: 'Heartbeat' },
trigger: 'Triggered by', started: 'Started', finished: 'Finished', unknownScope: 'Execution scope not recorded',
heartbeatStage: 'Heartbeat report', supervisionStage: 'Supervision review', notRecorded: 'No recorded execution',
@@ -9,6 +9,7 @@ export const heartbeat = {
pauseHelp: '回顾持续处理至完成,每批保存进度;可从活动记录主动暂停并继续。',
invalid: '请输入所示范围内的整数。',
pause: '暂停回顾', resume: '继续已保存回顾', facts: '已保留事实与来源',
cancel: '取消回顾', cancelling: '正在取消回顾…', pausing: '正在暂停回顾…',
pausedHint: '本次回顾尚未完成,成功批次已保存。请到活动页查看进度并继续。',
restartRequired: '冻结来源已修改或移除,已保存事实仍可读取。请从工作回顾新建回顾;下次自动检查也会使用新运行。',
factsHelp: '摘要用于导航,可展开已保存批次查看详细记录与原文。请对照来源核对模型整理的内容。',
@@ -36,7 +37,7 @@ export const heartbeat = {
activity: {
stages: '回顾阶段',
phases: { collecting: '收集来源', extracting: '整理批次', summarizing: '合并摘要', saving: '发布回顾' },
stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' },
stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', cancelled: '已取消', cancelling: '正在取消', pausing: '正在暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' },
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
navigationSaved: '已保存 {{count}} 次导航合并',
@@ -54,7 +55,7 @@ export const heartbeat = {
title: '活动记录', description: '记录手动回顾和自动监督的实际执行,停留在此页签时自动刷新。',
loading: '正在读取活动', loadingHint: '读取已保存的执行记录。', empty: '暂无活动记录', emptyHint: '可手动回顾当前进展,或在自动监督中创建计划。',
kind: { supervision: '工作回顾', heartbeat: '自动监督' },
status: { running: '运行中', completed: '已完成', failed: '失败', skipped: '已跳过', no_change: '无变化(未调用模型)', paused: '未完成,可继续' },
status: { running: '运行中', completed: '已完成', failed: '失败', skipped: '已跳过', no_change: '无变化(未调用模型)', paused: '未完成,可继续', cancelled: '已取消' },
triggers: { manual: '用户触发', scheduled: '定时计划', heartbeat: '心跳触发' },
trigger: '触发方式', started: '开始时间', finished: '结束时间', unknownScope: '未保存执行范围',
heartbeatStage: '心跳报告', supervisionStage: '监督回顾', notRecorded: '无执行记录',
+8 -3
View File
@@ -15,10 +15,9 @@
align-self: stretch;
}
.supervisor-workspace__run-status {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-default);
@@ -26,6 +25,12 @@
background: var(--accent-subtle);
color: var(--text-secondary);
}
.supervisor-workspace__run-status > span {
overflow-wrap: anywhere;
}
.supervisor-workspace__run-status > .icon-button {
grid-column: 3;
}
.supervisor-workspace p,
.supervisor-workspace time {
overflow-wrap: anywhere;
+2
View File
@@ -2192,6 +2192,8 @@ export type DesktopApi = {
}>
}
supervision: {
cancel: (input: { runId: string }) => Promise<void>
execution: () => Promise<import('./supervision-review-contracts').SupervisionReviewExecution>
pause: (input: { runId: string }) => Promise<void>
resume: (input: { runId: string }) => Promise<unknown>
batches: (input: { runId: string; offset?: number; limit?: number }) => Promise<import('./supervision-review-contracts').SupervisionReviewBatch[]>
+2
View File
@@ -267,6 +267,8 @@ export const ipcChannels = {
supervisionOverview: 'supervision:overview',
supervisionActivity: 'supervision:activity',
supervisionPause: 'supervision:pause',
supervisionCancel: 'supervision:cancel',
supervisionExecution: 'supervision:execution',
supervisionResume: 'supervision:resume',
supervisionBatches: 'supervision:batches',
supervisionRun: 'supervision:run',
+2 -2
View File
@@ -34,7 +34,7 @@ export type SupervisionActivity = {
id: string
kind: 'supervision' | 'heartbeat'
trigger: 'manual' | 'scheduled' | 'heartbeat'
status: 'running' | 'completed' | 'failed' | 'skipped' | 'no_change' | 'paused'
status: 'running' | 'completed' | 'failed' | 'skipped' | 'no_change' | 'paused' | 'cancelled'
reviewProgress?: import('./supervision-review-contracts').SupervisionReviewProgress
scope: SupervisionRunRequest['scope'] | null
startedAt: string
@@ -44,7 +44,7 @@ export type SupervisionActivity = {
summary: string | null
resultId: string | null
heartbeatStatus: 'claimed' | 'completed' | 'failed' | 'skipped' | 'no_change' | null
supervisionStatus: 'running' | 'completed' | 'failed' | 'no_change' | 'paused' | null
supervisionStatus: 'running' | 'completed' | 'failed' | 'no_change' | 'paused' | 'cancelled' | null
}
export const supervisionEvidenceSchema = z
@@ -11,6 +11,11 @@ export const supervisionReviewSettingsSchema = z.object({
export type SupervisionReviewSettings = z.infer<typeof supervisionReviewSettingsSchema>
export const defaultSupervisionReviewSettings = supervisionReviewSettingsSchema.parse({})
export const supervisionReviewIdSchema = z.object({ runId: z.string().uuid() }).strict()
export type SupervisionReviewExecution = {
active: boolean
runId?: string
stopping?: 'paused' | 'cancelled'
}
export const supervisionBatchesRequestSchema = supervisionReviewIdSchema.extend({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(20).default(10)