From 740bcff0cc88e78dd1e09538f19eca82dd498176 Mon Sep 17 00:00:00 2001 From: unohee Date: Sat, 27 Jun 2026 11:05:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(scheduler):=20=EA=B0=99=EC=9D=80=20repo=20?= =?UTF-8?q?=EB=B9=84=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=8A=B8=EB=A6=AC=20=EB=B3=91=EB=A0=AC=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20(INT-1975)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allowSameProjectConcurrent를 config로 노출(기본 ON)하고, worktreeMode가 꺼져 있으면 강제 무시하는 가드를 TaskScheduler 생성자에 추가. 워크트리 격리 없이 공유 워킹트리를 동시 변경해 손상시키는 경우를 차단한다. - core/types.ts, runnerTypes.ts: allowSameProjectConcurrent 필드 - core/config.ts: 스키마(default true) + 빌더 carry-through - taskScheduler.ts: worktreeMode 없으면 force-disable + warn 가드 - autonomousRunner.ts: 하드코딩 false → config.allowSameProjectConcurrent ?? true - 단위 테스트 4건(직렬화 기본·병렬 허용·가드·getNextExecutable) --- src/automation/autonomousRunner.ts | 4 +- src/automation/runnerTypes.ts | 2 + src/core/config.ts | 3 ++ src/core/types.ts | 7 +++ .../taskScheduler.concurrency.test.ts | 49 +++++++++++++++++++ src/orchestration/taskScheduler.ts | 15 +++++- 6 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/orchestration/taskScheduler.concurrency.test.ts diff --git a/src/automation/autonomousRunner.ts b/src/automation/autonomousRunner.ts index bfff8b5..0122c0e 100644 --- a/src/automation/autonomousRunner.ts +++ b/src/automation/autonomousRunner.ts @@ -168,9 +168,11 @@ export class AutonomousRunner { }); // Initialize TaskScheduler + // Same-repo parallelism is opt-in via config (default true) but the scheduler + // force-disables it unless worktreeMode is on — see TaskScheduler guard. (INT-1975) this.scheduler = initScheduler({ maxConcurrent: config.maxConcurrentTasks ?? 1, - allowSameProjectConcurrent: false, + allowSameProjectConcurrent: config.allowSameProjectConcurrent ?? true, worktreeMode: config.worktreeMode ?? false, }); diff --git a/src/automation/runnerTypes.ts b/src/automation/runnerTypes.ts index dd3ed64..2ce4d8b 100644 --- a/src/automation/runnerTypes.ts +++ b/src/automation/runnerTypes.ts @@ -34,6 +34,8 @@ export interface AutonomousConfig { plannerTimeoutMs?: number; decomposition?: import('../core/types.js').DecompositionConfig; worktreeMode?: boolean; + /** Allow concurrent tasks on the same repo (requires worktreeMode). Default true. (INT-1975) */ + allowSameProjectConcurrent?: boolean; guards?: Partial; /** Max objective self-repair attempts (lint/bs/test) before giving up (default: 3) */ maxReflections?: number; diff --git a/src/core/config.ts b/src/core/config.ts index 83f2318..81d6933 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -248,6 +248,8 @@ const AutonomousConfigSchema = z.object({ decomposition: DecompositionConfigSchema, /** Git worktree mode: each task runs in isolated worktree */ worktreeMode: z.boolean().default(false), + /** Allow concurrent tasks on the same repo (requires worktreeMode). (INT-1975) */ + allowSameProjectConcurrent: z.boolean().default(true), /** Dynamic job profiles for model selection */ jobProfiles: z.array(JobProfileSchema).optional(), /** Pipeline quality guards (bad-edit lint gate, BS detector, etc.) */ @@ -533,6 +535,7 @@ function transformConfig(raw: RawConfig): SwarmConfig { plannerTimeoutMs: raw.autonomous.decomposition.plannerTimeoutMs, } : undefined, worktreeMode: raw.autonomous.worktreeMode, + allowSameProjectConcurrent: raw.autonomous.allowSameProjectConcurrent, guards: raw.autonomous.guards, maxReflections: raw.autonomous.maxReflections, dailyTaskCap: raw.autonomous.dailyTaskCap, diff --git a/src/core/types.ts b/src/core/types.ts index 28f42d2..393d519 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -460,6 +460,13 @@ export type AutonomousStartupConfig = { decomposition?: DecompositionConfig; /** Git worktree mode: work in independent worktree per issue and auto-create PR */ worktreeMode?: boolean; + /** + * Allow concurrent tasks on the SAME repo. Requires worktreeMode (per-task + * filesystem isolation); ignored otherwise to avoid corrupting a shared tree. + * Non-conflicting issues are still gated by KG file-conflict detection and the + * blockedBy dependency graph. Default: true. (INT-1975) + */ + allowSameProjectConcurrent?: boolean; /** Pipeline guards configuration */ guards?: Partial; /** diff --git a/src/orchestration/taskScheduler.concurrency.test.ts b/src/orchestration/taskScheduler.concurrency.test.ts new file mode 100644 index 0000000..faed326 --- /dev/null +++ b/src/orchestration/taskScheduler.concurrency.test.ts @@ -0,0 +1,49 @@ +// Purpose: same-repo concurrency flag + worktree-isolation guard (INT-1975). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TaskScheduler } from './taskScheduler.js'; +import type { TaskItem } from './decisionEngine.js'; +import type { PipelineResult } from '../agents/pairPipeline.js'; + +const task = (id: string): TaskItem => ({ id, title: id, priority: 3 } as TaskItem); + +// Executor that never resolves — keeps the task "running" so isProjectBusy is testable. +function pendingExecutor() { + return () => new Promise(() => {}); +} + +describe('TaskScheduler same-project concurrency (INT-1975)', () => { + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warn.mockRestore(); }); + + it('serializes same-repo tasks by default (no flag)', () => { + const s = new TaskScheduler({ maxConcurrent: 4, worktreeMode: true }); + s.startTask(task('a'), '/repo', pendingExecutor()); + expect(s.isProjectBusy('/repo')).toBe(true); + }); + + it('allows same-repo parallelism when flag + worktreeMode are both on', () => { + const s = new TaskScheduler({ maxConcurrent: 4, worktreeMode: true, allowSameProjectConcurrent: true }); + s.startTask(task('a'), '/repo', pendingExecutor()); + expect(s.isProjectBusy('/repo')).toBe(false); + expect(s.getBusyProjects()).toEqual([]); + }); + + it('force-disables the flag when worktreeMode is off, and warns', () => { + const s = new TaskScheduler({ maxConcurrent: 4, worktreeMode: false, allowSameProjectConcurrent: true }); + s.startTask(task('a'), '/repo', pendingExecutor()); + // Guard ignored the flag → project is still busy (serialized, safe). + expect(s.isProjectBusy('/repo')).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('requires worktreeMode')); + }); + + it('getNextExecutable hands out two same-repo tasks when concurrency is allowed', () => { + const s = new TaskScheduler({ maxConcurrent: 4, worktreeMode: true, allowSameProjectConcurrent: true }); + s.enqueue(task('a'), '/repo'); + s.enqueue(task('b'), '/repo'); + const first = s.getNextExecutable(); + s.startTask(first!.task, first!.projectPath, pendingExecutor()); + // Without the flag this would return null (project busy); with it, 'b' is dispatchable. + expect(s.getNextExecutable()?.task.id).toBe('b'); + }); +}); diff --git a/src/orchestration/taskScheduler.ts b/src/orchestration/taskScheduler.ts index da8a4cd..d261790 100644 --- a/src/orchestration/taskScheduler.ts +++ b/src/orchestration/taskScheduler.ts @@ -56,10 +56,23 @@ export class TaskScheduler extends EventEmitter { constructor(config: SchedulerConfig) { super(); - this.config = { + const merged: SchedulerConfig = { allowSameProjectConcurrent: false, ...config, }; + // Same-project parallelism REQUIRES per-task worktree isolation. Without it, + // two concurrent tasks would mutate one shared working tree and corrupt each + // other. Guard at the one place that holds both flags: force-disable + warn. + // (INT-1975) + if (merged.allowSameProjectConcurrent && !merged.worktreeMode) { + console.warn( + '[Scheduler] allowSameProjectConcurrent ignored: requires worktreeMode ' + + '(a shared working tree would be corrupted by concurrent tasks). ' + + 'Set worktreeMode:true to enable same-project parallelism.' + ); + merged.allowSameProjectConcurrent = false; + } + this.config = merged; } // ============================================