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; } // ============================================