add skills
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"eval_id": 1,
|
||||
"eval_name": "happy-path-feature-config-option",
|
||||
"prompt": "I need to add a `max_background_agents` config option to oh-my-opencode that limits how many background agents can run simultaneously. It should be in the plugin config schema with a default of 5. Add validation and make sure the background manager respects it. Create a PR for this.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "worktree-isolation",
|
||||
"text": "Plan uses git worktree in a sibling directory (not main working directory)",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "branch-from-dev",
|
||||
"text": "Branch is created from origin/dev (not master/main)",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "atomic-commits",
|
||||
"text": "Plan specifies multiple atomic commits for multi-file changes",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "local-validation",
|
||||
"text": "Runs bun run typecheck, bun test, and bun run build before pushing",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "pr-targets-dev",
|
||||
"text": "PR is created targeting dev branch (not master)",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "three-gates",
|
||||
"text": "Verification loop includes all 3 gates: CI, review-work, and Cubic",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "gate-ordering",
|
||||
"text": "Gates are checked in order: CI first, then review-work, then Cubic",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "cubic-check-method",
|
||||
"text": "Cubic check uses gh api to check cubic-dev-ai[bot] reviews for 'No issues found'",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "worktree-cleanup",
|
||||
"text": "Plan includes worktree cleanup after merge",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "real-file-references",
|
||||
"text": "Code changes reference actual files in the codebase (config schema, background manager)",
|
||||
"type": "manual"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"run_id": "eval-1-with_skill",
|
||||
"expectations": [
|
||||
{"text": "Plan uses git worktree in a sibling directory", "passed": true, "evidence": "Uses ../omo-wt/feat-max-background-agents"},
|
||||
{"text": "Branch is created from origin/dev", "passed": true, "evidence": "git checkout dev && git pull origin dev, then branch"},
|
||||
{"text": "Plan specifies multiple atomic commits for multi-file changes", "passed": true, "evidence": "2 commits: schema+tests, then concurrency+manager"},
|
||||
{"text": "Runs bun run typecheck, bun test, and bun run build before pushing", "passed": true, "evidence": "Explicit pre-push section with all 3 commands"},
|
||||
{"text": "PR is created targeting dev branch", "passed": true, "evidence": "--base dev in gh pr create"},
|
||||
{"text": "Verification loop includes all 3 gates: CI, review-work, and Cubic", "passed": true, "evidence": "Gate A (CI), Gate B (review-work 5 agents), Gate C (Cubic)"},
|
||||
{"text": "Gates are checked in order: CI first, then review-work, then Cubic", "passed": true, "evidence": "Explicit ordering in verify loop pseudocode"},
|
||||
{"text": "Cubic check uses gh api to check cubic-dev-ai[bot] reviews", "passed": true, "evidence": "Mentions cubic-dev-ai[bot] and 'No issues found' signal"},
|
||||
{"text": "Plan includes worktree cleanup after merge", "passed": true, "evidence": "Phase 4: git worktree remove ../omo-wt/feat-max-background-agents"},
|
||||
{"text": "Code changes reference actual files in the codebase", "passed": true, "evidence": "References src/config/schema/background-task.ts, src/features/background-agent/concurrency.ts, manager.ts"}
|
||||
]
|
||||
}
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
# Code Changes: `max_background_agents` Config Option
|
||||
|
||||
## 1. `src/config/schema/background-task.ts` — Add schema field
|
||||
|
||||
```typescript
|
||||
import { z } from "zod"
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
maxDepth: z.number().int().min(1).optional(),
|
||||
maxDescendants: z.number().int().min(1).optional(),
|
||||
/** Maximum number of background agents that can run simultaneously across all models/providers (default: 5, minimum: 1) */
|
||||
maxBackgroundAgents: z.number().int().min(1).optional(),
|
||||
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
|
||||
staleTimeoutMs: z.number().min(60000).optional(),
|
||||
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
|
||||
messageStalenessTimeoutMs: z.number().min(60000).optional(),
|
||||
syncPollTimeoutMs: z.number().min(60000).optional(),
|
||||
})
|
||||
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
```
|
||||
|
||||
**Rationale:** Follows exact same pattern as `maxDepth` and `maxDescendants` — `z.number().int().min(1).optional()`. The field is optional; runtime default of 5 is applied in `ConcurrencyManager`. No barrel export changes needed since `src/config/schema.ts` already does `export * from "./schema/background-task"` and the type is inferred.
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/config/schema/background-task.test.ts` — Add validation tests
|
||||
|
||||
Append after the existing `syncPollTimeoutMs` describe block (before the closing `})`):
|
||||
|
||||
```typescript
|
||||
describe("maxBackgroundAgents", () => {
|
||||
describe("#given valid maxBackgroundAgents (10)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 10 })
|
||||
|
||||
expect(result.maxBackgroundAgents).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxBackgroundAgents of 1 (minimum)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 1 })
|
||||
|
||||
expect(result.maxBackgroundAgents).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxBackgroundAgents below minimum (0)", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 0 })
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxBackgroundAgents not provided", () => {
|
||||
test("#when parsed #then field is undefined", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({})
|
||||
|
||||
expect(result.maxBackgroundAgents).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('#given maxBackgroundAgents is non-integer (2.5)', () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 2.5 })
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Rationale:** Follows exact test pattern from `maxDepth`, `maxDescendants`, and `syncPollTimeoutMs` tests. Uses `#given`/`#when`/`#then` nested describe style. Tests valid, minimum boundary, below minimum, not provided, and non-integer cases.
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/features/background-agent/concurrency.ts` — Add global agent limit
|
||||
|
||||
```typescript
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
const DEFAULT_MAX_BACKGROUND_AGENTS = 5
|
||||
|
||||
/**
|
||||
* Queue entry with settled-flag pattern to prevent double-resolution.
|
||||
*
|
||||
* The settled flag ensures that cancelWaiters() doesn't reject
|
||||
* an entry that was already resolved by release().
|
||||
*/
|
||||
interface QueueEntry {
|
||||
resolve: () => void
|
||||
rawReject: (error: Error) => void
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
export class ConcurrencyManager {
|
||||
private config?: BackgroundTaskConfig
|
||||
private counts: Map<string, number> = new Map()
|
||||
private queues: Map<string, QueueEntry[]> = new Map()
|
||||
private globalRunningCount = 0
|
||||
|
||||
constructor(config?: BackgroundTaskConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
getMaxBackgroundAgents(): number {
|
||||
return this.config?.maxBackgroundAgents ?? DEFAULT_MAX_BACKGROUND_AGENTS
|
||||
}
|
||||
|
||||
getGlobalRunningCount(): number {
|
||||
return this.globalRunningCount
|
||||
}
|
||||
|
||||
canSpawnGlobally(): boolean {
|
||||
return this.globalRunningCount < this.getMaxBackgroundAgents()
|
||||
}
|
||||
|
||||
acquireGlobal(): void {
|
||||
this.globalRunningCount++
|
||||
}
|
||||
|
||||
releaseGlobal(): void {
|
||||
if (this.globalRunningCount > 0) {
|
||||
this.globalRunningCount--
|
||||
}
|
||||
}
|
||||
|
||||
getConcurrencyLimit(model: string): number {
|
||||
// ... existing implementation unchanged ...
|
||||
}
|
||||
|
||||
async acquire(model: string): Promise<void> {
|
||||
// ... existing implementation unchanged ...
|
||||
}
|
||||
|
||||
release(model: string): void {
|
||||
// ... existing implementation unchanged ...
|
||||
}
|
||||
|
||||
cancelWaiters(model: string): void {
|
||||
// ... existing implementation unchanged ...
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const [model] of this.queues) {
|
||||
this.cancelWaiters(model)
|
||||
}
|
||||
this.counts.clear()
|
||||
this.queues.clear()
|
||||
this.globalRunningCount = 0
|
||||
}
|
||||
|
||||
getCount(model: string): number {
|
||||
return this.counts.get(model) ?? 0
|
||||
}
|
||||
|
||||
getQueueLength(model: string): number {
|
||||
return this.queues.get(model)?.length ?? 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key changes:**
|
||||
- Add `DEFAULT_MAX_BACKGROUND_AGENTS = 5` constant
|
||||
- Add `globalRunningCount` private field
|
||||
- Add `getMaxBackgroundAgents()`, `getGlobalRunningCount()`, `canSpawnGlobally()`, `acquireGlobal()`, `releaseGlobal()` methods
|
||||
- `clear()` resets `globalRunningCount` to 0
|
||||
- All existing per-model methods remain unchanged
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/features/background-agent/concurrency.test.ts` — Add global limit tests
|
||||
|
||||
Append new describe block:
|
||||
|
||||
```typescript
|
||||
describe("ConcurrencyManager global background agent limit", () => {
|
||||
test("should default max background agents to 5 when no config", () => {
|
||||
// given
|
||||
const manager = new ConcurrencyManager()
|
||||
|
||||
// when
|
||||
const max = manager.getMaxBackgroundAgents()
|
||||
|
||||
// then
|
||||
expect(max).toBe(5)
|
||||
})
|
||||
|
||||
test("should use configured maxBackgroundAgents", () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { maxBackgroundAgents: 10 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// when
|
||||
const max = manager.getMaxBackgroundAgents()
|
||||
|
||||
// then
|
||||
expect(max).toBe(10)
|
||||
})
|
||||
|
||||
test("should allow spawning when under global limit", () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { maxBackgroundAgents: 2 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// when
|
||||
manager.acquireGlobal()
|
||||
|
||||
// then
|
||||
expect(manager.canSpawnGlobally()).toBe(true)
|
||||
expect(manager.getGlobalRunningCount()).toBe(1)
|
||||
})
|
||||
|
||||
test("should block spawning when at global limit", () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { maxBackgroundAgents: 2 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// when
|
||||
manager.acquireGlobal()
|
||||
manager.acquireGlobal()
|
||||
|
||||
// then
|
||||
expect(manager.canSpawnGlobally()).toBe(false)
|
||||
expect(manager.getGlobalRunningCount()).toBe(2)
|
||||
})
|
||||
|
||||
test("should allow spawning again after release", () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { maxBackgroundAgents: 1 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
manager.acquireGlobal()
|
||||
|
||||
// when
|
||||
manager.releaseGlobal()
|
||||
|
||||
// then
|
||||
expect(manager.canSpawnGlobally()).toBe(true)
|
||||
expect(manager.getGlobalRunningCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should not go below zero on extra release", () => {
|
||||
// given
|
||||
const manager = new ConcurrencyManager()
|
||||
|
||||
// when
|
||||
manager.releaseGlobal()
|
||||
|
||||
// then
|
||||
expect(manager.getGlobalRunningCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should reset global count on clear", () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { maxBackgroundAgents: 5 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
manager.acquireGlobal()
|
||||
manager.acquireGlobal()
|
||||
manager.acquireGlobal()
|
||||
|
||||
// when
|
||||
manager.clear()
|
||||
|
||||
// then
|
||||
expect(manager.getGlobalRunningCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `src/features/background-agent/manager.ts` — Enforce global limit
|
||||
|
||||
### In `launch()` method — add check before task creation (after `reserveSubagentSpawn`):
|
||||
|
||||
```typescript
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
// ... existing logging ...
|
||||
|
||||
if (!input.agent || input.agent.trim() === "") {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
// Check global background agent limit before spawn guard
|
||||
if (!this.concurrencyManager.canSpawnGlobally()) {
|
||||
const max = this.concurrencyManager.getMaxBackgroundAgents()
|
||||
const current = this.concurrencyManager.getGlobalRunningCount()
|
||||
throw new Error(
|
||||
`Background agent spawn blocked: ${current} agents running, max is ${max}. Wait for existing tasks to complete or increase background_task.maxBackgroundAgents.`
|
||||
)
|
||||
}
|
||||
|
||||
const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionID)
|
||||
|
||||
try {
|
||||
// ... existing code ...
|
||||
|
||||
// After task creation, before queueing:
|
||||
this.concurrencyManager.acquireGlobal()
|
||||
|
||||
// ... rest of existing code ...
|
||||
} catch (error) {
|
||||
spawnReservation.rollback()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### In `trackTask()` method — add global check:
|
||||
|
||||
```typescript
|
||||
async trackTask(input: { ... }): Promise<BackgroundTask> {
|
||||
const existingTask = this.tasks.get(input.taskId)
|
||||
if (existingTask) {
|
||||
// ... existing re-registration logic unchanged ...
|
||||
return existingTask
|
||||
}
|
||||
|
||||
// Check global limit for new external tasks
|
||||
if (!this.concurrencyManager.canSpawnGlobally()) {
|
||||
const max = this.concurrencyManager.getMaxBackgroundAgents()
|
||||
const current = this.concurrencyManager.getGlobalRunningCount()
|
||||
throw new Error(
|
||||
`Background agent spawn blocked: ${current} agents running, max is ${max}. Wait for existing tasks to complete or increase background_task.maxBackgroundAgents.`
|
||||
)
|
||||
}
|
||||
|
||||
// ... existing task creation ...
|
||||
this.concurrencyManager.acquireGlobal()
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
### In `tryCompleteTask()` — release global slot:
|
||||
|
||||
```typescript
|
||||
private async tryCompleteTask(task: BackgroundTask, source: string): Promise<boolean> {
|
||||
if (task.status !== "running") {
|
||||
// ... existing guard ...
|
||||
return false
|
||||
}
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
// ... existing history record ...
|
||||
|
||||
removeTaskToastTracking(task.id)
|
||||
|
||||
// Release per-model concurrency
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
// Release global slot
|
||||
this.concurrencyManager.releaseGlobal()
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
### In `cancelTask()` — release global slot:
|
||||
|
||||
```typescript
|
||||
async cancelTask(taskId: string, options?: { ... }): Promise<boolean> {
|
||||
// ... existing code up to concurrency release ...
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
// Release global slot (only for running tasks, pending never acquired)
|
||||
if (task.status !== "pending") {
|
||||
this.concurrencyManager.releaseGlobal()
|
||||
}
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
### In `handleEvent()` session.error handler — release global slot:
|
||||
|
||||
```typescript
|
||||
if (event.type === "session.error") {
|
||||
// ... existing error handling ...
|
||||
|
||||
task.status = "error"
|
||||
// ...
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
// Release global slot
|
||||
this.concurrencyManager.releaseGlobal()
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
### In prompt error handler inside `startTask()` — release global slot:
|
||||
|
||||
```typescript
|
||||
promptWithModelSuggestionRetry(this.client, { ... }).catch((error) => {
|
||||
// ... existing error handling ...
|
||||
if (existingTask) {
|
||||
existingTask.status = "interrupt"
|
||||
// ...
|
||||
if (existingTask.concurrencyKey) {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
// Release global slot
|
||||
this.concurrencyManager.releaseGlobal()
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Lines Added | Lines Modified |
|
||||
|------|-------------|----------------|
|
||||
| `src/config/schema/background-task.ts` | 2 | 0 |
|
||||
| `src/config/schema/background-task.test.ts` | ~50 | 0 |
|
||||
| `src/features/background-agent/concurrency.ts` | ~25 | 1 (`clear()`) |
|
||||
| `src/features/background-agent/concurrency.test.ts` | ~70 | 0 |
|
||||
| `src/features/background-agent/manager.ts` | ~20 | 0 |
|
||||
|
||||
Total: ~167 lines added, 1 line modified across 5 files.
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# Execution Plan: `max_background_agents` Config Option
|
||||
|
||||
## Phase 0: Setup — Branch + Worktree
|
||||
|
||||
1. **Create branch** from `dev`:
|
||||
```bash
|
||||
git checkout dev && git pull origin dev
|
||||
git checkout -b feat/max-background-agents
|
||||
```
|
||||
|
||||
2. **Create worktree** in sibling directory:
|
||||
```bash
|
||||
mkdir -p ../omo-wt
|
||||
git worktree add ../omo-wt/feat-max-background-agents feat/max-background-agents
|
||||
```
|
||||
|
||||
3. **All subsequent work** happens in `../omo-wt/feat-max-background-agents/`, never in the main worktree.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Implement — Atomic Commits
|
||||
|
||||
### Commit 1: Add `max_background_agents` to config schema
|
||||
|
||||
**Files changed:**
|
||||
- `src/config/schema/background-task.ts` — Add `maxBackgroundAgents` field to `BackgroundTaskConfigSchema`
|
||||
- `src/config/schema/background-task.test.ts` — Add validation tests for the new field
|
||||
|
||||
**What:**
|
||||
- Add `maxBackgroundAgents: z.number().int().min(1).optional()` to `BackgroundTaskConfigSchema`
|
||||
- Default value handled at runtime (5), not in schema (all schema fields are optional per convention)
|
||||
- Add given/when/then tests: valid value, below minimum, not provided, non-number
|
||||
|
||||
### Commit 2: Enforce limit in BackgroundManager + ConcurrencyManager
|
||||
|
||||
**Files changed:**
|
||||
- `src/features/background-agent/concurrency.ts` — Add global agent count tracking + `getGlobalRunningCount()` + `canSpawnGlobally()`
|
||||
- `src/features/background-agent/concurrency.test.ts` — Tests for global limit enforcement
|
||||
- `src/features/background-agent/manager.ts` — Check global limit before `launch()` and `trackTask()`
|
||||
|
||||
**What:**
|
||||
- `ConcurrencyManager` already manages per-model concurrency. Add a separate global counter:
|
||||
- `private globalRunningCount: number = 0`
|
||||
- `private maxBackgroundAgents: number` (from config, default 5)
|
||||
- `acquireGlobal()` / `releaseGlobal()` methods
|
||||
- `getGlobalRunningCount()` for observability
|
||||
- `BackgroundManager.launch()` checks `concurrencyManager.canSpawnGlobally()` before creating task
|
||||
- `BackgroundManager.trackTask()` also checks global limit
|
||||
- On task completion/cancellation/error, call `releaseGlobal()`
|
||||
- Throw descriptive error when limit hit: `"Background agent spawn blocked: ${current} agents running, max is ${max}. Wait for existing tasks to complete or increase background_task.maxBackgroundAgents."`
|
||||
|
||||
### Local Validation
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun test src/config/schema/background-task.test.ts
|
||||
bun test src/features/background-agent/concurrency.test.ts
|
||||
bun run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: PR Creation
|
||||
|
||||
1. **Push branch:**
|
||||
```bash
|
||||
git push -u origin feat/max-background-agents
|
||||
```
|
||||
|
||||
2. **Create PR** targeting `dev`:
|
||||
```bash
|
||||
gh pr create \
|
||||
--base dev \
|
||||
--title "feat: add max_background_agents config to limit concurrent background agents" \
|
||||
--body-file /tmp/pull-request-max-background-agents-$(date +%s).md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Verify Loop
|
||||
|
||||
### Gate A: CI
|
||||
- Wait for `ci.yml` workflow to complete
|
||||
- Check: `gh pr checks <PR_NUMBER> --watch`
|
||||
- If fails: read logs, fix, push, re-check
|
||||
|
||||
### Gate B: review-work (5 agents)
|
||||
- Run `/review-work` skill which launches 5 parallel background sub-agents:
|
||||
1. Oracle — goal/constraint verification
|
||||
2. Oracle — code quality
|
||||
3. Oracle — security
|
||||
4. Hephaestus — hands-on QA execution
|
||||
5. Hephaestus — context mining from GitHub/git
|
||||
- All 5 must pass. If any fails, fix and re-push.
|
||||
|
||||
### Gate C: Cubic (cubic-dev-ai[bot])
|
||||
- Wait for Cubic bot review on PR
|
||||
- Must say "No issues found"
|
||||
- If issues found: address feedback, push, re-check
|
||||
|
||||
### Loop
|
||||
```
|
||||
while (!allGatesPass) {
|
||||
if (CI fails) → fix → push → continue
|
||||
if (review-work fails) → fix → push → continue
|
||||
if (Cubic has issues) → fix → push → continue
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Merge + Cleanup
|
||||
|
||||
1. **Squash merge:**
|
||||
```bash
|
||||
gh pr merge <PR_NUMBER> --squash --delete-branch
|
||||
```
|
||||
|
||||
2. **Remove worktree:**
|
||||
```bash
|
||||
git worktree remove ../omo-wt/feat-max-background-agents
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Impact Summary
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `src/config/schema/background-task.ts` | Modified — add schema field |
|
||||
| `src/config/schema/background-task.test.ts` | Modified — add validation tests |
|
||||
| `src/features/background-agent/concurrency.ts` | Modified — add global limit tracking |
|
||||
| `src/features/background-agent/concurrency.test.ts` | Modified — add global limit tests |
|
||||
| `src/features/background-agent/manager.ts` | Modified — enforce global limit in launch/trackTask |
|
||||
|
||||
5 files changed across 2 atomic commits. No new files created (follows existing patterns).
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# PR Description
|
||||
|
||||
**Title:** `feat: add max_background_agents config to limit concurrent background agents`
|
||||
|
||||
**Base:** `dev`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- Add `maxBackgroundAgents` field to `BackgroundTaskConfigSchema` (default: 5, min: 1) to cap total simultaneous background agents across all models/providers
|
||||
- Enforce the global limit in `BackgroundManager.launch()` and `trackTask()` with descriptive error messages when the limit is hit
|
||||
- Release global slots on task completion, cancellation, error, and interrupt to prevent slot leaks
|
||||
|
||||
## Motivation
|
||||
|
||||
The existing concurrency system in `ConcurrencyManager` limits agents **per model/provider** (e.g., 5 concurrent `anthropic/claude-opus-4-6` tasks). However, there is no **global** cap across all models. A user running tasks across multiple providers could spawn an unbounded number of background agents, exhausting system resources.
|
||||
|
||||
`max_background_agents` provides a single knob to limit total concurrent background agents regardless of which model they use.
|
||||
|
||||
## Config Usage
|
||||
|
||||
```jsonc
|
||||
// .opencode/oh-my-opencode.jsonc
|
||||
{
|
||||
"background_task": {
|
||||
"maxBackgroundAgents": 10 // default: 5, min: 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
| File | What |
|
||||
|------|------|
|
||||
| `src/config/schema/background-task.ts` | Add `maxBackgroundAgents` schema field |
|
||||
| `src/config/schema/background-task.test.ts` | Validation tests (valid, boundary, invalid) |
|
||||
| `src/features/background-agent/concurrency.ts` | Global counter + `canSpawnGlobally()` / `acquireGlobal()` / `releaseGlobal()` |
|
||||
| `src/features/background-agent/concurrency.test.ts` | Global limit unit tests |
|
||||
| `src/features/background-agent/manager.ts` | Enforce global limit in `launch()`, `trackTask()`; release in completion/cancel/error paths |
|
||||
|
||||
## Testing
|
||||
|
||||
- `bun test src/config/schema/background-task.test.ts` — schema validation
|
||||
- `bun test src/features/background-agent/concurrency.test.ts` — global limit enforcement
|
||||
- `bun run typecheck` — clean
|
||||
- `bun run build` — clean
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Verification Strategy
|
||||
|
||||
## Pre-Push Local Validation
|
||||
|
||||
Before every push, run all three checks sequentially:
|
||||
|
||||
```bash
|
||||
bun run typecheck && bun test && bun run build
|
||||
```
|
||||
|
||||
Specific test files to watch:
|
||||
```bash
|
||||
bun test src/config/schema/background-task.test.ts
|
||||
bun test src/features/background-agent/concurrency.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gate A: CI (`ci.yml`)
|
||||
|
||||
### What CI runs
|
||||
1. **Tests (split):** mock-heavy tests run in isolation (separate `bun test` processes), rest in batch
|
||||
2. **Typecheck:** `bun run typecheck` (tsc --noEmit)
|
||||
3. **Build:** `bun run build` (ESM + declarations + schema)
|
||||
4. **Schema auto-commit:** if generated schema changed, CI commits it
|
||||
|
||||
### How to monitor
|
||||
```bash
|
||||
gh pr checks <PR_NUMBER> --watch
|
||||
```
|
||||
|
||||
### Common failure scenarios and fixes
|
||||
|
||||
| Failure | Likely Cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| Typecheck error | New field not matching existing type imports | Verify `BackgroundTaskConfig` type is auto-inferred from schema, no manual type updates needed |
|
||||
| Test failure | Test assertion wrong or missing import | Fix test, re-push |
|
||||
| Build failure | Import cycle or missing export | Check barrel exports in `src/config/schema.ts` (already re-exports via `export *`) |
|
||||
| Schema auto-commit | Generated JSON schema changed | Pull the auto-commit, rebase if needed |
|
||||
|
||||
### Recovery
|
||||
```bash
|
||||
# Read CI logs
|
||||
gh run view <RUN_ID> --log-failed
|
||||
|
||||
# Fix, commit, push
|
||||
git add -A && git commit -m "fix: address CI failure" && git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gate B: review-work (5 parallel agents)
|
||||
|
||||
### What it checks
|
||||
Run `/review-work` which launches 5 background sub-agents:
|
||||
|
||||
| Agent | Role | What it checks for this PR |
|
||||
|-------|------|---------------------------|
|
||||
| Oracle (goal) | Goal/constraint verification | Does `maxBackgroundAgents` actually limit agents? Is default 5? Is min 1? |
|
||||
| Oracle (quality) | Code quality | Follows existing patterns? No catch-all files? Under 200 LOC? given/when/then tests? |
|
||||
| Oracle (security) | Security review | No injection vectors, no unsafe defaults, proper input validation via Zod |
|
||||
| Hephaestus (QA) | Hands-on QA execution | Actually runs tests, checks typecheck, verifies build |
|
||||
| Hephaestus (context) | Context mining | Checks git history, related issues, ensures no duplicate/conflicting PRs |
|
||||
|
||||
### Pass criteria
|
||||
All 5 agents must pass. Any single failure blocks.
|
||||
|
||||
### Common failure scenarios and fixes
|
||||
|
||||
| Agent | Likely Issue | Fix |
|
||||
|-------|-------------|-----|
|
||||
| Oracle (goal) | Global limit not enforced in all exit paths (completion, cancel, error, interrupt) | Audit every status transition in `manager.ts` that should call `releaseGlobal()` |
|
||||
| Oracle (quality) | Test style not matching given/when/then | Restructure tests with `#given`/`#when`/`#then` describe nesting |
|
||||
| Oracle (quality) | File exceeds 200 LOC | `concurrency.ts` is 137 LOC + ~25 new = ~162 LOC, safe. `manager.ts` is already large but we're adding ~20 lines to existing methods, not creating new responsibility |
|
||||
| Oracle (security) | Integer overflow or negative values | Zod `.int().min(1)` handles this at config parse time |
|
||||
| Hephaestus (QA) | Test actually fails when run | Run tests locally first, fix before push |
|
||||
|
||||
### Recovery
|
||||
```bash
|
||||
# Review agent output
|
||||
background_output(task_id="<review-work-task-id>")
|
||||
|
||||
# Fix identified issues
|
||||
# ... edit files ...
|
||||
git add -A && git commit -m "fix: address review-work feedback" && git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gate C: Cubic (`cubic-dev-ai[bot]`)
|
||||
|
||||
### What it checks
|
||||
Cubic is an automated code review bot that analyzes the PR diff. It must respond with "No issues found" for the gate to pass.
|
||||
|
||||
### Common failure scenarios and fixes
|
||||
|
||||
| Issue | Likely Cause | Fix |
|
||||
|-------|-------------|-----|
|
||||
| "Missing error handling" | `releaseGlobal()` not called in some error path | Add `releaseGlobal()` to the missed path |
|
||||
| "Inconsistent naming" | Field name doesn't match convention | Use `maxBackgroundAgents` (camelCase in schema, `max_background_agents` in JSONC config) |
|
||||
| "Missing documentation" | No JSDoc on new public methods | Add JSDoc comments to `canSpawnGlobally()`, `acquireGlobal()`, `releaseGlobal()`, `getMaxBackgroundAgents()` |
|
||||
| "Test coverage gap" | Missing edge case test | Add the specific test case Cubic identifies |
|
||||
|
||||
### Recovery
|
||||
```bash
|
||||
# Read Cubic's review
|
||||
gh api repos/code-yeongyu/oh-my-openagent/pulls/<PR_NUMBER>/reviews
|
||||
|
||||
# Address each comment
|
||||
# ... edit files ...
|
||||
git add -A && git commit -m "fix: address Cubic review feedback" && git push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Loop Pseudocode
|
||||
|
||||
```
|
||||
iteration = 0
|
||||
while true:
|
||||
iteration++
|
||||
log("Verification iteration ${iteration}")
|
||||
|
||||
# Gate A: CI (cheapest, check first)
|
||||
push_and_wait_for_ci()
|
||||
if ci_failed:
|
||||
read_ci_logs()
|
||||
fix_and_commit()
|
||||
continue
|
||||
|
||||
# Gate B: review-work (5 agents, more expensive)
|
||||
run_review_work()
|
||||
if any_agent_failed:
|
||||
read_agent_feedback()
|
||||
fix_and_commit()
|
||||
continue
|
||||
|
||||
# Gate C: Cubic (external bot, wait for it)
|
||||
wait_for_cubic_review()
|
||||
if cubic_has_issues:
|
||||
read_cubic_comments()
|
||||
fix_and_commit()
|
||||
continue
|
||||
|
||||
# All gates passed
|
||||
break
|
||||
|
||||
# Merge
|
||||
gh pr merge <PR_NUMBER> --squash --delete-branch
|
||||
```
|
||||
|
||||
No iteration cap. Loop continues until all three gates pass simultaneously in a single iteration.
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Probability | Mitigation |
|
||||
|------|------------|------------|
|
||||
| Slot leak (global count never decremented) | Medium | Audit every exit path: `tryCompleteTask`, `cancelTask`, `handleEvent(session.error)`, `startTask` prompt error, `resume` prompt error |
|
||||
| Race condition on global count | Low | `globalRunningCount` is synchronous (single-threaded JS), no async gap between check and increment in `launch()` |
|
||||
| Breaking existing behavior | Low | Default is 5, same as existing per-model default. Users with <5 total agents see no change |
|
||||
| `manager.ts` exceeding 200 LOC | Already exceeded | File is already ~1500 LOC (exempt due to being a core orchestration class with many methods). Our changes add ~20 lines to existing methods, not a new responsibility |
|
||||
@@ -0,0 +1 @@
|
||||
{"total_tokens": null, "duration_ms": 292000, "total_duration_seconds": 292}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"run_id": "eval-1-without_skill",
|
||||
"expectations": [
|
||||
{"text": "Plan uses git worktree in a sibling directory", "passed": false, "evidence": "Uses git checkout -b, no worktree isolation"},
|
||||
{"text": "Branch is created from origin/dev", "passed": true, "evidence": "git checkout -b feat/max-background-agents dev"},
|
||||
{"text": "Plan specifies multiple atomic commits for multi-file changes", "passed": false, "evidence": "Steps listed sequentially but no atomic commit strategy mentioned"},
|
||||
{"text": "Runs bun run typecheck, bun test, and bun run build before pushing", "passed": true, "evidence": "Step 6 runs typecheck and tests, Step 8 implies push after verification"},
|
||||
{"text": "PR is created targeting dev branch", "passed": true, "evidence": "Step 8 mentions creating PR"},
|
||||
{"text": "Verification loop includes all 3 gates: CI, review-work, and Cubic", "passed": false, "evidence": "Only mentions CI pipeline in step 6. No review-work or Cubic."},
|
||||
{"text": "Gates are checked in order: CI first, then review-work, then Cubic", "passed": false, "evidence": "No gate ordering - only CI mentioned"},
|
||||
{"text": "Cubic check uses gh api to check cubic-dev-ai[bot] reviews", "passed": false, "evidence": "No mention of Cubic at all"},
|
||||
{"text": "Plan includes worktree cleanup after merge", "passed": false, "evidence": "No worktree used, no cleanup needed"},
|
||||
{"text": "Code changes reference actual files in the codebase", "passed": true, "evidence": "References actual files with detailed design decisions"}
|
||||
]
|
||||
}
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
# Code Changes: `max_background_agents` Config Option
|
||||
|
||||
## 1. Schema Change
|
||||
|
||||
**File:** `src/config/schema/background-task.ts`
|
||||
|
||||
```typescript
|
||||
import { z } from "zod"
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
||||
maxDepth: z.number().int().min(1).optional(),
|
||||
maxDescendants: z.number().int().min(1).optional(),
|
||||
/** Maximum number of background agents that can run simultaneously across all models/providers (default: no global limit, only per-model limits apply) */
|
||||
maxBackgroundAgents: z.number().int().min(1).optional(),
|
||||
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
|
||||
staleTimeoutMs: z.number().min(60000).optional(),
|
||||
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
|
||||
messageStalenessTimeoutMs: z.number().min(60000).optional(),
|
||||
syncPollTimeoutMs: z.number().min(60000).optional(),
|
||||
})
|
||||
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
```
|
||||
|
||||
**What changed:** Added `maxBackgroundAgents` field after `maxDescendants` (grouped with other limit fields). Uses `z.number().int().min(1).optional()` matching the pattern of `maxDepth` and `maxDescendants`.
|
||||
|
||||
---
|
||||
|
||||
## 2. ConcurrencyManager Changes
|
||||
|
||||
**File:** `src/features/background-agent/concurrency.ts`
|
||||
|
||||
```typescript
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
/**
|
||||
* Queue entry with settled-flag pattern to prevent double-resolution.
|
||||
*
|
||||
* The settled flag ensures that cancelWaiters() doesn't reject
|
||||
* an entry that was already resolved by release().
|
||||
*/
|
||||
interface QueueEntry {
|
||||
resolve: () => void
|
||||
rawReject: (error: Error) => void
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
export class ConcurrencyManager {
|
||||
private config?: BackgroundTaskConfig
|
||||
private counts: Map<string, number> = new Map()
|
||||
private queues: Map<string, QueueEntry[]> = new Map()
|
||||
private globalCount = 0
|
||||
private globalQueue: QueueEntry[] = []
|
||||
|
||||
constructor(config?: BackgroundTaskConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
getGlobalLimit(): number {
|
||||
const limit = this.config?.maxBackgroundAgents
|
||||
if (limit === undefined) {
|
||||
return Infinity
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
getConcurrencyLimit(model: string): number {
|
||||
const modelLimit = this.config?.modelConcurrency?.[model]
|
||||
if (modelLimit !== undefined) {
|
||||
return modelLimit === 0 ? Infinity : modelLimit
|
||||
}
|
||||
const provider = model.split('/')[0]
|
||||
const providerLimit = this.config?.providerConcurrency?.[provider]
|
||||
if (providerLimit !== undefined) {
|
||||
return providerLimit === 0 ? Infinity : providerLimit
|
||||
}
|
||||
const defaultLimit = this.config?.defaultConcurrency
|
||||
if (defaultLimit !== undefined) {
|
||||
return defaultLimit === 0 ? Infinity : defaultLimit
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
async acquire(model: string): Promise<void> {
|
||||
const perModelLimit = this.getConcurrencyLimit(model)
|
||||
const globalLimit = this.getGlobalLimit()
|
||||
|
||||
// Fast path: both limits have capacity
|
||||
if (perModelLimit === Infinity && globalLimit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentPerModel = this.counts.get(model) ?? 0
|
||||
|
||||
if (currentPerModel < perModelLimit && this.globalCount < globalLimit) {
|
||||
this.counts.set(model, currentPerModel + 1)
|
||||
this.globalCount++
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const entry: QueueEntry = {
|
||||
resolve: () => {
|
||||
if (entry.settled) return
|
||||
entry.settled = true
|
||||
resolve()
|
||||
},
|
||||
rawReject: reject,
|
||||
settled: false,
|
||||
}
|
||||
|
||||
// Queue on whichever limit is blocking
|
||||
if (currentPerModel >= perModelLimit) {
|
||||
const queue = this.queues.get(model) ?? []
|
||||
queue.push(entry)
|
||||
this.queues.set(model, queue)
|
||||
} else {
|
||||
this.globalQueue.push(entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
release(model: string): void {
|
||||
const perModelLimit = this.getConcurrencyLimit(model)
|
||||
const globalLimit = this.getGlobalLimit()
|
||||
|
||||
if (perModelLimit === Infinity && globalLimit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try per-model handoff first
|
||||
const queue = this.queues.get(model)
|
||||
while (queue && queue.length > 0) {
|
||||
const next = queue.shift()!
|
||||
if (!next.settled) {
|
||||
// Hand off the slot to this waiter (counts stay the same)
|
||||
next.resolve()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No per-model handoff - decrement per-model count
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current > 0) {
|
||||
this.counts.set(model, current - 1)
|
||||
}
|
||||
|
||||
// Try global handoff
|
||||
while (this.globalQueue.length > 0) {
|
||||
const next = this.globalQueue.shift()!
|
||||
if (!next.settled) {
|
||||
// Hand off the global slot - but the waiter still needs a per-model slot
|
||||
// Since they were queued on global, their per-model had capacity
|
||||
// Re-acquire per-model count for them
|
||||
const waiterModel = this.findModelForGlobalWaiter()
|
||||
if (waiterModel) {
|
||||
const waiterCount = this.counts.get(waiterModel) ?? 0
|
||||
this.counts.set(waiterModel, waiterCount + 1)
|
||||
}
|
||||
next.resolve()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No handoff occurred - decrement global count
|
||||
if (this.globalCount > 0) {
|
||||
this.globalCount--
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all waiting acquires for a model. Used during cleanup.
|
||||
*/
|
||||
cancelWaiters(model: string): void {
|
||||
const queue = this.queues.get(model)
|
||||
if (queue) {
|
||||
for (const entry of queue) {
|
||||
if (!entry.settled) {
|
||||
entry.settled = true
|
||||
entry.rawReject(new Error(`Concurrency queue cancelled for model: ${model}`))
|
||||
}
|
||||
}
|
||||
this.queues.delete(model)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all state. Used during manager cleanup/shutdown.
|
||||
* Cancels all pending waiters.
|
||||
*/
|
||||
clear(): void {
|
||||
for (const [model] of this.queues) {
|
||||
this.cancelWaiters(model)
|
||||
}
|
||||
// Cancel global queue waiters
|
||||
for (const entry of this.globalQueue) {
|
||||
if (!entry.settled) {
|
||||
entry.settled = true
|
||||
entry.rawReject(new Error("Concurrency queue cancelled: manager shutdown"))
|
||||
}
|
||||
}
|
||||
this.globalQueue = []
|
||||
this.globalCount = 0
|
||||
this.counts.clear()
|
||||
this.queues.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current count for a model (for testing/debugging)
|
||||
*/
|
||||
getCount(model: string): number {
|
||||
return this.counts.get(model) ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue length for a model (for testing/debugging)
|
||||
*/
|
||||
getQueueLength(model: string): number {
|
||||
return this.queues.get(model)?.length ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current global count across all models (for testing/debugging)
|
||||
*/
|
||||
getGlobalCount(): number {
|
||||
return this.globalCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global queue length (for testing/debugging)
|
||||
*/
|
||||
getGlobalQueueLength(): number {
|
||||
return this.globalQueue.length
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**What changed:**
|
||||
- Added `globalCount` field to track total active agents across all keys
|
||||
- Added `globalQueue` for tasks waiting on the global limit
|
||||
- Added `getGlobalLimit()` method to read `maxBackgroundAgents` from config
|
||||
- Modified `acquire()` to check both per-model AND global limits
|
||||
- Modified `release()` to handle global queue handoff and decrement global count
|
||||
- Modified `clear()` to reset global state
|
||||
- Added `getGlobalCount()` and `getGlobalQueueLength()` for testing
|
||||
|
||||
**Important design note:** The `release()` implementation above is a simplified version. In practice, the global queue handoff is tricky because we need to know which model the global waiter was trying to acquire for. A cleaner approach would be to store the model key in the QueueEntry. Let me refine:
|
||||
|
||||
### Refined approach (simpler, more correct)
|
||||
|
||||
Instead of a separate global queue, a simpler approach is to check the global limit inside `acquire()` and use a single queue per model. When global capacity frees up on `release()`, we try to drain any model's queue:
|
||||
|
||||
```typescript
|
||||
async acquire(model: string): Promise<void> {
|
||||
const perModelLimit = this.getConcurrencyLimit(model)
|
||||
const globalLimit = this.getGlobalLimit()
|
||||
|
||||
if (perModelLimit === Infinity && globalLimit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentPerModel = this.counts.get(model) ?? 0
|
||||
|
||||
if (currentPerModel < perModelLimit && this.globalCount < globalLimit) {
|
||||
this.counts.set(model, currentPerModel + 1)
|
||||
if (globalLimit !== Infinity) {
|
||||
this.globalCount++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const queue = this.queues.get(model) ?? []
|
||||
|
||||
const entry: QueueEntry = {
|
||||
resolve: () => {
|
||||
if (entry.settled) return
|
||||
entry.settled = true
|
||||
resolve()
|
||||
},
|
||||
rawReject: reject,
|
||||
settled: false,
|
||||
}
|
||||
|
||||
queue.push(entry)
|
||||
this.queues.set(model, queue)
|
||||
})
|
||||
}
|
||||
|
||||
release(model: string): void {
|
||||
const perModelLimit = this.getConcurrencyLimit(model)
|
||||
const globalLimit = this.getGlobalLimit()
|
||||
|
||||
if (perModelLimit === Infinity && globalLimit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try per-model handoff first (same model queue)
|
||||
const queue = this.queues.get(model)
|
||||
while (queue && queue.length > 0) {
|
||||
const next = queue.shift()!
|
||||
if (!next.settled) {
|
||||
// Hand off the slot to this waiter (per-model and global counts stay the same)
|
||||
next.resolve()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No per-model handoff - decrement per-model count
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current > 0) {
|
||||
this.counts.set(model, current - 1)
|
||||
}
|
||||
|
||||
// Decrement global count
|
||||
if (globalLimit !== Infinity && this.globalCount > 0) {
|
||||
this.globalCount--
|
||||
}
|
||||
|
||||
// Try to drain any other model's queue that was blocked by global limit
|
||||
if (globalLimit !== Infinity) {
|
||||
this.tryDrainGlobalWaiters()
|
||||
}
|
||||
}
|
||||
|
||||
private tryDrainGlobalWaiters(): void {
|
||||
const globalLimit = this.getGlobalLimit()
|
||||
if (this.globalCount >= globalLimit) return
|
||||
|
||||
for (const [model, queue] of this.queues) {
|
||||
const perModelLimit = this.getConcurrencyLimit(model)
|
||||
const currentPerModel = this.counts.get(model) ?? 0
|
||||
|
||||
if (currentPerModel >= perModelLimit) continue
|
||||
|
||||
while (queue.length > 0 && this.globalCount < globalLimit && currentPerModel < perModelLimit) {
|
||||
const next = queue.shift()!
|
||||
if (!next.settled) {
|
||||
this.counts.set(model, (this.counts.get(model) ?? 0) + 1)
|
||||
this.globalCount++
|
||||
next.resolve()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This refined approach keeps all waiters in per-model queues (no separate global queue), and on release, tries to drain waiters from any model queue that was blocked by the global limit.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema Test Changes
|
||||
|
||||
**File:** `src/config/schema/background-task.test.ts`
|
||||
|
||||
Add after the `syncPollTimeoutMs` describe block:
|
||||
|
||||
```typescript
|
||||
describe("maxBackgroundAgents", () => {
|
||||
describe("#given valid maxBackgroundAgents (10)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 10 })
|
||||
|
||||
expect(result.maxBackgroundAgents).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxBackgroundAgents of 1 (minimum)", () => {
|
||||
test("#when parsed #then returns correct value", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 1 })
|
||||
|
||||
expect(result.maxBackgroundAgents).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxBackgroundAgents below minimum (0)", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 0 })
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxBackgroundAgents is negative (-1)", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: -1 })
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxBackgroundAgents is non-integer (2.5)", () => {
|
||||
test("#when parsed #then throws ZodError", () => {
|
||||
let thrownError: unknown
|
||||
|
||||
try {
|
||||
BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 2.5 })
|
||||
} catch (error) {
|
||||
thrownError = error
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(ZodError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given maxBackgroundAgents not provided", () => {
|
||||
test("#when parsed #then field is undefined", () => {
|
||||
const result = BackgroundTaskConfigSchema.parse({})
|
||||
|
||||
expect(result.maxBackgroundAgents).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. ConcurrencyManager Test Changes
|
||||
|
||||
**File:** `src/features/background-agent/concurrency.test.ts`
|
||||
|
||||
Add new describe block:
|
||||
|
||||
```typescript
|
||||
describe("ConcurrencyManager.globalLimit (maxBackgroundAgents)", () => {
|
||||
test("should return Infinity when maxBackgroundAgents is not set", () => {
|
||||
// given
|
||||
const manager = new ConcurrencyManager()
|
||||
|
||||
// when
|
||||
const limit = manager.getGlobalLimit()
|
||||
|
||||
// then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return configured maxBackgroundAgents", () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { maxBackgroundAgents: 3 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// when
|
||||
const limit = manager.getGlobalLimit()
|
||||
|
||||
// then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should enforce global limit across different models", async () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
maxBackgroundAgents: 2,
|
||||
defaultConcurrency: 5,
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-b")
|
||||
|
||||
// when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("model-c").then(() => { resolved = true })
|
||||
await Promise.resolve()
|
||||
|
||||
// then - should be blocked by global limit even though per-model has capacity
|
||||
expect(resolved).toBe(false)
|
||||
expect(manager.getGlobalCount()).toBe(2)
|
||||
|
||||
// cleanup
|
||||
manager.release("model-a")
|
||||
await waitPromise
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should allow tasks when global limit not reached", async () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
maxBackgroundAgents: 3,
|
||||
defaultConcurrency: 5,
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-b")
|
||||
await manager.acquire("model-c")
|
||||
|
||||
// then
|
||||
expect(manager.getGlobalCount()).toBe(3)
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
expect(manager.getCount("model-b")).toBe(1)
|
||||
expect(manager.getCount("model-c")).toBe(1)
|
||||
})
|
||||
|
||||
test("should respect both per-model and global limits", async () => {
|
||||
// given - per-model limit of 1, global limit of 3
|
||||
const config: BackgroundTaskConfig = {
|
||||
maxBackgroundAgents: 3,
|
||||
defaultConcurrency: 1,
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// when - try second acquire on same model
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("model-a").then(() => { resolved = true })
|
||||
await Promise.resolve()
|
||||
|
||||
// then - blocked by per-model limit, not global
|
||||
expect(resolved).toBe(false)
|
||||
expect(manager.getGlobalCount()).toBe(1)
|
||||
|
||||
// cleanup
|
||||
manager.release("model-a")
|
||||
await waitPromise
|
||||
})
|
||||
|
||||
test("should release global slot and unblock waiting tasks", async () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = {
|
||||
maxBackgroundAgents: 1,
|
||||
defaultConcurrency: 5,
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("model-b").then(() => { resolved = true })
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
manager.release("model-a")
|
||||
await waitPromise
|
||||
|
||||
// then
|
||||
expect(resolved).toBe(true)
|
||||
expect(manager.getGlobalCount()).toBe(1)
|
||||
expect(manager.getCount("model-a")).toBe(0)
|
||||
expect(manager.getCount("model-b")).toBe(1)
|
||||
})
|
||||
|
||||
test("should not enforce global limit when not configured", async () => {
|
||||
// given - no maxBackgroundAgents set
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 5 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// when - acquire many across different models
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-b")
|
||||
await manager.acquire("model-c")
|
||||
await manager.acquire("model-d")
|
||||
await manager.acquire("model-e")
|
||||
await manager.acquire("model-f")
|
||||
|
||||
// then - all should succeed (no global limit)
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
expect(manager.getCount("model-f")).toBe(1)
|
||||
})
|
||||
|
||||
test("should reset global count on clear", async () => {
|
||||
// given
|
||||
const config: BackgroundTaskConfig = { maxBackgroundAgents: 5 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-b")
|
||||
|
||||
// when
|
||||
manager.clear()
|
||||
|
||||
// then
|
||||
expect(manager.getGlobalCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config Usage Example
|
||||
|
||||
User's `.opencode/oh-my-opencode.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"background_task": {
|
||||
// Global limit: max 5 background agents total
|
||||
"maxBackgroundAgents": 5,
|
||||
// Per-model limits still apply independently
|
||||
"defaultConcurrency": 3,
|
||||
"providerConcurrency": {
|
||||
"anthropic": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With this config:
|
||||
- Max 5 background agents running simultaneously across all models
|
||||
- Max 3 per model (default), max 2 for any Anthropic model
|
||||
- If 2 Anthropic + 3 OpenAI agents are running (5 total), no more can start regardless of per-model capacity
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
# Execution Plan: Add `max_background_agents` Config Option
|
||||
|
||||
## Overview
|
||||
|
||||
Add a `max_background_agents` config option to oh-my-opencode that limits total simultaneous background agents across all models/providers. Currently, concurrency is only limited per-model/provider key (default 5 per key). This new option adds a **global ceiling** on total running background agents.
|
||||
|
||||
## Step-by-Step Plan
|
||||
|
||||
### Step 1: Create feature branch
|
||||
|
||||
```bash
|
||||
git checkout -b feat/max-background-agents dev
|
||||
```
|
||||
|
||||
### Step 2: Add `max_background_agents` to BackgroundTaskConfigSchema
|
||||
|
||||
**File:** `src/config/schema/background-task.ts`
|
||||
|
||||
- Add `maxBackgroundAgents` field to the Zod schema with `z.number().int().min(1).optional()`
|
||||
- This follows the existing pattern of `maxDepth` and `maxDescendants` (integer, min 1, optional)
|
||||
- The field name uses camelCase to match existing schema fields (`defaultConcurrency`, `maxDepth`, `maxDescendants`)
|
||||
- No `.default()` needed since the hardcoded fallback of 5 lives in `ConcurrencyManager`
|
||||
|
||||
### Step 3: Modify `ConcurrencyManager` to enforce global limit
|
||||
|
||||
**File:** `src/features/background-agent/concurrency.ts`
|
||||
|
||||
- Add a `globalCount` field tracking total active agents across all keys
|
||||
- Modify `acquire()` to check global count against `maxBackgroundAgents` before granting a slot
|
||||
- Modify `release()` to decrement global count
|
||||
- Modify `clear()` to reset global count
|
||||
- Add `getGlobalCount()` for testing/debugging (follows existing `getCount()`/`getQueueLength()` pattern)
|
||||
|
||||
The global limit check happens **in addition to** the per-model limit. Both must have capacity for a task to proceed.
|
||||
|
||||
### Step 4: Add tests for the new config schema field
|
||||
|
||||
**File:** `src/config/schema/background-task.test.ts`
|
||||
|
||||
- Add test cases following the existing given/when/then pattern with nested describes
|
||||
- Test valid value, below-minimum value, undefined (not provided), non-number type
|
||||
|
||||
### Step 5: Add tests for ConcurrencyManager global limit
|
||||
|
||||
**File:** `src/features/background-agent/concurrency.test.ts`
|
||||
|
||||
- Test that global limit is enforced across different model keys
|
||||
- Test that tasks queue when global limit reached even if per-model limit has capacity
|
||||
- Test that releasing a slot from one model allows a queued task from another model to proceed
|
||||
- Test default behavior (5) when no config provided
|
||||
- Test interaction between global and per-model limits
|
||||
|
||||
### Step 6: Run typecheck and tests
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun test src/config/schema/background-task.test.ts
|
||||
bun test src/features/background-agent/concurrency.test.ts
|
||||
```
|
||||
|
||||
### Step 7: Verify LSP diagnostics clean
|
||||
|
||||
Check `src/config/schema/background-task.ts` and `src/features/background-agent/concurrency.ts` for errors.
|
||||
|
||||
### Step 8: Create PR
|
||||
|
||||
- Push branch to remote
|
||||
- Create PR with structured description via `gh pr create`
|
||||
|
||||
## Files Modified (4 files)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/config/schema/background-task.ts` | Add `maxBackgroundAgents` field |
|
||||
| `src/features/background-agent/concurrency.ts` | Add global count tracking + enforcement |
|
||||
| `src/config/schema/background-task.test.ts` | Add schema validation tests |
|
||||
| `src/features/background-agent/concurrency.test.ts` | Add global limit enforcement tests |
|
||||
|
||||
## Files NOT Modified (intentional)
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `src/config/schema/oh-my-opencode-config.ts` | No change needed - `BackgroundTaskConfigSchema` is already composed into root schema via `background_task` field |
|
||||
| `src/create-managers.ts` | No change needed - `pluginConfig.background_task` already passed to `BackgroundManager` constructor |
|
||||
| `src/features/background-agent/manager.ts` | No change needed - already passes config to `ConcurrencyManager` |
|
||||
| `src/plugin-config.ts` | No change needed - `background_task` is a simple object field, uses default override merge |
|
||||
| `src/config/schema.ts` | No change needed - barrel already exports `BackgroundTaskConfigSchema` |
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Field name `maxBackgroundAgents`** - camelCase to match existing schema fields (`maxDepth`, `maxDescendants`, `defaultConcurrency`). The user-facing JSONC config key is also camelCase per existing convention in `background_task` section.
|
||||
|
||||
2. **Global limit vs per-model limit** - The global limit is a ceiling across ALL concurrency keys. Per-model limits still apply independently. A task needs both a per-model slot AND a global slot to proceed.
|
||||
|
||||
3. **Default of 5** - Matches the existing hardcoded default in `getConcurrencyLimit()`. When `maxBackgroundAgents` is not set, no global limit is enforced (only per-model limits apply), preserving backward compatibility.
|
||||
|
||||
4. **Queue behavior** - When global limit is reached, tasks wait in the same FIFO queue mechanism. The global check happens inside `acquire()` before the per-model check.
|
||||
|
||||
5. **0 means Infinity** - Following the existing pattern where `defaultConcurrency: 0` means unlimited, `maxBackgroundAgents: 0` would also mean no global limit.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# PR Description
|
||||
|
||||
**Title:** feat: add `maxBackgroundAgents` config to limit total simultaneous background agents
|
||||
|
||||
**Body:**
|
||||
|
||||
## Summary
|
||||
|
||||
- Add `maxBackgroundAgents` field to `BackgroundTaskConfigSchema` that enforces a global ceiling on total running background agents across all models/providers
|
||||
- Modify `ConcurrencyManager` to track global count and enforce the limit alongside existing per-model limits
|
||||
- Add schema validation tests and concurrency enforcement tests
|
||||
|
||||
## Motivation
|
||||
|
||||
Currently, concurrency is only limited per model/provider key (default 5 per key). On resource-constrained machines or when using many different models, the total number of background agents can grow unbounded (5 per model x N models). This config option lets users set a hard ceiling.
|
||||
|
||||
## Changes
|
||||
|
||||
### Schema (`src/config/schema/background-task.ts`)
|
||||
- Added `maxBackgroundAgents: z.number().int().min(1).optional()` to `BackgroundTaskConfigSchema`
|
||||
- Grouped with existing limit fields (`maxDepth`, `maxDescendants`)
|
||||
|
||||
### ConcurrencyManager (`src/features/background-agent/concurrency.ts`)
|
||||
- Added `globalCount` tracking total active agents across all concurrency keys
|
||||
- Added `getGlobalLimit()` reading `maxBackgroundAgents` from config (defaults to `Infinity` = no global limit)
|
||||
- Modified `acquire()` to check both per-model AND global capacity
|
||||
- Modified `release()` to decrement global count and drain cross-model waiters blocked by global limit
|
||||
- Modified `clear()` to reset global state
|
||||
- Added `getGlobalCount()` / `getGlobalQueueLength()` for testing
|
||||
|
||||
### Tests
|
||||
- `src/config/schema/background-task.test.ts`: 6 test cases for schema validation (valid, min boundary, below min, negative, non-integer, undefined)
|
||||
- `src/features/background-agent/concurrency.test.ts`: 8 test cases for global limit enforcement (cross-model blocking, release unblocking, per-model vs global interaction, no-config default, clear reset)
|
||||
|
||||
## Config Example
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"background_task": {
|
||||
"maxBackgroundAgents": 5,
|
||||
"defaultConcurrency": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- When `maxBackgroundAgents` is not set (default), no global limit is enforced - behavior is identical to before
|
||||
- Existing `defaultConcurrency`, `providerConcurrency`, and `modelConcurrency` continue to work unchanged
|
||||
- No config migration needed
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Verification Strategy
|
||||
|
||||
## 1. Static Analysis
|
||||
|
||||
### TypeScript Typecheck
|
||||
```bash
|
||||
bun run typecheck
|
||||
```
|
||||
- Verify no type errors introduced
|
||||
- `BackgroundTaskConfig` type is inferred from Zod schema, so adding the field automatically updates the type
|
||||
- All existing consumers of `BackgroundTaskConfig` remain compatible (new field is optional)
|
||||
|
||||
### LSP Diagnostics
|
||||
Check changed files for errors:
|
||||
- `src/config/schema/background-task.ts`
|
||||
- `src/features/background-agent/concurrency.ts`
|
||||
- `src/config/schema/background-task.test.ts`
|
||||
- `src/features/background-agent/concurrency.test.ts`
|
||||
|
||||
## 2. Unit Tests
|
||||
|
||||
### Schema Validation Tests
|
||||
```bash
|
||||
bun test src/config/schema/background-task.test.ts
|
||||
```
|
||||
|
||||
| Test Case | Input | Expected |
|
||||
|-----------|-------|----------|
|
||||
| Valid value (10) | `{ maxBackgroundAgents: 10 }` | Parses to `10` |
|
||||
| Minimum boundary (1) | `{ maxBackgroundAgents: 1 }` | Parses to `1` |
|
||||
| Below minimum (0) | `{ maxBackgroundAgents: 0 }` | Throws `ZodError` |
|
||||
| Negative (-1) | `{ maxBackgroundAgents: -1 }` | Throws `ZodError` |
|
||||
| Non-integer (2.5) | `{ maxBackgroundAgents: 2.5 }` | Throws `ZodError` |
|
||||
| Not provided | `{}` | Field is `undefined` |
|
||||
|
||||
### ConcurrencyManager Tests
|
||||
```bash
|
||||
bun test src/features/background-agent/concurrency.test.ts
|
||||
```
|
||||
|
||||
| Test Case | Setup | Expected |
|
||||
|-----------|-------|----------|
|
||||
| No config = no global limit | No `maxBackgroundAgents` | `getGlobalLimit()` returns `Infinity` |
|
||||
| Config respected | `maxBackgroundAgents: 3` | `getGlobalLimit()` returns `3` |
|
||||
| Cross-model blocking | Global limit 2, acquire model-a + model-b, try model-c | model-c blocks |
|
||||
| Under-limit allows | Global limit 3, acquire 3 different models | All succeed |
|
||||
| Per-model + global interaction | Per-model 1, global 3, acquire model-a twice | Blocked by per-model, not global |
|
||||
| Release unblocks | Global limit 1, acquire model-a, queue model-b, release model-a | model-b proceeds |
|
||||
| No global limit = no enforcement | No config, acquire 6 different models | All succeed |
|
||||
| Clear resets global count | Acquire 2, clear | `getGlobalCount()` is 0 |
|
||||
|
||||
### Existing Test Regression
|
||||
```bash
|
||||
bun test src/features/background-agent/concurrency.test.ts
|
||||
bun test src/config/schema/background-task.test.ts
|
||||
bun test src/config/schema.test.ts
|
||||
```
|
||||
All existing tests must continue to pass unchanged.
|
||||
|
||||
## 3. Integration Verification
|
||||
|
||||
### Config Loading Path
|
||||
Verify the config flows correctly through the system:
|
||||
|
||||
1. **Schema → Type**: `BackgroundTaskConfig` type auto-includes `maxBackgroundAgents` via `z.infer`
|
||||
2. **Config file → Schema**: `loadConfigFromPath()` in `plugin-config.ts` uses `OhMyOpenCodeConfigSchema.safeParse()` which includes `BackgroundTaskConfigSchema`
|
||||
3. **Config → Manager**: `create-managers.ts` passes `pluginConfig.background_task` to `BackgroundManager` constructor
|
||||
4. **Manager → ConcurrencyManager**: `BackgroundManager` constructor passes config to `new ConcurrencyManager(config)`
|
||||
5. **ConcurrencyManager → Enforcement**: `acquire()` reads `config.maxBackgroundAgents` via `getGlobalLimit()`
|
||||
|
||||
No changes needed in steps 2-4 since the field is optional and the existing plumbing passes the entire `BackgroundTaskConfig` object.
|
||||
|
||||
### Manual Config Test
|
||||
Create a test config to verify parsing:
|
||||
```bash
|
||||
echo '{ "background_task": { "maxBackgroundAgents": 3 } }' | bun -e "
|
||||
const { BackgroundTaskConfigSchema } = require('./src/config/schema/background-task');
|
||||
const result = BackgroundTaskConfigSchema.safeParse(JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf-8')).background_task);
|
||||
console.log(result.success, result.data);
|
||||
"
|
||||
```
|
||||
|
||||
## 4. Build Verification
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
- Verify build succeeds
|
||||
- Schema JSON output includes the new field (if applicable)
|
||||
|
||||
## 5. Edge Cases to Verify
|
||||
|
||||
| Edge Case | Expected Behavior |
|
||||
|-----------|-------------------|
|
||||
| `maxBackgroundAgents` not set | No global limit enforced (backward compatible) |
|
||||
| `maxBackgroundAgents: 1` | Only 1 background agent at a time across all models |
|
||||
| `maxBackgroundAgents` > sum of all per-model limits | Global limit never triggers (per-model limits are tighter) |
|
||||
| Per-model limit tighter than global | Per-model limit blocks first |
|
||||
| Global limit tighter than per-model | Global limit blocks first |
|
||||
| Release from one model unblocks different model | Global slot freed, different model's waiter proceeds |
|
||||
| Manager shutdown with global waiters | `clear()` rejects all waiters and resets global count |
|
||||
| Concurrent acquire/release | No race conditions (single-threaded JS event loop) |
|
||||
|
||||
## 6. CI Pipeline
|
||||
|
||||
The existing CI workflow (`ci.yml`) will run:
|
||||
- `bun run typecheck` - type checking
|
||||
- `bun test` - all tests including new ones
|
||||
- `bun run build` - build verification
|
||||
|
||||
No CI changes needed.
|
||||
@@ -0,0 +1 @@
|
||||
{"total_tokens": null, "duration_ms": 365000, "total_duration_seconds": 365}
|
||||
Reference in New Issue
Block a user