sync: update from private repo (6be06e0)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
5c3d7bb5c4
commit
d95267c4b0
@ -220,4 +220,15 @@ describe('isProviderActive (primaryProvider restriction)', () => {
|
|||||||
expect(isProviderActive(c, 'gitea')).toBe(true); // gitea still usable
|
expect(isProviderActive(c, 'gitea')).toBe(true); // gitea still usable
|
||||||
expect(isProviderActive(c, 'google')).toBe(false); // google not configured
|
expect(isProviderActive(c, 'google')).toBe(false); // google not configured
|
||||||
});
|
});
|
||||||
|
it('does not throw when providers is entirely absent (local-only config)', () => {
|
||||||
|
// Saving "local only" from the Settings UI can persist an auth block with
|
||||||
|
// no providers key at all. The OAuth helpers + strategy registration must
|
||||||
|
// treat that as "no OAuth provider configured", not crash.
|
||||||
|
const c = {
|
||||||
|
sessionSecret: 's', sessionMaxAge: 1, secureCookie: false, adminEmails: [],
|
||||||
|
} as AuthConfig;
|
||||||
|
expect(() => isProviderActive(c, 'google')).not.toThrow();
|
||||||
|
expect(isProviderActive(c, 'google')).toBe(false);
|
||||||
|
expect(isProviderActive(c, 'gitea')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -67,10 +67,10 @@ export function isProviderConfigured(
|
|||||||
* An invalid primary (pointing at an unconfigured provider) is ignored.
|
* An invalid primary (pointing at an unconfigured provider) is ignored.
|
||||||
*/
|
*/
|
||||||
export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea'): boolean {
|
export function isProviderActive(authConfig: AuthConfig, kind: 'google' | 'gitea'): boolean {
|
||||||
if (!isProviderConfigured(authConfig.providers[kind], kind)) return false;
|
if (!isProviderConfigured(authConfig.providers?.[kind], kind)) return false;
|
||||||
const primary = authConfig.primaryProvider;
|
const primary = authConfig.primaryProvider;
|
||||||
if (primary === 'google' && isProviderConfigured(authConfig.providers.google, 'google')) return kind === 'google';
|
if (primary === 'google' && isProviderConfigured(authConfig.providers?.google, 'google')) return kind === 'google';
|
||||||
if (primary === 'gitea' && isProviderConfigured(authConfig.providers.gitea, 'gitea')) return kind === 'gitea';
|
if (primary === 'gitea' && isProviderConfigured(authConfig.providers?.gitea, 'gitea')) return kind === 'gitea';
|
||||||
// primary=local restricts login to local accounts → OAuth providers off.
|
// primary=local restricts login to local accounts → OAuth providers off.
|
||||||
if (primary === 'local' && isLocalEnabled(authConfig)) return false;
|
if (primary === 'local' && isLocalEnabled(authConfig)) return false;
|
||||||
return true;
|
return true;
|
||||||
@ -132,8 +132,8 @@ export function buildChangePasswordHandler(repo: Repository): RequestHandler {
|
|||||||
*/
|
*/
|
||||||
function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string {
|
function renderLoginPage(authConfig: AuthConfig, branding: LoginBranding = DEFAULT_LOGIN_BRANDING): string {
|
||||||
const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
|
const raw = readFileSync(path.join(__authDirname, 'auth-login.html'), 'utf-8');
|
||||||
const googleConfigured = isProviderConfigured(authConfig.providers.google, 'google');
|
const googleConfigured = isProviderConfigured(authConfig.providers?.google, 'google');
|
||||||
const giteaConfigured = isProviderConfigured(authConfig.providers.gitea, 'gitea');
|
const giteaConfigured = isProviderConfigured(authConfig.providers?.gitea, 'gitea');
|
||||||
const localEnabled = isLocalEnabled(authConfig);
|
const localEnabled = isLocalEnabled(authConfig);
|
||||||
const allowSignup = authConfig.local?.allowSignup === true;
|
const allowSignup = authConfig.local?.allowSignup === true;
|
||||||
// Ignore a primaryProvider that points to an unconfigured/disabled provider —
|
// Ignore a primaryProvider that points to an unconfigured/disabled provider —
|
||||||
@ -413,7 +413,7 @@ export async function fetchGiteaOrgsForUser(
|
|||||||
// ── Strategy Registration ─────────────────────────────────────────────────────
|
// ── Strategy Registration ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void {
|
function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||||
const googleConfig = authConfig.providers.google;
|
const googleConfig = authConfig.providers?.google;
|
||||||
if (!isProviderConfigured(googleConfig, 'google')) return;
|
if (!isProviderConfigured(googleConfig, 'google')) return;
|
||||||
if (!isProviderActive(authConfig, 'google')) return;
|
if (!isProviderActive(authConfig, 'google')) return;
|
||||||
|
|
||||||
@ -445,7 +445,7 @@ function registerGoogleStrategy(repo: Repository, authConfig: AuthConfig): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
|
function registerGiteaStrategy(repo: Repository, authConfig: AuthConfig): void {
|
||||||
const giteaConfig = authConfig.providers.gitea;
|
const giteaConfig = authConfig.providers?.gitea;
|
||||||
if (!isProviderConfigured(giteaConfig, 'gitea')) return;
|
if (!isProviderConfigured(giteaConfig, 'gitea')) return;
|
||||||
if (!isProviderActive(authConfig, 'gitea')) return;
|
if (!isProviderActive(authConfig, 'gitea')) return;
|
||||||
|
|
||||||
|
|||||||
@ -2005,6 +2005,49 @@ export class Repository {
|
|||||||
return row ? rowToJob(row) : null;
|
return row ? rowToJob(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only peek at the next job this worker WOULD claim (retry-priority,
|
||||||
|
* then oldest queued), without claiming it. Used by the idle-preferring
|
||||||
|
* claim gate to learn the next job's role before deciding whether to defer
|
||||||
|
* to an idler sibling. Mirrors the claimNext*Job WHERE clauses exactly.
|
||||||
|
*/
|
||||||
|
async peekNextClaimable(workerId: string): Promise<Job | null> {
|
||||||
|
const retry = this.db.prepare(`
|
||||||
|
SELECT j.*
|
||||||
|
FROM jobs j
|
||||||
|
JOIN worker_nodes w ON w.worker_id = ?
|
||||||
|
WHERE j.status = 'retry'
|
||||||
|
AND replace(j.next_retry_at, 'T', ' ') <= datetime('now')
|
||||||
|
AND w.enabled = 1
|
||||||
|
AND w.healthy = 1
|
||||||
|
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM issue_locks il
|
||||||
|
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||||
|
)
|
||||||
|
ORDER BY j.next_retry_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(workerId) as JobRow | undefined;
|
||||||
|
if (retry) return rowToJob(retry);
|
||||||
|
|
||||||
|
const queued = this.db.prepare(`
|
||||||
|
SELECT j.*
|
||||||
|
FROM jobs j
|
||||||
|
JOIN worker_nodes w ON w.worker_id = ?
|
||||||
|
WHERE j.status = 'queued'
|
||||||
|
AND w.enabled = 1
|
||||||
|
AND w.healthy = 1
|
||||||
|
AND instr(w.profile_tags, ',' || j.required_profile || ',') > 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM issue_locks il
|
||||||
|
WHERE il.repo = j.repo AND il.issue_number = j.issue_number
|
||||||
|
)
|
||||||
|
ORDER BY j.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(workerId) as JobRow | undefined;
|
||||||
|
return queued ? rowToJob(queued) : null;
|
||||||
|
}
|
||||||
|
|
||||||
async getJobsByStatus(status: JobStatus): Promise<Job[]> {
|
async getJobsByStatus(status: JobStatus): Promise<Job[]> {
|
||||||
const rows = this.db
|
const rows = this.db
|
||||||
.prepare('SELECT * FROM jobs WHERE status = ? ORDER BY created_at ASC')
|
.prepare('SELECT * FROM jobs WHERE status = ? ORDER BY created_at ASC')
|
||||||
|
|||||||
@ -31,6 +31,7 @@ class MockWorker {
|
|||||||
setWorkerMetrics(): void {}
|
setWorkerMetrics(): void {}
|
||||||
setSkillCatalog(): void {}
|
setSkillCatalog(): void {}
|
||||||
setPushService(): void {}
|
setPushService(): void {}
|
||||||
|
setSiblingsAccessor(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('./worker.js', () => ({ Worker: MockWorker }));
|
vi.mock('./worker.js', () => ({ Worker: MockWorker }));
|
||||||
|
|||||||
@ -240,6 +240,10 @@ export class WorkerManager {
|
|||||||
/** Build a single worker and wire up the optional collaborators. */
|
/** Build a single worker and wire up the optional collaborators. */
|
||||||
private createWorker(def: WorkerDef, config: AppConfig): Worker {
|
private createWorker(def: WorkerDef, config: AppConfig): Worker {
|
||||||
const w = new Worker(def.id, def.endpoint, def.model, this.repo, config);
|
const w = new Worker(def.id, def.endpoint, def.model, this.repo, config);
|
||||||
|
// Live sibling list for idle-preferring claims. The closure reads
|
||||||
|
// this.workers, which is reassigned on rebuild — so it always reflects
|
||||||
|
// the current pool (kept + fresh), excluding retired workers.
|
||||||
|
w.setSiblingsAccessor(() => this.workers);
|
||||||
if (this.mcpTokenManager) w.setMcpTokenManager(this.mcpTokenManager);
|
if (this.mcpTokenManager) w.setMcpTokenManager(this.mcpTokenManager);
|
||||||
if (this.workerMetrics) w.setWorkerMetrics(this.workerMetrics);
|
if (this.workerMetrics) w.setWorkerMetrics(this.workerMetrics);
|
||||||
if (this.skillCatalog) w.setSkillCatalog(this.skillCatalog);
|
if (this.skillCatalog) w.setSkillCatalog(this.skillCatalog);
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { mergeMcpConfig } from './mcp/config.js';
|
|||||||
import { NotesService } from './notes/notes-service.js';
|
import { NotesService } from './notes/notes-service.js';
|
||||||
import { NotesRepository } from './notes/notes-repository.js';
|
import { NotesRepository } from './notes/notes-repository.js';
|
||||||
import { createStickyBackendResolver } from './worker/sticky-backend.js';
|
import { createStickyBackendResolver } from './worker/sticky-backend.js';
|
||||||
|
import { pickIdlerIndex } from './worker/idle-routing.js';
|
||||||
import { jobEventBus } from './bridge/job-events.js';
|
import { jobEventBus } from './bridge/job-events.js';
|
||||||
import { normalizeToolNameForMetric } from './metrics/tool-name-allowlist.js';
|
import { normalizeToolNameForMetric } from './metrics/tool-name-allowlist.js';
|
||||||
|
|
||||||
@ -334,6 +335,12 @@ export class Worker {
|
|||||||
private stopped = false;
|
private stopped = false;
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private healthInterval: ReturnType<typeof setInterval> | null = null;
|
private healthInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
/** Live sibling list, injected by WorkerManager for idle-preferring claims. */
|
||||||
|
private siblingsAccessor: (() => Worker[]) | null = null;
|
||||||
|
/** Last initialize() result — whether we'd actually claim if poked. */
|
||||||
|
private lastAvailable = false;
|
||||||
|
/** Job id we deferred to an idler last round (safety net against stuck yields). */
|
||||||
|
private lastYieldedJobId: string | null = null;
|
||||||
private workerId: string;
|
private workerId: string;
|
||||||
private endpoint: string;
|
private endpoint: string;
|
||||||
private model: string | undefined;
|
private model: string | undefined;
|
||||||
@ -404,6 +411,50 @@ export class Worker {
|
|||||||
return this.inflight;
|
return this.inflight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Free execution slots right now (max_concurrency − inflight). */
|
||||||
|
public get freeSlots(): number {
|
||||||
|
return Math.max(0, this.getMaxConcurrency() - this.inflight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when this worker would actually pick up a job if poked. */
|
||||||
|
public get availableForClaim(): boolean {
|
||||||
|
return this.running && !this.stopped && this.lastAvailable
|
||||||
|
&& isExecutionWorker(this.getWorkerDef());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this worker serves jobs of the given role. */
|
||||||
|
public canClaimRole(role: string): boolean {
|
||||||
|
return this.supportsRole(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nudge this worker to poll immediately (hands a yielded job to an idler). */
|
||||||
|
public pokePoll(): void {
|
||||||
|
void this.processNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WorkerManager injects the live sibling list so claims prefer idler workers. */
|
||||||
|
public setSiblingsAccessor(fn: () => Worker[]): void {
|
||||||
|
this.siblingsAccessor = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the idlest sibling that has strictly more free slots than us and
|
||||||
|
* serves `role`. Returns null when we are (tied for) the most free, in which
|
||||||
|
* case we should claim the job ourselves.
|
||||||
|
*/
|
||||||
|
private findIdlerCompetitor(role: string): Worker | null {
|
||||||
|
const others = (this.siblingsAccessor?.() ?? []).filter((s) => s !== this);
|
||||||
|
const idx = pickIdlerIndex(
|
||||||
|
this.freeSlots,
|
||||||
|
others.map((s) => ({
|
||||||
|
freeSlots: s.freeSlots,
|
||||||
|
availableForClaim: s.availableForClaim,
|
||||||
|
servesRole: s.canClaimRole(role),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return idx >= 0 ? others[idx]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire a V2 push for a job status transition. Fire-and-forget — never
|
* Fire a V2 push for a job status transition. Fire-and-forget — never
|
||||||
* throws and never awaits the underlying queue. Skips silently when
|
* throws and never awaits the underlying queue. Skips silently when
|
||||||
@ -639,10 +690,33 @@ export class Worker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const available = await this.initialize();
|
const available = await this.initialize();
|
||||||
|
this.lastAvailable = available;
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
|
||||||
const max = this.getMaxConcurrency();
|
const max = this.getMaxConcurrency();
|
||||||
while (this.inflight < max && this.running && !this.stopped) {
|
while (this.inflight < max && this.running && !this.stopped) {
|
||||||
|
// Idle-preferring gate (most-free-wins): if a strictly-idler sibling
|
||||||
|
// serves the next job's role, hand it off (nudge that worker) instead
|
||||||
|
// of piling on. Safety net: if we already deferred this exact job last
|
||||||
|
// round and it is still here, the idler didn't take it (unhealthy /
|
||||||
|
// raced) — claim it ourselves so a job never gets stuck.
|
||||||
|
//
|
||||||
|
// Only consult the gate when there are sibling workers to defer to AND
|
||||||
|
// the repo supports peeking. Single-worker setups and unit tests skip
|
||||||
|
// it entirely — no extra query, no added latency, original claim timing.
|
||||||
|
const siblings = this.siblingsAccessor?.();
|
||||||
|
if (siblings && siblings.length > 1 && this.repo.peekNextClaimable) {
|
||||||
|
const peek = await this.repo.peekNextClaimable(this.workerId);
|
||||||
|
if (peek && peek.id !== this.lastYieldedJobId) {
|
||||||
|
const idler = this.findIdlerCompetitor(peek.requiredRole);
|
||||||
|
if (idler) {
|
||||||
|
this.lastYieldedJobId = peek.id;
|
||||||
|
idler.pokePoll();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.lastYieldedJobId = null;
|
||||||
|
}
|
||||||
// リトライジョブを優先
|
// リトライジョブを優先
|
||||||
const job = await this.repo.claimNextRetryJob(this.workerId)
|
const job = await this.repo.claimNextRetryJob(this.workerId)
|
||||||
?? await this.repo.claimNextJob(this.workerId);
|
?? await this.repo.claimNextJob(this.workerId);
|
||||||
|
|||||||
44
src/worker/idle-routing.test.ts
Normal file
44
src/worker/idle-routing.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { pickIdlerIndex, type ClaimCandidate } from './idle-routing.js';
|
||||||
|
|
||||||
|
const c = (freeSlots: number, opts: Partial<ClaimCandidate> = {}): ClaimCandidate => ({
|
||||||
|
freeSlots,
|
||||||
|
availableForClaim: opts.availableForClaim ?? true,
|
||||||
|
servesRole: opts.servesRole ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickIdlerIndex (most-free-wins)', () => {
|
||||||
|
it('returns -1 when there are no siblings', () => {
|
||||||
|
expect(pickIdlerIndex(0, [])).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('yields to a strictly-idler sibling (0/n beats a loaded worker)', () => {
|
||||||
|
// self has 0 free (fully loaded); sibling has 4 free.
|
||||||
|
expect(pickIdlerIndex(0, [c(4)])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not yield on a tie (claims itself)', () => {
|
||||||
|
expect(pickIdlerIndex(2, [c(2), c(2)])).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not yield when the caller is the most free', () => {
|
||||||
|
expect(pickIdlerIndex(4, [c(1), c(3)])).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the idlest among several stricter competitors', () => {
|
||||||
|
expect(pickIdlerIndex(1, [c(2), c(5), c(3)])).toBe(1); // 5 free wins
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores siblings that do not serve the role', () => {
|
||||||
|
expect(pickIdlerIndex(0, [c(8, { servesRole: false })])).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unavailable (unhealthy/stopped) siblings', () => {
|
||||||
|
expect(pickIdlerIndex(0, [c(8, { availableForClaim: false })])).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a non-serving idler but still yields to a serving one', () => {
|
||||||
|
// idx0: very idle but wrong role; idx1: idle and serves → pick idx1.
|
||||||
|
expect(pickIdlerIndex(0, [c(9, { servesRole: false }), c(3)])).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
src/worker/idle-routing.ts
Normal file
39
src/worker/idle-routing.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Idle-preferring worker selection helper.
|
||||||
|
*
|
||||||
|
* Workers run as in-process instances that each poll the DB for jobs. Without
|
||||||
|
* coordination, whichever worker's poll timer fires first claims the next job —
|
||||||
|
* so a busy worker can grab work while an idle sibling sits at 0/n. This helper
|
||||||
|
* implements the "most-free-wins" rule: before claiming, a worker checks its
|
||||||
|
* siblings and yields to one that has STRICTLY more free slots (scoped to
|
||||||
|
* siblings that are available and actually serve the job's role). A 0/n idle
|
||||||
|
* worker therefore always beats a partially-loaded one.
|
||||||
|
*
|
||||||
|
* Returns the index of the idlest qualifying competitor, or -1 when the caller
|
||||||
|
* is already (tied for) the most free and should claim the job itself.
|
||||||
|
*/
|
||||||
|
export interface ClaimCandidate {
|
||||||
|
/** max_concurrency − inflight for this candidate. */
|
||||||
|
freeSlots: number;
|
||||||
|
/** Running, healthy, enabled — would actually claim if poked. */
|
||||||
|
availableForClaim: boolean;
|
||||||
|
/** Serves the role of the job about to be claimed. */
|
||||||
|
servesRole: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickIdlerIndex(
|
||||||
|
selfFreeSlots: number,
|
||||||
|
candidates: readonly ClaimCandidate[],
|
||||||
|
): number {
|
||||||
|
let bestIdx = -1;
|
||||||
|
let bestFree = selfFreeSlots; // a competitor must STRICTLY exceed this to win
|
||||||
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
|
const c = candidates[i];
|
||||||
|
if (!c.availableForClaim || !c.servesRole) continue;
|
||||||
|
if (c.freeSlots > bestFree) {
|
||||||
|
bestIdx = i;
|
||||||
|
bestFree = c.freeSlots;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestIdx;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user