返回 oh-my-ppt
job-manager.ts
根目录 / src / main / generation / job-manager.ts
1 import log from 'electron-log/main.js'
2 import type { SessionJobKind } from '../db/database'
3 import type { FinalizeContext } from './types'
4 import type { GenerationContext } from './context'
5 import {
6 finalizeGenerationFailure,
7 resolveGenerationFailureSessionStatus
8 } from './finalization'
9 import { isCancellationMessage, normalizeRestoredSessionStatus } from './status-utils'
10 import { JobCoordinator, sessionLockKey, type JobLease } from '../agent-runtime'
11
12 const MAX_ACTIVE_GENERATION_JOBS = 2
13
14 export type GenerateJobReservation = JobLease
15
16 type BackgroundJob<TContext extends FinalizeContext> = {
17 sessionId: string
18 runId: string
19 kind: SessionJobKind
20 context: TContext
21 totalPages: number
22 status: 'pending' | 'active'
23 reservedCapacitySlot: boolean
24 reservation: GenerateJobReservation
25 execute: (context: TContext) => Promise<void>
26 pendingCancellation?: Promise<void>
27 removeAbortListener: () => void
28 }
29
30 export class GenerateJobManager {
31 private ctx: GenerationContext
32 private jobsBySession = new Map<string, BackgroundJob<FinalizeContext>>()
33 private pendingQueue: Array<BackgroundJob<FinalizeContext>> = []
34 private activeCount = 0
35 private startingCount = 0
36
37 private coordinator: JobCoordinator
38
39 constructor(ctx: GenerationContext, coordinator = new JobCoordinator()) {
40 this.ctx = ctx
41 this.coordinator = coordinator
42 }
43
44 async reserve(
45 operation: string,
46 sessionId: string,
47 runId: string
48 ): Promise<
49 | { alreadyRunning: true; runId?: string }
50 | { alreadyRunning: false; reservation: GenerateJobReservation }
51 > {
52 const existingJob = this.jobsBySession.get(sessionId)
53 if (existingJob) {
54 return { alreadyRunning: true, runId: existingJob.runId }
55 }
56 const existingRunState = this.ctx.sessionRuns.sessionRunStates.get(sessionId)
57 if (existingRunState?.status === 'queued' || existingRunState?.status === 'running') {
58 return { alreadyRunning: true, runId: existingRunState.runId }
59 }
60 const result = await this.coordinator.reserve({
61 jobId: runId,
62 domain: 'generation',
63 owner: { kind: 'session', id: sessionId },
64 claims: { write: [sessionLockKey(sessionId)] },
65 wait: 'fail'
66 })
67 if (result.status === 'busy') {
68 return { alreadyRunning: true, runId: result.conflictingJobId }
69 }
70 log.info('[generate:job] reserved', { sessionId, runId, operation })
71 return { alreadyRunning: false, reservation: result.lease }
72 }
73
74 assertNotCancelled(reservation: GenerateJobReservation | null | undefined): void {
75 if (reservation?.signal.aborted) {
76 throw new Error('生成已取消')
77 }
78 }
79
80 release(reservation: GenerateJobReservation | null | undefined): void {
81 if (!reservation) return
82 reservation.release()
83 }
84
85 async enqueue<TContext extends FinalizeContext>(args: {
86 reservation: GenerateJobReservation
87 kind: Extract<
88 SessionJobKind,
89 'standard' | 'template' | 'retry' | 'add-page' | 'single-page-retry'
90 >
91 context: TContext
92 totalPages: number
93 activityKind?:
94 | 'page-edit'
95 | 'edit'
96 | 'style-switch'
97 | 'page-beautify'
98 | 'single-page-retry'
99 | 'addPage'
100 targetPageId?: string
101 targetPageNumber?: number
102 completedPageBaseCount?: number
103 failedPageBaseKeys?: string[]
104 execute: (context: TContext) => Promise<void>
105 }): Promise<{ runId: string; queued: boolean }> {
106 const {
107 reservation,
108 context,
109 kind,
110 totalPages,
111 activityKind,
112 targetPageId,
113 targetPageNumber,
114 completedPageBaseCount,
115 failedPageBaseKeys,
116 execute
117 } = args
118 const runId = context.runId
119 if (reservation.jobId !== runId) {
120 throw new Error(`Generation reservation jobId mismatch: expected ${runId}`)
121 }
122 this.assertNotCancelled(reservation)
123
124 const willRunNow = this.activeCount + this.startingCount < MAX_ACTIVE_GENERATION_JOBS
125 if (willRunNow) {
126 this.startingCount += 1
127 }
128 let runCreated = false
129 let jobCreated = false
130
131 try {
132 await this.ctx.db.createGenerationRunWithSessionJob({
133 run: {
134 id: runId,
135 sessionId: context.sessionId,
136 mode: context.effectiveMode,
137 totalPages,
138 modelConfigId: context.modelConfigId,
139 animationPreferences: context.animationPreferences || null,
140 metadata: {
141 backgroundJob: true,
142 kind,
143 jobKind: kind
144 }
145 },
146 job: {
147 id: runId,
148 sessionId: context.sessionId,
149 kind,
150 status: willRunNow ? 'active' : 'pending',
151 previousSessionStatus: normalizeRestoredSessionStatus(context.previousSessionStatus),
152 totalPages
153 }
154 })
155 runCreated = true
156 jobCreated = true
157 this.assertNotCancelled(reservation)
158
159 const state = this.ctx.sessionRuns.beginSessionRunState({
160 sessionId: context.sessionId,
161 runId,
162 mode: context.effectiveMode,
163 kind,
164 activityKind,
165 targetPageId,
166 targetPageNumber,
167 totalPages,
168 previousSessionStatus: context.previousSessionStatus,
169 status: willRunNow ? 'running' : 'queued',
170 completedPageBaseCount,
171 failedPageBaseKeys
172 })
173 this.ctx.runtimeEmitters.emitSessionRunLifecycle(state)
174
175 const job: BackgroundJob<FinalizeContext> = {
176 sessionId: context.sessionId,
177 runId,
178 kind,
179 context,
180 totalPages,
181 status: 'pending',
182 reservedCapacitySlot: willRunNow,
183 reservation,
184 execute: execute as (context: FinalizeContext) => Promise<void>,
185 removeAbortListener: () => undefined
186 }
187 this.jobsBySession.set(context.sessionId, job)
188 this.watchCancellation(job)
189
190 if (willRunNow) {
191 this.startJob(job, { reservedSlot: true })
192 } else {
193 this.pendingQueue.push(job)
194 this.ctx.runtimeEmitters.emitGenerateChunk(context.sessionId, {
195 type: 'stage_started',
196 payload: {
197 runId,
198 stage: 'queued',
199 label: '排队中',
200 progress: 0,
201 totalPages
202 }
203 })
204 log.info('[generate:job] queued', { sessionId: context.sessionId, runId, kind })
205 }
206
207 return { runId, queued: !willRunNow }
208 } catch (error) {
209 if (willRunNow) {
210 this.startingCount = Math.max(0, this.startingCount - 1)
211 }
212 const message =
213 error instanceof Error ? error.message : String(error || 'Generation job setup failed')
214 if (jobCreated) {
215 await this.ctx.db
216 .updateSessionJobStatus(runId, 'aborted', {
217 abortReason: isCancellationMessage(message) ? 'cancelled' : 'setup_failed'
218 })
219 .catch((statusError) => {
220 log.warn('[generate:job] failed to abort partially created job', {
221 sessionId: context.sessionId,
222 runId,
223 message: statusError instanceof Error ? statusError.message : String(statusError)
224 })
225 })
226 }
227 if (runCreated) {
228 const settled = await Promise.allSettled([
229 this.ctx.db.updateGenerationRunStatus(runId, 'failed', message),
230 this.ctx.db.updateSessionStatus(
231 context.sessionId,
232 normalizeRestoredSessionStatus(context.previousSessionStatus)
233 )
234 ])
235 settled.forEach((result) => {
236 if (result.status === 'rejected') {
237 log.warn('[generate:job] failed to clean up partial job setup', {
238 sessionId: context.sessionId,
239 runId,
240 message:
241 result.reason instanceof Error ? result.reason.message : String(result.reason)
242 })
243 }
244 })
245 }
246 this.release(reservation)
247 throw error
248 }
249 }
250
251 async cancel(sessionId: string): Promise<boolean> {
252 const job = this.jobsBySession.get(sessionId)
253 const activeJob = this.coordinator.getByOwner({ kind: 'session', id: sessionId })
254 const cancelled = activeJob ? this.coordinator.cancel(activeJob.jobId) : false
255 if (!job) return cancelled
256 if (job.status === 'pending') {
257 await this.cancelPendingJob(job)
258 return cancelled || Boolean(job.pendingCancellation)
259 }
260 return true
261 }
262
263 async abortInterruptedJobs(reason: string): Promise<void> {
264 const activeJobs = await this.ctx.db.listActiveSessionJobs([
265 'standard',
266 'template',
267 'retry',
268 'add-page',
269 'single-page-retry'
270 ])
271 for (const job of activeJobs) {
272 if (this.jobsBySession.has(job.session_id)) continue
273 const reservation = this.coordinator.getByOwner({ kind: 'session', id: job.session_id })
274 if (reservation?.jobId === job.id) continue
275 const generationRun = await this.ctx.db.getGenerationRun(job.id)
276 if (generationRun?.status === 'completed' || generationRun?.status === 'partial') {
277 await this.ctx.db.updateSessionJobStatus(job.id, 'finished')
278 continue
279 }
280 await this.ctx.db.updateSessionJobStatus(job.id, 'aborted', { abortReason: reason })
281 await this.ctx.db.updateGenerationRunStatus(job.id, 'failed', reason)
282 await this.ctx.db.updateSessionStatus(
283 job.session_id,
284 normalizeRestoredSessionStatus(job.previous_session_status)
285 )
286 }
287 }
288
289 private startJob(
290 job: BackgroundJob<FinalizeContext>,
291 options?: { reservedSlot?: boolean }
292 ): void {
293 if (this.jobsBySession.get(job.sessionId) !== job || job.reservation.signal.aborted) {
294 void this.cancelPendingJob(job)
295 return
296 }
297 job.status = 'active'
298 if (options?.reservedSlot) {
299 this.startingCount = Math.max(0, this.startingCount - 1)
300 job.reservedCapacitySlot = false
301 }
302 this.activeCount += 1
303 void this.activateAndRunJob(job, !options?.reservedSlot)
304 }
305
306 private async activateAndRunJob(
307 job: BackgroundJob<FinalizeContext>,
308 emitStarted: boolean
309 ): Promise<void> {
310 try {
311 await this.ctx.db.updateSessionJobStatus(job.runId, 'active')
312 } catch (error) {
313 log.warn('[generate:job] failed to mark active', {
314 sessionId: job.sessionId,
315 runId: job.runId,
316 message: error instanceof Error ? error.message : String(error)
317 })
318 await this.runJob(job, error)
319 return
320 }
321
322 const state = this.ctx.sessionRuns.sessionRunStates.get(job.sessionId)
323 if (state?.runId === job.runId) {
324 state.status = 'running'
325 state.updatedAt = Date.now()
326 }
327 log.info('[generate:job] start', {
328 sessionId: job.sessionId,
329 runId: job.runId,
330 kind: job.kind
331 })
332 if (emitStarted) {
333 this.ctx.runtimeEmitters.emitRuntimeJobStarted({
334 sessionId: job.sessionId,
335 jobId: job.runId,
336 domain: 'generation'
337 })
338 }
339 await this.runJob(job)
340 }
341
342 private async runJob(job: BackgroundJob<FinalizeContext>, activationError?: unknown): Promise<void> {
343 try {
344 try {
345 if (activationError) throw activationError
346 await job.execute(job.context)
347 } catch (error) {
348 await this.settleFailedJob(job, error)
349 return
350 }
351
352 try {
353 await this.ctx.db.updateSessionJobStatus(job.runId, 'finished')
354 } catch (error) {
355 log.error('[generate:job] failed to settle completed session job', {
356 sessionId: job.sessionId,
357 runId: job.runId,
358 message: error instanceof Error ? error.message : String(error || '')
359 })
360 return
361 }
362 this.ctx.runtimeEmitters.emitRuntimeJobTerminal({
363 sessionId: job.sessionId,
364 jobId: job.runId,
365 domain: 'generation',
366 status: 'completed'
367 })
368 } finally {
369 job.removeAbortListener()
370 this.ctx.agentManager.removeSession(job.sessionId)
371 this.jobsBySession.delete(job.sessionId)
372 this.release(job.reservation)
373 this.activeCount = Math.max(0, this.activeCount - 1)
374 this.processQueue()
375 }
376 }
377
378 private async settleFailedJob(job: BackgroundJob<FinalizeContext>, error: unknown): Promise<void> {
379 const message = error instanceof Error ? error.message : String(error || '')
380 const cancelled = job.reservation.signal.aborted || isCancellationMessage(message)
381 let terminalStatePersisted = false
382 let finalizationFailed = false
383 try {
384 await finalizeGenerationFailure(
385 this.ctx,
386 job.context,
387 cancelled ? new Error('生成已取消') : error
388 )
389 terminalStatePersisted = true
390 } catch (finalizeError) {
391 finalizationFailed = true
392 log.error('[generate:job] failed to finalize generation', {
393 sessionId: job.sessionId,
394 runId: job.runId,
395 message:
396 finalizeError instanceof Error ? finalizeError.message : String(finalizeError || '')
397 })
398 const fallbackResults = await Promise.allSettled([
399 this.ctx.db.updateGenerationRunStatus(
400 job.runId,
401 'failed',
402 message || 'Generation failed'
403 ),
404 this.ctx.db.updateSessionStatus(
405 job.sessionId,
406 resolveGenerationFailureSessionStatus(job.context, cancelled)
407 )
408 ])
409 terminalStatePersisted = fallbackResults.every((result) => result.status === 'fulfilled')
410 if (!terminalStatePersisted) {
411 const failure = fallbackResults.find((result) => result.status === 'rejected')
412 log.error('[generate:job] failed to persist fallback generation terminal state', {
413 sessionId: job.sessionId,
414 runId: job.runId,
415 message:
416 failure?.status === 'rejected' && failure.reason instanceof Error
417 ? failure.reason.message
418 : String(failure?.status === 'rejected' ? failure.reason : '')
419 })
420 }
421 }
422
423 // Do not mark the session job terminal until the generation run and session state are
424 // both durable. Otherwise startup recovery will no longer find an orphaned active job.
425 if (!terminalStatePersisted) return
426
427 // finalizeGenerationFailure publishes this itself on its normal path. Its
428 // fallback only persists the database state, so close the in-memory run
429 // before releasing the lease; otherwise reserve() will keep treating the
430 // session as running for the rest of the process lifetime.
431 if (finalizationFailed) {
432 this.ctx.runtimeEmitters.emitGenerateChunk(job.sessionId, {
433 type: 'run_error',
434 payload: {
435 runId: job.runId,
436 message: cancelled ? '生成已取消' : message || 'Generation failed',
437 cancelled
438 }
439 })
440 }
441
442 let jobStatusPersisted = false
443 try {
444 if (cancelled) {
445 await this.ctx.db.updateSessionJobStatus(job.runId, 'aborted', {
446 abortReason: 'cancelled'
447 })
448 } else {
449 await this.ctx.db.updateSessionJobStatus(job.runId, 'finished')
450 }
451 jobStatusPersisted = true
452 } catch (statusError) {
453 log.error('[generate:job] failed to settle session job', {
454 sessionId: job.sessionId,
455 runId: job.runId,
456 message: statusError instanceof Error ? statusError.message : String(statusError || '')
457 })
458 }
459
460 if (jobStatusPersisted) {
461 this.ctx.runtimeEmitters.emitRuntimeJobTerminal({
462 sessionId: job.sessionId,
463 jobId: job.runId,
464 domain: 'generation',
465 status: cancelled ? 'cancelled' : 'failed',
466 errorCode: cancelled ? undefined : 'generation_failed',
467 errorMessage: cancelled ? undefined : message
468 })
469 }
470 }
471
472 private processQueue(): void {
473 while (
474 this.activeCount + this.startingCount < MAX_ACTIVE_GENERATION_JOBS &&
475 this.pendingQueue.length > 0
476 ) {
477 const next = this.pendingQueue.shift()
478 if (!next || !this.jobsBySession.has(next.sessionId)) continue
479 if (next.reservation.signal.aborted) {
480 void this.cancelPendingJob(next)
481 continue
482 }
483 this.startJob(next)
484 }
485 }
486
487 private watchCancellation(job: BackgroundJob<FinalizeContext>): void {
488 const onAbort = (): void => {
489 if (job.status === 'pending') void this.cancelPendingJob(job)
490 }
491 job.removeAbortListener = (): void => job.reservation.signal.removeEventListener('abort', onAbort)
492 job.reservation.signal.addEventListener('abort', onAbort, { once: true })
493 if (job.reservation.signal.aborted) onAbort()
494 }
495
496 private async cancelPendingJob(job: BackgroundJob<FinalizeContext>): Promise<void> {
497 if (job.pendingCancellation) return job.pendingCancellation
498 if (job.status !== 'pending' || this.jobsBySession.get(job.sessionId) !== job) return
499
500 this.pendingQueue = this.pendingQueue.filter((candidate) => candidate !== job)
501 this.jobsBySession.delete(job.sessionId)
502 job.removeAbortListener()
503 if (job.reservedCapacitySlot) {
504 this.startingCount = Math.max(0, this.startingCount - 1)
505 job.reservedCapacitySlot = false
506 }
507
508 job.pendingCancellation = (async () => {
509 try {
510 await this.ctx.db.updateSessionJobStatus(job.runId, 'aborted', { abortReason: 'cancelled' })
511 await this.ctx.db.updateGenerationRunStatus(job.runId, 'failed', '生成已取消')
512 await this.ctx.db.updateSessionStatus(
513 job.sessionId,
514 normalizeRestoredSessionStatus(job.context.previousSessionStatus)
515 )
516 this.ctx.runtimeEmitters.emitGenerateChunk(job.sessionId, {
517 type: 'run_error',
518 payload: { runId: job.runId, message: '生成已取消' }
519 })
520 this.ctx.runtimeEmitters.emitRuntimeJobTerminal({
521 sessionId: job.sessionId,
522 jobId: job.runId,
523 domain: 'generation',
524 status: 'cancelled'
525 })
526 } catch (error) {
527 log.warn('[generate:job] failed to settle cancelled queued job', {
528 sessionId: job.sessionId,
529 runId: job.runId,
530 message: error instanceof Error ? error.message : String(error)
531 })
532 } finally {
533 this.ctx.agentManager.removeSession(job.sessionId)
534 this.release(job.reservation)
535 this.processQueue()
536 }
537 })()
538 return job.pendingCancellation
539 }
540 }
541
541 lines TYPESCRIPT