| 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 |