返回 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' | 'settling'
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 | 'single-page-retry'
98 | 'addPage'
99 targetPageId?: string
100 targetPageNumber?: number
101 completedPageBaseCount?: number
102 failedPageBaseKeys?: string[]
103 execute: (context: TContext) => Promise<void>
104 }): Promise<{ runId: string; queued: boolean }> {
105 const {
106 reservation,
107 context,
108 kind,
109 totalPages,
110 activityKind,
111 targetPageId,
112 targetPageNumber,
113 completedPageBaseCount,
114 failedPageBaseKeys,
115 execute
116 } = args
117 const runId = context.runId
118 if (reservation.jobId !== runId) {
119 throw new Error(`Generation reservation jobId mismatch: expected ${runId}`)
120 }
121 this.assertNotCancelled(reservation)
122
123 const willRunNow = this.activeCount + this.startingCount < MAX_ACTIVE_GENERATION_JOBS
124 if (willRunNow) {
125 this.startingCount += 1
126 }
127 let runCreated = false
128 let jobCreated = false
129
130 try {
131 await this.ctx.db.createGenerationRunWithSessionJob({
132 run: {
133 id: runId,
134 sessionId: context.sessionId,
135 mode: context.effectiveMode,
136 totalPages,
137 modelConfigId: context.modelConfigId,
138 animationPreferences: context.animationPreferences || null,
139 metadata: {
140 backgroundJob: true,
141 kind,
142 jobKind: kind
143 }
144 },
145 job: {
146 id: runId,
147 sessionId: context.sessionId,
148 kind,
149 status: willRunNow ? 'active' : 'pending',
150 previousSessionStatus: normalizeRestoredSessionStatus(context.previousSessionStatus),
151 totalPages
152 }
153 })
154 runCreated = true
155 jobCreated = true
156 this.assertNotCancelled(reservation)
157
158 const state = this.ctx.sessionRuns.beginSessionRunState({
159 sessionId: context.sessionId,
160 runId,
161 mode: context.effectiveMode,
162 kind,
163 activityKind,
164 targetPageId,
165 targetPageNumber,
166 totalPages,
167 previousSessionStatus: context.previousSessionStatus,
168 status: willRunNow ? 'running' : 'queued',
169 completedPageBaseCount,
170 failedPageBaseKeys
171 })
172 this.ctx.runtimeEmitters.emitSessionRunLifecycle(state)
173
174 const job: BackgroundJob<FinalizeContext> = {
175 sessionId: context.sessionId,
176 runId,
177 kind,
178 context,
179 totalPages,
180 status: 'pending',
181 reservedCapacitySlot: willRunNow,
182 reservation,
183 execute: execute as (context: FinalizeContext) => Promise<void>,
184 removeAbortListener: () => undefined
185 }
186 this.jobsBySession.set(context.sessionId, job)
187 this.watchCancellation(job)
188
189 if (willRunNow) {
190 this.startJob(job, { reservedSlot: true })
191 } else {
192 this.pendingQueue.push(job)
193 this.ctx.runtimeEmitters.emitGenerateChunk(context.sessionId, {
194 type: 'stage_started',
195 payload: {
196 runId,
197 stage: 'queued',
198 label: '排队中',
199 progress: 0,
200 totalPages
201 }
202 })
203 log.info('[generate:job] queued', { sessionId: context.sessionId, runId, kind })
204 }
205
206 return { runId, queued: !willRunNow }
207 } catch (error) {
208 if (willRunNow) {
209 this.startingCount = Math.max(0, this.startingCount - 1)
210 }
211 const message =
212 error instanceof Error ? error.message : String(error || 'Generation job setup failed')
213 if (jobCreated) {
214 await this.ctx.db
215 .updateSessionJobStatus(runId, 'aborted', {
216 abortReason: isCancellationMessage(message) ? 'cancelled' : 'setup_failed'
217 })
218 .catch((statusError) => {
219 log.warn('[generate:job] failed to abort partially created job', {
220 sessionId: context.sessionId,
221 runId,
222 message: statusError instanceof Error ? statusError.message : String(statusError)
223 })
224 })
225 }
226 if (runCreated) {
227 const settled = await Promise.allSettled([
228 this.ctx.db.updateGenerationRunStatus(runId, 'failed', message),
229 this.ctx.db.updateSessionStatus(
230 context.sessionId,
231 normalizeRestoredSessionStatus(context.previousSessionStatus)
232 )
233 ])
234 settled.forEach((result) => {
235 if (result.status === 'rejected') {
236 log.warn('[generate:job] failed to clean up partial job setup', {
237 sessionId: context.sessionId,
238 runId,
239 message:
240 result.reason instanceof Error ? result.reason.message : String(result.reason)
241 })
242 }
243 })
244 }
245 this.release(reservation)
246 throw error
247 }
248 }
249
250 async cancel(sessionId: string): Promise<boolean> {
251 const job = this.jobsBySession.get(sessionId)
252 if (job?.status === 'settling') return false
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 this.assertNotCancelled(job.reservation)
348 // execute() only resolves after generation, history, and session state have committed.
349 // Keep the lease until its session-job row is durable, but do not let a late cancel
350 // turn that already committed success into a contradictory cancelled run.
351 job.status = 'settling'
352 } catch (error) {
353 await this.settleFailedJob(job, error)
354 return
355 }
356
357 try {
358 await this.ctx.db.updateSessionJobStatus(job.runId, 'finished')
359 } catch (error) {
360 log.error('[generate:job] failed to settle completed session job', {
361 sessionId: job.sessionId,
362 runId: job.runId,
363 message: error instanceof Error ? error.message : String(error || '')
364 })
365 return
366 }
367 this.ctx.runtimeEmitters.emitRuntimeJobTerminal({
368 sessionId: job.sessionId,
369 jobId: job.runId,
370 domain: 'generation',
371 status: 'completed'
372 })
373 } finally {
374 job.removeAbortListener()
375 this.ctx.agentManager.removeSession(job.sessionId)
376 this.jobsBySession.delete(job.sessionId)
377 this.release(job.reservation)
378 this.activeCount = Math.max(0, this.activeCount - 1)
379 this.processQueue()
380 }
381 }
382
383 private async settleFailedJob(job: BackgroundJob<FinalizeContext>, error: unknown): Promise<void> {
384 const message = error instanceof Error ? error.message : String(error || '')
385 const cancelled = job.reservation.signal.aborted || isCancellationMessage(message)
386 let terminalStatePersisted = false
387 let finalizationFailed = false
388 try {
389 await finalizeGenerationFailure(
390 this.ctx,
391 job.context,
392 cancelled ? new Error('生成已取消') : error
393 )
394 terminalStatePersisted = true
395 } catch (finalizeError) {
396 finalizationFailed = true
397 log.error('[generate:job] failed to finalize generation', {
398 sessionId: job.sessionId,
399 runId: job.runId,
400 message:
401 finalizeError instanceof Error ? finalizeError.message : String(finalizeError || '')
402 })
403 const fallbackResults = await Promise.allSettled([
404 this.ctx.db.updateGenerationRunStatus(
405 job.runId,
406 'failed',
407 message || 'Generation failed'
408 ),
409 this.ctx.db.updateSessionStatus(
410 job.sessionId,
411 resolveGenerationFailureSessionStatus(job.context, cancelled)
412 )
413 ])
414 terminalStatePersisted = fallbackResults.every((result) => result.status === 'fulfilled')
415 if (!terminalStatePersisted) {
416 const failure = fallbackResults.find((result) => result.status === 'rejected')
417 log.error('[generate:job] failed to persist fallback generation terminal state', {
418 sessionId: job.sessionId,
419 runId: job.runId,
420 message:
421 failure?.status === 'rejected' && failure.reason instanceof Error
422 ? failure.reason.message
423 : String(failure?.status === 'rejected' ? failure.reason : '')
424 })
425 }
426 }
427
428 // Do not mark the session job terminal until the generation run and session state are
429 // both durable. Otherwise startup recovery will no longer find an orphaned active job.
430 if (!terminalStatePersisted) return
431
432 // finalizeGenerationFailure publishes this itself on its normal path. Its
433 // fallback only persists the database state, so close the in-memory run
434 // before releasing the lease; otherwise reserve() will keep treating the
435 // session as running for the rest of the process lifetime.
436 if (finalizationFailed) {
437 this.ctx.runtimeEmitters.emitGenerateChunk(job.sessionId, {
438 type: 'run_error',
439 payload: {
440 runId: job.runId,
441 message: cancelled ? '生成已取消' : message || 'Generation failed',
442 cancelled
443 }
444 })
445 }
446
447 let jobStatusPersisted = false
448 try {
449 if (cancelled) {
450 await this.ctx.db.updateSessionJobStatus(job.runId, 'aborted', {
451 abortReason: 'cancelled'
452 })
453 } else {
454 await this.ctx.db.updateSessionJobStatus(job.runId, 'finished')
455 }
456 jobStatusPersisted = true
457 } catch (statusError) {
458 log.error('[generate:job] failed to settle session job', {
459 sessionId: job.sessionId,
460 runId: job.runId,
461 message: statusError instanceof Error ? statusError.message : String(statusError || '')
462 })
463 }
464
465 if (jobStatusPersisted) {
466 this.ctx.runtimeEmitters.emitRuntimeJobTerminal({
467 sessionId: job.sessionId,
468 jobId: job.runId,
469 domain: 'generation',
470 status: cancelled ? 'cancelled' : 'failed',
471 errorCode: cancelled ? undefined : 'generation_failed',
472 errorMessage: cancelled ? undefined : message
473 })
474 }
475 }
476
477 private processQueue(): void {
478 while (
479 this.activeCount + this.startingCount < MAX_ACTIVE_GENERATION_JOBS &&
480 this.pendingQueue.length > 0
481 ) {
482 const next = this.pendingQueue.shift()
483 if (!next || !this.jobsBySession.has(next.sessionId)) continue
484 if (next.reservation.signal.aborted) {
485 void this.cancelPendingJob(next)
486 continue
487 }
488 this.startJob(next)
489 }
490 }
491
492 private watchCancellation(job: BackgroundJob<FinalizeContext>): void {
493 const onAbort = (): void => {
494 if (job.status === 'pending') void this.cancelPendingJob(job)
495 }
496 job.removeAbortListener = (): void => job.reservation.signal.removeEventListener('abort', onAbort)
497 job.reservation.signal.addEventListener('abort', onAbort, { once: true })
498 if (job.reservation.signal.aborted) onAbort()
499 }
500
501 private async cancelPendingJob(job: BackgroundJob<FinalizeContext>): Promise<void> {
502 if (job.pendingCancellation) return job.pendingCancellation
503 if (job.status !== 'pending' || this.jobsBySession.get(job.sessionId) !== job) return
504
505 this.pendingQueue = this.pendingQueue.filter((candidate) => candidate !== job)
506 this.jobsBySession.delete(job.sessionId)
507 job.removeAbortListener()
508 if (job.reservedCapacitySlot) {
509 this.startingCount = Math.max(0, this.startingCount - 1)
510 job.reservedCapacitySlot = false
511 }
512
513 job.pendingCancellation = (async () => {
514 try {
515 await this.ctx.db.updateSessionJobStatus(job.runId, 'aborted', { abortReason: 'cancelled' })
516 await this.ctx.db.updateGenerationRunStatus(job.runId, 'failed', '生成已取消')
517 await this.ctx.db.updateSessionStatus(
518 job.sessionId,
519 normalizeRestoredSessionStatus(job.context.previousSessionStatus)
520 )
521 this.ctx.runtimeEmitters.emitGenerateChunk(job.sessionId, {
522 type: 'run_error',
523 payload: { runId: job.runId, message: '生成已取消' }
524 })
525 this.ctx.runtimeEmitters.emitRuntimeJobTerminal({
526 sessionId: job.sessionId,
527 jobId: job.runId,
528 domain: 'generation',
529 status: 'cancelled'
530 })
531 } catch (error) {
532 log.warn('[generate:job] failed to settle cancelled queued job', {
533 sessionId: job.sessionId,
534 runId: job.runId,
535 message: error instanceof Error ? error.message : String(error)
536 })
537 } finally {
538 this.ctx.agentManager.removeSession(job.sessionId)
539 this.release(job.reservation)
540 this.processQueue()
541 }
542 })()
543 return job.pendingCancellation
544 }
545 }
546
546 lines TYPESCRIPT