返回 oh-my-ppt
job-coordinator.test.ts
根目录 / tests / unit / agent-runtime / job-coordinator.test.ts
1 import { describe, expect, it } from 'vitest'
2 import { JobCoordinator } from '../../../src/main/agent-runtime/job/coordinator'
3 import { sessionLockKey } from '../../../src/main/agent-runtime/lock/keys'
4
5 const reserve = (
6 coordinator: JobCoordinator,
7 args: Partial<Parameters<JobCoordinator['reserve']>[0]> & {
8 jobId: string
9 owner: { kind: 'session' | 'style' | 'image-history'; id: string }
10 claims: { read?: string[]; write?: string[] }
11 }
12 ) =>
13 coordinator.reserve({
14 domain: 'generation',
15 wait: 'block',
16 ...args
17 })
18
19 describe('JobCoordinator', () => {
20 it('returns the existing job for the same owner and conflicting fail-fast claim', async () => {
21 const coordinator = new JobCoordinator()
22 const first = await reserve(coordinator, {
23 jobId: 'job-1',
24 owner: { kind: 'session', id: 'session-1' },
25 claims: { write: ['session:session-1'] }
26 })
27 if (first.status !== 'acquired') throw new Error('expected first job to acquire')
28
29 await expect(
30 reserve(coordinator, {
31 jobId: 'job-2',
32 owner: { kind: 'session', id: 'session-1' },
33 claims: { write: ['session:session-1'] }
34 })
35 ).resolves.toEqual({ status: 'busy', conflictingJobId: 'job-1' })
36
37 await expect(
38 reserve(coordinator, {
39 jobId: 'job-3',
40 owner: { kind: 'style', id: 'style-1' },
41 claims: { write: ['session:session-1'] },
42 wait: 'fail'
43 })
44 ).resolves.toEqual({ status: 'busy', conflictingJobId: 'job-1' })
45
46 first.lease.release()
47 })
48
49 it('keeps every outer session writer busy while generation owns the session', async () => {
50 const coordinator = new JobCoordinator()
51 const sessionId = 'session-1'
52 const generation = await reserve(coordinator, {
53 jobId: 'generation-run-1',
54 owner: { kind: 'session', id: sessionId },
55 claims: { write: [sessionLockKey(sessionId)] }
56 })
57 if (generation.status !== 'acquired') throw new Error('expected generation job')
58
59 const outerWriters = [
60 { name: 'retry-failed-pages', domain: 'generation' as const },
61 { name: 'add-page', domain: 'generation' as const },
62 { name: 'single-page-retry', domain: 'generation' as const },
63 { name: 'page-edit', domain: 'edit' as const },
64 { name: 'page-beautify', domain: 'edit' as const },
65 { name: 'deck-edit', domain: 'edit' as const },
66 { name: 'style-switch', domain: 'style' as const }
67 ]
68 for (const operation of outerWriters) {
69 await expect(
70 coordinator.reserve({
71 jobId: `${operation.name}-job`,
72 domain: operation.domain,
73 owner: { kind: 'session', id: sessionId },
74 claims: { write: [sessionLockKey(sessionId)] },
75 wait: 'fail'
76 })
77 ).resolves.toEqual({ status: 'busy', conflictingJobId: 'generation-run-1' })
78 }
79
80 generation.lease.release()
81 const retry = await reserve(coordinator, {
82 jobId: 'retry-run-2',
83 owner: { kind: 'session', id: sessionId },
84 claims: { write: [sessionLockKey(sessionId)] }
85 })
86 expect(retry.status).toBe('acquired')
87 if (retry.status === 'acquired') retry.lease.release()
88 })
89
90 it('keeps queued jobs cancellable through the same signal and removes them', async () => {
91 const coordinator = new JobCoordinator()
92 const active = await reserve(coordinator, {
93 jobId: 'job-active',
94 owner: { kind: 'session', id: 'session-1' },
95 claims: { write: ['session:session-1'] }
96 })
97 if (active.status !== 'acquired') throw new Error('expected active job')
98
99 const waiting = reserve(coordinator, {
100 jobId: 'job-waiting',
101 owner: { kind: 'style', id: 'style-1' },
102 claims: { write: ['session:session-1'] }
103 })
104 expect(coordinator.getByOwner({ kind: 'style', id: 'style-1' })).toMatchObject({
105 jobId: 'job-waiting',
106 state: 'waiting'
107 })
108
109 expect(coordinator.cancel('job-waiting')).toBe(true)
110 await expect(waiting).rejects.toMatchObject({ name: 'AbortError' })
111 expect(coordinator.getByOwner({ kind: 'style', id: 'style-1' })).toBeNull()
112
113 active.lease.release()
114 })
115
116 it('relays external cancellation, cancels active jobs once, and releases idempotently', async () => {
117 const coordinator = new JobCoordinator()
118 const external = new AbortController()
119 const acquired = await reserve(coordinator, {
120 jobId: 'job-1',
121 owner: { kind: 'session', id: 'session-1' },
122 claims: { write: ['session:session-1'] },
123 signal: external.signal
124 })
125 if (acquired.status !== 'acquired') throw new Error('expected acquired job')
126
127 external.abort()
128 expect(acquired.lease.signal.aborted).toBe(true)
129 expect(coordinator.cancel('job-1')).toBe(false)
130
131 acquired.lease.release()
132 acquired.lease.release()
133
134 const next = await reserve(coordinator, {
135 jobId: 'job-2',
136 owner: { kind: 'session', id: 'session-1' },
137 claims: { write: ['session:session-1'] }
138 })
139 expect(next.status).toBe('acquired')
140 if (next.status === 'acquired') next.lease.release()
141 })
142
143 it('cancels every job owned by the requested owner', async () => {
144 const coordinator = new JobCoordinator()
145 const acquired = await reserve(coordinator, {
146 jobId: 'job-1',
147 owner: { kind: 'session', id: 'session-1' },
148 claims: { write: ['session:session-1'] }
149 })
150 if (acquired.status !== 'acquired') throw new Error('expected acquired job')
151
152 expect(coordinator.cancelOwner({ kind: 'session', id: 'session-1' })).toBe(1)
153 expect(acquired.lease.signal.aborted).toBe(true)
154 acquired.lease.release()
155 })
156 })
157
157 lines TYPESCRIPT