From f2f4c1273e5e951c8aa13e3e3540137dcf5052b8 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 10 Oct 2026 15:44:14 +0800 Subject: [PATCH] fix(supervisor): review only finished sources and add an all-time range Reviews now snapshot only messages in a terminal state (complete or error) and finished tasks, so a reply still streaming when the review starts waits for the next review instead of changing under it. A frozen source that still changes is skipped for that run and left for the next review, replacing the re-review at the latest version and its 200-source cap. The review period picker gains an All time option. --- src/main/assistant/assistant-database.ts | 14 +++- .../supervision-review-store.test.ts | 4 +- .../assistant/supervision-review-store.ts | 72 +++++++++---------- src/main/assistant/supervision-review.test.ts | 47 ++++++++---- src/main/assistant/supervision-worker.test.ts | 10 +-- src/renderer/src/SupervisionReviewStages.tsx | 2 +- src/renderer/src/SupervisorActivity.tsx | 2 +- src/renderer/src/SupervisorWorkspace.test.tsx | 25 ++++++- src/renderer/src/SupervisorWorkspace.tsx | 3 + .../src/i18n/locales/en-US/heartbeat.ts | 3 +- .../src/i18n/locales/zh-CN/heartbeat.ts | 3 +- src/shared/supervision-review-contracts.ts | 4 +- 12 files changed, 121 insertions(+), 68 deletions(-) diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 725a2296..e98257f1 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -134,7 +134,7 @@ import { SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION } from './subagent-progress-storage' -export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62 +export const ASSISTANT_DATABASE_SCHEMA_VERSION = 63 function withoutHistoricalWorkMode(value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value @@ -11919,6 +11919,7 @@ export class AssistantDatabase { database.exec('BEGIN IMMEDIATE') try { database.exec(supervisionReviewMigration) + database.exec(supervisionReviewCurrentView) database.exec('PRAGMA user_version = 47; COMMIT;') } catch (error) { database.exec('ROLLBACK'); throw error } } @@ -12250,6 +12251,15 @@ export class AssistantDatabase { `) } catch (error) { database.exec('ROLLBACK'); throw error } } + if (version.user_version < 63) { + // Reviews read only finished messages and tasks, so a frozen manifest never changes. + database.exec('BEGIN IMMEDIATE') + try { + database.exec('DROP VIEW IF EXISTS supervision_review_current') + database.exec(supervisionReviewCurrentView) + database.exec('PRAGMA user_version = 63; COMMIT;') + } catch (error) { database.exec('ROLLBACK'); throw error } + } } supervisionExperiences(): SupervisionExperienceStore { @@ -12314,7 +12324,7 @@ export class AssistantDatabase { } } import type { ImageOperation } from '../../shared/image-generation-contracts' -import { SupervisionReviewStore, supervisionReviewMigration } from './supervision-review-store' +import { SupervisionReviewStore, supervisionReviewCurrentView, supervisionReviewMigration } from './supervision-review-store' import { SupervisionStoryStore, supervisionStoriesMigration } from './supervision-stories' import { SupervisionExperienceStore, supervisionExperiencesMigration } from './supervision-experiences' import { migrateSupervisionTimeline, placeTimelineEvents, supersedeReextractedEvents, supervisionAttention, timelineCandidates } from './supervision-timeline' diff --git a/src/main/assistant/supervision-review-store.test.ts b/src/main/assistant/supervision-review-store.test.ts index ca1899c0..10035135 100644 --- a/src/main/assistant/supervision-review-store.test.ts +++ b/src/main/assistant/supervision-review-store.test.ts @@ -70,9 +70,9 @@ it('scans read-only, transfers at most 200 rows, yields without a transaction, a expect(wrote).toBe(true) expect(f.store.progress(f.runId).sources).toBe(450) expect(f.store.load(f.runId).initializing).toBeUndefined() - // A concurrent write is detected on resume and that one source is re-frozen, not the whole run failed. + // A concurrent write is detected on resume and that one source is skipped, not the whole run failed. await f.store.resumeWithReader(f.runId, f.reader) - expect(f.store.progress(f.runId)).toMatchObject({ sources: 450, revisedSources: 1 }) + expect(f.store.progress(f.runId)).toMatchObject({ sources: 449, skippedSources: 1 }) expect(f.store.load(f.runId).restartRequired).toBeUndefined() await expect(f.worker.call('reviewManifestPage', [f.runId, 0])).rejects.toThrow('lost its snapshot') }) diff --git a/src/main/assistant/supervision-review-store.ts b/src/main/assistant/supervision-review-store.ts index a49753e4..2087820c 100644 --- a/src/main/assistant/supervision-review-store.ts +++ b/src/main/assistant/supervision-review-store.ts @@ -10,14 +10,7 @@ import { setImmediate as yieldTurn } from 'node:timers/promises' 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; omittedSources?: number; revisedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] } - -/** - * A source that keeps changing while it is reviewed (for example an assistant reply - * that is still being rewritten) is re-frozen at most this many times per run in total; - * after that further changed sources are omitted so the run always terminates. - */ -export const MAX_REVISED_REVIEW_SOURCES = 200 +export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; skippedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] } export class ReviewSourcesOmitted extends Error {} @@ -59,6 +52,14 @@ export const supervisionReviewMigration = ` id TEXT NOT NULL, children_json TEXT NOT NULL, output_json TEXT NOT NULL, PRIMARY KEY(run_id, id) ); +` + +/** + * Reviewable sources. Only finished messages (complete or error) and finished tasks are + * visible: their content no longer changes, so the manifest frozen when a review starts + * is a fixed snapshot. Replies still streaming and running tasks wait for the next review. + */ +export const supervisionReviewCurrentView = ` CREATE VIEW supervision_review_current AS SELECT 'message:' || m.id AS source, m.id AS record_id, 0 AS reference_index, 'conversation' AS type, c.id AS owner, c.project_id AS project_id, c.id AS conversation_id, m.sequence AS sequence, @@ -67,14 +68,15 @@ export const supervisionReviewMigration = ` json_array(m.review_revision, c.project_id, c.title, m.role, m.created_at) AS context, json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'role', m.role, 'sequence', m.sequence) AS locator FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id - WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') + WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error') UNION ALL SELECT 'task:' || t.id, t.id, 0, 'task', t.id, COALESCE(t.project_id, ''), '', 0, t.title, t.created_at, COALESCE(t.completed_at, t.created_at), json_object('title', t.title, 'status', t.status, 'createdAt', t.created_at, 'completedAt', t.completed_at), json_array(t.project_id, t.title, t.status, t.created_at, t.completed_at), json_object('projectId', t.project_id) FROM tasks t LEFT JOIN projects p ON p.id = t.project_id - WHERE t.visible = 1 AND (t.project_id IS NULL OR p.status = 'active') + WHERE t.visible = 1 AND t.status IN ('completed', 'failed', 'cancelled', 'interrupted') + AND (t.project_id IS NULL OR p.status = 'active') UNION ALL SELECT 'knowledge:' || m.id || ':' || ref.key, m.id, ref.key, 'knowledge', COALESCE(json_extract(ref.value, '$.chunkId'), json_extract(ref.value, '$.documentId'), @@ -86,7 +88,7 @@ export const supervisionReviewMigration = ` json_patch(ref.value, json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'referenceIndex', ref.key)) FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id JOIN json_each(m.metadata_json, '$.sourceReferences') ref - WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant'); + WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error'); ` export class SupervisionReviewStore { @@ -248,13 +250,13 @@ export class SupervisionReviewStore { resume(runId: string, signal?: AbortSignal): void { const state = this.prepareResume(runId) if (state.initializing) { this.initializeSources(runId, state, signal); return } - // Sources changed since they were frozen are re-frozen and reprocessed; removed ones are omitted. + // A source that no longer matches its frozen version is skipped and left for the next review. let after = '' for (;;) { const rows = this.resumePage(runId, after) if (!rows.length) break const changed = this.changedSource(rows, signal) - if (changed) { this.reconcileSource(runId, changed); after = changed; continue } + if (changed) { this.skipSource(runId, changed); after = changed; continue } after = rows.at(-1)!.source } } @@ -287,8 +289,8 @@ export class SupervisionReviewStore { if (!rows.length) break const changed = await reader.changedSource(rows, signal) this.checkRunning(runId, signal) - // The read snapshot may lag the writer; reconciliation re-reads the source here. - if (changed) { this.reconcileSource(runId, changed); after = changed; await yieldTurn(); continue } + // The read snapshot may lag the writer; skipSource re-reads the source here. + if (changed) { this.skipSource(runId, changed); after = changed; await yieldTurn(); continue } after = rows.at(-1)!.source await yieldTurn() } @@ -348,8 +350,8 @@ export class SupervisionReviewStore { 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) { - // Re-freeze (or omit) the changed source, then rebuild the batch from current offsets. - this.reconcileSource(runId, metadata.source) + // Skip the changed source, then rebuild the batch from current offsets. + this.skipSource(runId, metadata.source) return this.chunk(runId, projectId, conversationId, config) } const row = { ...metadata, ...current } @@ -396,33 +398,29 @@ export class SupervisionReviewStore { } /** - * Brings one frozen source in line with the business database without failing the run. - * A changed source is re-frozen at its current revision and reviewed again from the start - * (leaves that used it are discarded with their navigation); a removed or no longer visible - * source is omitted. Returns false when the source already matches. + * The manifest is a snapshot of finished sources, so a frozen source should never change. + * If one still does (or disappears), it is dropped from this run without failing it: leaves + * that used it are discarded with their navigation, and because no checkpoint is written for + * it, the next review picks it up at its current version. Returns false when it still matches. */ - reconcileSource(runId: string, source: string): boolean { + skipSource(runId: string, source: string): boolean { const frozen = this.db.prepare('SELECT revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, source) as { revision: string } | undefined if (!frozen) return false const live = this.currentSource(source, 0, 0) if (live?.current_revision === frozen.revision) return false if (this.omitDeletedConversations(runId)) return true - const current = live && { revision: live.current_revision, length: Number(live.body_length) } - const state = this.load(runId) - const revise = current !== undefined && current.length > 0 && (state.revisedSources ?? 0) < MAX_REVISED_REVIEW_SOURCES - this.db.exec('SAVEPOINT reconcile_review_source') + // No longer visible (archived, emptied): omitted. Still visible but changed: left for the next review. + const counter = live && Number(live.body_length) > 0 ? '$.skippedSources' : '$.omittedSources' + this.db.exec('SAVEPOINT skip_review_source') try { this.discardLeaves(runId, new Set([source])) - if (revise && current) { - this.db.prepare(`UPDATE supervision_review_sources SET revision = ?, length = ?, initial_offset = 0, processed_offset = 0 - WHERE run_id = ? AND source = ?`).run(current.revision, current.length, runId, source) - } else this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source) + this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source) this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(json_remove(state_json, '$.restartRequired'), - '${revise ? '$.revisedSources' : '$.omittedSources'}', COALESCE(json_extract(state_json, '${revise ? '$.revisedSources' : '$.omittedSources'}'), 0) + 1, + '${counter}', COALESCE(json_extract(state_json, '${counter}'), 0) + 1, '$.phase', 'extracting') WHERE run_id = ?`).run(runId) - this.db.exec('RELEASE reconcile_review_source') + this.db.exec('RELEASE skip_review_source') return true - } catch (error) { this.db.exec('ROLLBACK TO reconcile_review_source; RELEASE reconcile_review_source'); throw error } + } catch (error) { this.db.exec('ROLLBACK TO skip_review_source; RELEASE skip_review_source'); throw error } } /** @@ -464,9 +462,9 @@ export class SupervisionReviewStore { try { this.omitDeletedConversations(runId) if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return } - // A source changed while the model was working: drop this answer, keep the run going. + // A source changed while the model was working: drop this answer and that source, keep the run going. const stale = evidence.find(item => this.currentSource(String(item.locator!.source), 0, 0)?.current_revision !== item.locator!.revision) - if (stale) { this.reconcileSource(runId, String(stale.locator!.source)); this.db.exec('COMMIT'); return } + if (stale) { this.skipSource(runId, String(stale.locator!.source)); 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) { @@ -500,8 +498,8 @@ export class SupervisionReviewStore { FROM supervision_review_batches WHERE run_id = ?`).get(runId)! const state = this.load(runId) const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)! - // Changed sources are reconciled on resume, so no saved run requires a restart. - return { runId, omittedSources: state.omittedSources, revisedSources: state.revisedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources), + // Changed sources are skipped on resume, so no saved run requires a restart. + return { runId, omittedSources: state.omittedSources, skippedSources: state.skippedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, 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 } } diff --git a/src/main/assistant/supervision-review.test.ts b/src/main/assistant/supervision-review.test.ts index a5a6d239..9fc40b54 100644 --- a/src/main/assistant/supervision-review.test.ts +++ b/src/main/assistant/supervision-review.test.ts @@ -313,21 +313,35 @@ it('still pauses on user request and resumes saved batches without changing batc expect(f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence).map(row => row.content.length)).toEqual([1000, 1000, 500]) }) -it('rejects changed source versions and rolls back offsets when batch persistence fails', async () => { - const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 }) +it('skips changed source versions and rolls back offsets when batch persistence fails', async () => { + const f = await fixture([['x'.repeat(2500)], ['Kept']], { concurrency: 1 }) f.sql.exec("CREATE TRIGGER fail_batch BEFORE INSERT ON supervision_review_batches BEGIN SELECT RAISE(ABORT, 'disk failure'); END") await expect(f.service().run(request)).rejects.toThrow('disk failure') const runId = f.db.listSupervisionActivity()[0]!.id expect(f.db.supervisionReviewStore().progress(runId).characters).toBe(0) - expect(f.sql.prepare('SELECT processed_offset FROM supervision_review_sources').get()!.processed_offset).toBe(0) + expect(f.sql.prepare('SELECT MAX(processed_offset) AS offset FROM supervision_review_sources').get()!.offset).toBe(0) f.sql.exec('DROP TRIGGER fail_batch') f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', f.conversations[0]!.messages[0]!.id) const resumed = await f.service().resume(runId) - expect(resumed).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, characters: 'Changed'.length, complete: true } }) - expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Changed']) + expect(resumed).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, characters: 'Kept'.length, complete: true } }) + expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Kept']) }) -it('re-reviews a source rewritten while its batch is with the model and discards the stale answer', async () => { +it('reviews only finished messages: a streaming reply waits for the next review', async () => { + const f = await fixture([['Finished question', 'Partial reply']], { concurrency: 1 }) + const reply = f.conversations[0]!.messages[1]!.id + f.sql.prepare("UPDATE messages SET state = 'streaming' WHERE id = ?").run(reply) + const manual = { ...request, reanalyze: undefined } + const first = await f.service().run(manual) + expect(first.coverage).toMatchObject({ sources: 1, complete: true }) + expect(f.db.supervisionReviewStore().batches(first.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Finished question']) + f.sql.prepare("UPDATE messages SET content = ?, state = 'complete' WHERE id = ?").run('Partial reply, finished', reply) + const next = await f.service().run(manual) + expect(next.coverage).toMatchObject({ sources: 1, complete: true }) + expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Partial reply, finished']) +}) + +it('skips a source rewritten while its batch is with the model and discards the stale answer', async () => { const f = await fixture([['Original reply'], ['Other conversation']], { concurrency: 1 }) const target = f.conversations[0]!.messages[0]!.id const base = f.summarize.getMockImplementation()! @@ -340,13 +354,12 @@ it('re-reviews a source rewritten while its batch is with the model and discards return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') } }) const result = await f.service().run(request) - expect(result).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, sources: 2, remainingSources: 0, complete: true } }) + expect(result).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, remainingSources: 0, complete: true } }) const contents = f.db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence).map(item => item.content) - expect(contents).toContain('Original reply, continued after reconnect') - expect(contents).not.toContain('Original reply') + expect(contents).toEqual(['Other conversation']) }) -it('discards a mixed leaf when one of its sources changes and reviews only current versions', async () => { +it('discards a mixed leaf when one of its sources changes and skips only that source', async () => { const f = await fixture([['A', 'B']], { concurrency: 1 }) const store = f.db.supervisionReviewStore() const runId = f.db.startSupervisionRun(request) @@ -360,8 +373,8 @@ it('discards a mixed leaf when one of its sources changes and reviews only curre f.db.failSupervisionRun(runId, 'Provider failed') await f.service().resume(runId) expect(store.navigation(runId, 'dependent')).toBeUndefined() - expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A', 'B2']) - expect(store.progress(runId)).toMatchObject({ revisedSources: 1, complete: true }) + expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A']) + expect(store.progress(runId)).toMatchObject({ skippedSources: 1, sources: 1, complete: true }) }) it('omits a source that is no longer visible and resumes old runs failed by the earlier restart rule', async () => { @@ -443,9 +456,13 @@ it('lets the next automatic review continue a failed run whose source changed in f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('New version', f.conversations[0]!.messages[0]!.id) const result = await f.service().run(automatic) expect(result.runId).toBe(runId) - expect(result).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, characters: 'New version'.length } }) - // The leaf of the old version is discarded; only the current version is published. - expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version']) + expect(result).toMatchObject({ status: 'no_change', coverage: { skippedSources: 1, sources: 0 } }) + // The leaf of the old version is discarded and no checkpoint is written for the source, + // so the next automatic review reads its current version. + expect(f.db.supervisionReviewStore().batches(runId)).toEqual([]) + const next = await f.service().run(automatic) + expect(next.runId).not.toBe(runId) + expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version']) }) it('makes a manual review incremental by default and keeps explicit re-analysis out of shared progress', async () => { diff --git a/src/main/assistant/supervision-worker.test.ts b/src/main/assistant/supervision-worker.test.ts index e92e841f..0f6e5001 100644 --- a/src/main/assistant/supervision-worker.test.ts +++ b/src/main/assistant/supervision-worker.test.ts @@ -45,7 +45,7 @@ async function fixture() { { documentId: 'document', documentName: 'Reference', snippet: 'Knowledge \u{1f600}', chunkId: 'chunk' } ] }), conversations[0]!.messages[0]!.id) db.createTask({ id: randomUUID(), conversationId: conversations[0]!.id, projectId: project.id, - title: 'Visible task', instructions: 'Test', origin: 'user', visible: true }) + title: 'Visible task', instructions: 'Test', origin: 'user', visible: true, status: 'completed' }) const state: ReviewState = { request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: '2026-01-01T00:00:00.000Z', to: '2027-01-01T00:00:00.000Z' } }, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, timeoutSeconds: 30, concurrency: 1 }, phase: 'collecting' } @@ -96,15 +96,15 @@ it('rebuilds a partially written manifest after restart and validates completed await f.storage.resumeSupervisionReview(id, new AbortController().signal) expect(f.db.supervisionReviewStore().progress(id).sources).toBe(422) expect(f.db.supervisionReviewStore().load(id).initializing).toBeUndefined() - // A source changed after freezing is re-frozen at its new version instead of failing the run. + // A source changed after freezing is skipped for this run instead of failing it. const changedId = f.conversations[0]!.messages[0]!.id f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', changedId) await f.storage.resumeSupervisionReview(id, new AbortController().signal) const store = f.db.supervisionReviewStore() - expect(store.progress(id)).toMatchObject({ sources: 422, revisedSources: 1 }) + expect(store.progress(id)).toMatchObject({ sources: 421, skippedSources: 1 }) expect(store.load(id).restartRequired).toBeUndefined() - expect(f.sql.prepare('SELECT length, processed_offset FROM supervision_review_sources WHERE run_id = ? AND source = ?') - .get(id, `message:${changedId}`)).toMatchObject({ length: 'Changed'.length, processed_offset: 0 }) + expect(f.sql.prepare('SELECT 1 FROM supervision_review_sources WHERE run_id = ? AND source = ?') + .get(id, `message:${changedId}`)).toBeUndefined() }) it.each(['paused', 'cancelled'] as const)('accepts %s during production initialization, holds the slot, and never calls the model', async status => { diff --git a/src/renderer/src/SupervisionReviewStages.tsx b/src/renderer/src/SupervisionReviewStages.tsx index d11e1f33..65256050 100644 --- a/src/renderer/src/SupervisionReviewStages.tsx +++ b/src/renderer/src/SupervisionReviewStages.tsx @@ -24,7 +24,7 @@ export function SupervisionReviewStages({ row, stopping }: { row: SupervisionAct

{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}

{!!progress.omittedSources &&

{t('activity.omittedSources', { count: progress.omittedSources })}

} - {!!progress.revisedSources &&

{t('activity.revisedSources', { count: progress.revisedSources })}

} + {!!progress.skippedSources &&

{t('activity.skippedSources', { count: progress.skippedSources })}

} {!progress.complete && !progress.phase &&

{t('activity.stageUnknown')}

} {row.supervisionStatus === 'running' && !stopping &&

{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}

} diff --git a/src/renderer/src/SupervisorActivity.tsx b/src/renderer/src/SupervisorActivity.tsx index fff638d9..4b621c91 100644 --- a/src/renderer/src/SupervisorActivity.tsx +++ b/src/renderer/src/SupervisorActivity.tsx @@ -229,7 +229,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c {row.reviewProgress &&

{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}

} {!!row.reviewProgress?.omittedSources &&

{t('activity.omittedSources', { count: row.reviewProgress.omittedSources })}

} - {!!row.reviewProgress?.revisedSources &&

{t('activity.revisedSources', { count: row.reviewProgress.revisedSources })}

} + {!!row.reviewProgress?.skippedSources &&

{t('activity.skippedSources', { count: row.reviewProgress.skippedSources })}

} {row.reviewProgress?.navigationNodes !== undefined &&

{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}

} {row.summary &&

{row.summary}

} {row.reviewProgress?.settings &&
{t('reviewSettings.configuration')} diff --git a/src/renderer/src/SupervisorWorkspace.test.tsx b/src/renderer/src/SupervisorWorkspace.test.tsx index 574cb7cb..06a5a462 100644 --- a/src/renderer/src/SupervisorWorkspace.test.tsx +++ b/src/renderer/src/SupervisorWorkspace.test.tsx @@ -225,7 +225,7 @@ describe('SupervisorWorkspace', () => { vi.setSystemTime(now) fireEvent.click(screen.getByRole('button', { name: copy.newReview })) const picker = screen.getByLabelText(copy.period) - expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'custom', 'range']) + expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'all', 'custom', 'range']) expect(picker).toHaveValue('7') fireEvent.change(picker, { target: { value: 'custom' } }) fireEvent.change(screen.getByRole('spinbutton', { name: copy.dayCount }), { target: { value: '45' } }) @@ -245,6 +245,29 @@ describe('SupervisorWorkspace', () => { timeRange: { from: new Date(now.getTime() - 46 * 86400_000).toISOString(), to: now.toISOString() } }) }) + it.each(['zh-CN', 'en-US'] as const)('reviews all time from the epoch through now and names it in the reanalysis dialog in %s', async locale => { + await changeUiLocale(locale) + const copy = i18nResources[locale].heartbeat.supervisor + const run = vi.fn().mockResolvedValue(undefined) + window.goodbuddy = { supervision: { overview: async () => [], run } } as never + render() + await screen.findByText(copy.empty) + vi.useFakeTimers({ toFake: ['Date'] }) + const now = new Date(2026, 9, 5, 15, 30) + vi.setSystemTime(now) + fireEvent.click(screen.getByRole('button', { name: copy.newReview })) + fireEvent.change(screen.getByLabelText(copy.period), { target: { value: 'all' } }) + const timeRange = { from: new Date(0).toISOString(), to: now.toISOString() } + await act(async () => fireEvent.click(screen.getByRole('button', { name: copy.run }))) + expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', scope: { kind: 'global' }, timeRange }) + fireEvent.click(screen.getByRole('button', { name: copy.more })) + fireEvent.click(screen.getByRole('menuitem', { name: copy.reanalyze })) + const dialog = screen.getByRole('alertdialog') + expect(dialog).toHaveTextContent(copy.allTimeLabel) + await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: copy.reanalyzeConfirm }))) + expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange }) + }) + it.each([ ['2025-11-02', '2025-11-02'], ['2026-03-08', '2026-03-08'], diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index 28bd327b..1188637f 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -240,6 +240,8 @@ export function SupervisorWorkspace({ const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError) const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => { + // All time: from the epoch, which predates any stored source, through now. + if (period === 'all') return { range: { from: new Date(0).toISOString(), to: now.toISOString() }, label: t('supervisor.allTimeLabel') } if (period === 'range') { const from = new Date(`${startDate}T00:00:00`) const end = new Date(`${endDate}T00:00:00`) @@ -651,6 +653,7 @@ export function SupervisorWorkspace({ {t('supervisor.days', { count: value })} ))} + diff --git a/src/renderer/src/i18n/locales/en-US/heartbeat.ts b/src/renderer/src/i18n/locales/en-US/heartbeat.ts index 20d9c7f7..9d54b813 100644 --- a/src/renderer/src/i18n/locales/en-US/heartbeat.ts +++ b/src/renderer/src/i18n/locales/en-US/heartbeat.ts @@ -64,7 +64,7 @@ export const heartbeat = { 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', omittedSources: '{{count}} sources skipped because their conversations were deleted. Coverage includes only retained sources. If none remain, the review finishes without an empty report.', - revisedSources: '{{count}} sources changed during the review and were reviewed again at their latest version; batches of older versions were discarded.', + skippedSources: '{{count}} sources changed during the review. They were skipped this time and will be handled in the next review.', navigationSaved: '{{count}} navigation merges saved', resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration', treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.', @@ -123,6 +123,7 @@ export const heartbeat = { empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an heartbeat plan.', run: 'Review', running: 'Reviewing…', more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze', reanalyzeHint: 'All content in {{scope}} for {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss', + allTime: 'All time', allTimeLabel: 'all time', customDays: 'Custom days', dateRange: 'Date range', dayCount: 'Number of days', startDate: 'Start date', endDate: 'End date', recentDays: 'the last {{count}} days', dateRangeLabel: '{{from}} through {{to}} (local dates, through now if ending today)', customDaysHelp: 'Counts back from now in 24-hour days.', dateRangeHelp: 'Local dates, including the end day. If ending today, includes content through now.', diff --git a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts index 74642d45..97296ca2 100644 --- a/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts +++ b/src/renderer/src/i18n/locales/zh-CN/heartbeat.ts @@ -61,7 +61,7 @@ export const heartbeat = { stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。', coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源', omittedSources: '会话已删除,已跳过 {{count}} 个来源;覆盖统计仅包含保留的来源。没有剩余来源时结束回顾,不生成空报告。', - revisedSources: '回顾期间有 {{count}} 个来源被修改,已按最新内容重新回顾;旧版本的批次已丢弃。', + skippedSources: '回顾期间有 {{count}} 个来源发生变化,本次已跳过,将在下次回顾中处理。', navigationSaved: '已保存 {{count}} 次导航合并', resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置', treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。', @@ -120,6 +120,7 @@ export const heartbeat = { empty: '还没有成功回顾', emptyHint: '即使没有心跳计划,也可以手动回顾当前进展。', run: '回顾', running: '回顾整理中…', more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理', reanalyzeHint: '将重新分析{{scope}}在{{period}}内的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示', + allTime: '全部', allTimeLabel: '全部时间', customDays: '自定义天数', dateRange: '指定日期', dayCount: '天数', startDate: '开始日期', endDate: '结束日期', recentDays: '最近 {{count}} 天', dateRangeLabel: '{{from}} 至 {{to}}(本地日期,结束日期为今天时截至当前)', customDaysHelp: '从当前时刻向前计算,每天按 24 小时计。', dateRangeHelp: '按本地日期计算,包含结束日期当天;结束日期为今天时截至当前时刻。', diff --git a/src/shared/supervision-review-contracts.ts b/src/shared/supervision-review-contracts.ts index 433c38da..1573ed0d 100644 --- a/src/shared/supervision-review-contracts.ts +++ b/src/shared/supervision-review-contracts.ts @@ -39,8 +39,8 @@ export type SupervisionReviewProgress = { remainingSources: number /** Sources excluded because their owning conversation was deleted; not reviewed coverage. */ omittedSources?: number - /** Sources that changed during the run and were reviewed again at their newer version. */ - revisedSources?: number + /** Frozen sources that changed or disappeared during the run; skipped and left for the next review. */ + skippedSources?: number complete: boolean restartRequired?: boolean inFlight?: number