Files
goodbuddy/src/main/assistant/heartbeat-service.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

167 lines
6.7 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import {
heartbeatCreateSchema,
heartbeatHistorySchema,
heartbeatIdSchema,
heartbeatListSchema,
heartbeatPauseSchema,
heartbeatRunNowSchema,
heartbeatUpdateRequestSchema,
type AssistantHeartbeatConfig,
type AssistantHeartbeatEntry,
type AssistantHeartbeatRun
} from '../../shared/assistant-contracts'
import type { ClaimedHeartbeatRun } from './assistant-database'
import type { HeartbeatDomainPort } from './supervision-domain-ports'
export type HeartbeatHistory = {
runs: AssistantHeartbeatRun[]
entries: AssistantHeartbeatEntry[]
}
/** Result of the shared incremental review triggered by one heartbeat. */
export type HeartbeatReviewOutcome = {
status: 'completed' | 'no_change' | 'paused' | 'cancelled'
/** Supervision run that produced the published result, if any. */
runId?: string
}
export type HeartbeatTrigger = {
config: AssistantHeartbeatConfig
run: AssistantHeartbeatRun
}
/**
* The heartbeat no longer reads sources or writes a report of its own. It
* triggers the shared incremental supervisor review; the plan's intervention
* then works only from what that review published.
*/
export interface HeartbeatActions {
review(trigger: HeartbeatTrigger): Promise<HeartbeatReviewOutcome>
suggest?(trigger: HeartbeatTrigger & { supervisionRunId: string }): Promise<number>
}
// A trigger only holds its lease while it hands work to the supervisor.
const triggerLeaseMilliseconds = 5 * 60_000
export class HeartbeatService {
private readonly workerId = `heartbeat:${randomUUID()}`
private readonly pendingManualRuns = new Map<string, Promise<AssistantHeartbeatRun>>()
constructor(
private readonly database: HeartbeatDomainPort,
private readonly actions: HeartbeatActions
) {}
async list(input: unknown = {}): Promise<AssistantHeartbeatConfig[]> {
const parsed = heartbeatListSchema.parse(input)
return this.database.listHeartbeatConfigs(parsed.projectId)
}
async create(input: unknown, now = new Date()): Promise<AssistantHeartbeatConfig> {
const parsed = heartbeatCreateSchema.parse(input)
return this.database.createHeartbeatConfig(parsed, now)
}
async update(input: unknown, now = new Date()): Promise<AssistantHeartbeatConfig> {
const parsed = heartbeatUpdateRequestSchema.parse(input)
return this.database.updateHeartbeatConfig(parsed.id, parsed.config, now)
}
async pause(input: unknown): Promise<void> {
const parsed = heartbeatPauseSchema.parse(input)
await this.database.setHeartbeatPaused(parsed.id, parsed.paused)
}
async remove(input: unknown): Promise<void> {
const parsed = heartbeatIdSchema.parse(input)
await this.database.removeHeartbeatConfig(parsed.id)
}
async history(input: unknown = {}): Promise<HeartbeatHistory> {
const parsed = heartbeatHistorySchema.parse(input)
return {
runs: await this.database.listHeartbeatRuns(parsed.configId, parsed.limit),
entries: await this.database.listHeartbeatEntries(parsed.configId, parsed.limit)
}
}
async runNow(input: unknown, now?: Date): Promise<AssistantHeartbeatRun> {
const parsed = heartbeatRunNowSchema.parse(input)
const pending = this.pendingManualRuns.get(parsed.id)
if (pending) return pending
const operation = (async () => {
const startedAt = now ?? new Date()
const claim = await this.database.claimHeartbeatNow(
parsed.id, parsed.idempotencyKey, this.workerId, startedAt, triggerLeaseMilliseconds
)
if (!claim.acquired) return claim.run
return this.executeClaim(claim, now)
})()
this.pendingManualRuns.set(parsed.id, operation)
try {
return await operation
} finally {
this.pendingManualRuns.delete(parsed.id)
}
}
async processDue(now?: Date): Promise<AssistantHeartbeatRun[]> {
// The database claims at most one due heartbeat per tick; repeated missed
// times collapse into that one check.
const claims = await this.database.claimDueHeartbeats(this.workerId, now ?? new Date(), triggerLeaseMilliseconds)
const results: AssistantHeartbeatRun[] = []
for (const claim of claims) results.push(await this.executeClaim(claim, now))
return results
}
/** Re-derive suggestions for a completed heartbeat whose intervention failed. */
async retrySuggestions(heartbeatRunId: string): Promise<number> {
const run = await this.database.getHeartbeatRun(heartbeatRunId)
const config = await this.database.getHeartbeatConfig(run.configId)
const supervisionRunId = await this.database.supervisionRunForHeartbeat(heartbeatRunId)
if (!supervisionRunId || !this.actions.suggest) throw new Error('此次自动监督没有可生成建议的回顾结果')
return this.intervene({ config, run }, supervisionRunId)
}
private async executeClaim(claim: ClaimedHeartbeatRun, now?: Date): Promise<AssistantHeartbeatRun> {
let run: AssistantHeartbeatRun
try {
run = await this.database.completeHeartbeatTrigger(claim, now ?? new Date())
} catch (error) {
return this.database.failHeartbeatRun(claim, error instanceof Error ? error.message : 'Heartbeat failed', now ?? new Date())
}
const trigger = { config: claim.config, run }
await this.database.setHeartbeatProjection(run.id, 'running', claim.config.scope)
let outcome: HeartbeatReviewOutcome
try {
outcome = await this.actions.review(trigger)
} catch (error) {
await this.database.setHeartbeatProjection(run.id, 'failed', undefined,
error instanceof Error ? error.message : 'Supervision failed')
return this.database.getHeartbeatRun(run.id)
}
if (outcome.status === 'no_change') await this.database.markHeartbeatNoChange(run.id, claim.config.id)
await this.database.setHeartbeatProjection(run.id, 'completed')
if (outcome.status === 'completed' && outcome.runId && (claim.config.intervention ?? 'suggest') === 'suggest' && this.actions.suggest) {
await this.intervene(trigger, outcome.runId, true)
}
return this.database.getHeartbeatRun(run.id)
}
private async intervene(trigger: HeartbeatTrigger, supervisionRunId: string, preserveReview = false): Promise<number> {
await this.database.setHeartbeatSuggestionStatus(trigger.run.id, 'running')
try {
const count = await this.actions.suggest!({ ...trigger, supervisionRunId })
await this.database.setHeartbeatSuggestionStatus(trigger.run.id, count ? 'completed' : 'skipped')
return count
} catch (error) {
// The published graph and incremental progress stay committed.
await this.database.setHeartbeatSuggestionStatus(trigger.run.id, 'failed',
error instanceof Error ? error.message : 'Suggestion failed')
if (preserveReview) return 0
throw error
}
}
}