oh-my-opencode 4.3.1 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/command/get-unpublished-changes.md +148 -0
- package/.agents/command/omomomo.md +37 -0
- package/.agents/command/publish.md +376 -0
- package/.agents/command/remove-deadcode.md +221 -0
- package/.agents/command/security-research.md +16 -0
- package/.agents/skills/get-unpublished-changes/SKILL.md +24 -0
- package/.agents/skills/github-triage/SKILL.md +587 -0
- package/.agents/skills/github-triage/scripts/gh_fetch.py +398 -0
- package/.agents/skills/hyperplan/SKILL.md +450 -0
- package/.agents/skills/omomomo/SKILL.md +36 -0
- package/.agents/skills/pre-publish-review/SKILL.md +407 -0
- package/.agents/skills/publish/SKILL.md +428 -0
- package/.agents/skills/remove-deadcode/SKILL.md +216 -0
- package/.agents/skills/security-research/SKILL.md +204 -0
- package/.agents/skills/work-with-pr/SKILL.md +360 -0
- package/.agents/skills/work-with-pr-workspace/evals/evals.json +76 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/benchmark.json +138 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/benchmark.md +42 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/eval_metadata.json +57 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/grading.json +15 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/code-changes.md +454 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/execution-plan.md +136 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/pr-description.md +47 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/verification-strategy.md +163 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/grading.json +15 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/outputs/code-changes.md +615 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/outputs/execution-plan.md +99 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/outputs/pr-description.md +50 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/outputs/verification-strategy.md +111 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/eval_metadata.json +37 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/grading.json +11 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/code-changes.md +205 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/execution-plan.md +78 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/pr-description.md +42 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/verification-strategy.md +87 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/grading.json +11 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/code-changes.md +334 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/execution-plan.md +86 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/pr-description.md +23 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/verification-strategy.md +119 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/eval_metadata.json +32 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/grading.json +10 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/outputs/code-changes.md +221 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/outputs/execution-plan.md +104 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/outputs/pr-description.md +41 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/outputs/verification-strategy.md +84 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/grading.json +10 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/outputs/code-changes.md +342 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/outputs/execution-plan.md +131 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/outputs/pr-description.md +39 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/outputs/verification-strategy.md +128 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/eval_metadata.json +32 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/grading.json +10 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/outputs/code-changes.md +143 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/outputs/execution-plan.md +82 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/outputs/pr-description.md +51 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/outputs/verification-strategy.md +69 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/grading.json +10 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/outputs/code-changes.md +252 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/outputs/execution-plan.md +83 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/outputs/pr-description.md +33 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/outputs/verification-strategy.md +101 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/eval_metadata.json +32 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/grading.json +10 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/code-changes.md +387 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/execution-plan.md +112 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/pr-description.md +51 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/verification-strategy.md +75 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/grading.json +10 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/outputs/code-changes.md +529 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/outputs/execution-plan.md +127 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/outputs/pr-description.md +42 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/outputs/verification-strategy.md +120 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/timing.json +1 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/review.html +1326 -0
- package/.opencode/command/get-unpublished-changes.md +148 -0
- package/.opencode/command/omomomo.md +37 -0
- package/.opencode/command/publish.md +376 -0
- package/.opencode/command/remove-deadcode.md +221 -0
- package/.opencode/command/security-research.md +16 -0
- package/.opencode/skills/github-triage/SKILL.md +587 -0
- package/.opencode/skills/github-triage/scripts/gh_fetch.py +398 -0
- package/.opencode/skills/hyperplan/SKILL.md +450 -0
- package/.opencode/skills/pre-publish-review/SKILL.md +407 -0
- package/.opencode/skills/work-with-pr/SKILL.md +360 -0
- package/.opencode/skills/work-with-pr-workspace/evals/evals.json +76 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/benchmark.json +138 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/benchmark.md +42 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/eval_metadata.json +57 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/grading.json +15 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/code-changes.md +454 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/execution-plan.md +136 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/pr-description.md +47 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/outputs/verification-strategy.md +163 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/with_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/grading.json +15 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/outputs/code-changes.md +615 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/outputs/execution-plan.md +99 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/outputs/pr-description.md +50 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/outputs/verification-strategy.md +111 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-1/without_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/eval_metadata.json +37 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/grading.json +11 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/code-changes.md +205 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/execution-plan.md +78 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/pr-description.md +42 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/verification-strategy.md +87 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/grading.json +11 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/code-changes.md +334 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/execution-plan.md +86 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/pr-description.md +23 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/verification-strategy.md +119 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/eval_metadata.json +32 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/grading.json +10 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/outputs/code-changes.md +221 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/outputs/execution-plan.md +104 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/outputs/pr-description.md +41 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/outputs/verification-strategy.md +84 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/with_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/grading.json +10 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/outputs/code-changes.md +342 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/outputs/execution-plan.md +131 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/outputs/pr-description.md +39 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/outputs/verification-strategy.md +128 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-3/without_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/eval_metadata.json +32 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/grading.json +10 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/outputs/code-changes.md +143 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/outputs/execution-plan.md +82 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/outputs/pr-description.md +51 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/outputs/verification-strategy.md +69 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/with_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/grading.json +10 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/outputs/code-changes.md +252 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/outputs/execution-plan.md +83 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/outputs/pr-description.md +33 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/outputs/verification-strategy.md +101 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-4/without_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/eval_metadata.json +32 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/grading.json +10 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/code-changes.md +387 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/execution-plan.md +112 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/pr-description.md +51 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/verification-strategy.md +75 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/grading.json +10 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/outputs/code-changes.md +529 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/outputs/execution-plan.md +127 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/outputs/pr-description.md +42 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/outputs/verification-strategy.md +120 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/without_skill/timing.json +1 -0
- package/.opencode/skills/work-with-pr-workspace/iteration-1/review.html +1326 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh-cn.md +1 -1
- package/dist/agents/atlas/agent.d.ts +6 -6
- package/dist/agents/prometheus/gemini.d.ts +0 -11
- package/dist/agents/prometheus/gpt.d.ts +0 -10
- package/dist/agents/prometheus/system-prompt.d.ts +2 -20
- package/dist/agents/types.d.ts +1 -16
- package/dist/cli/index.js +60 -20
- package/dist/config/schema/agent-names.d.ts +3 -3
- package/dist/config/schema/agent-overrides.d.ts +208 -208
- package/dist/config/schema/categories.d.ts +28 -28
- package/dist/config/schema/fallback-models.d.ts +20 -20
- package/dist/config/schema/oh-my-opencode-config.d.ts +208 -208
- package/dist/features/background-agent/parent-wake-dedupe.d.ts +19 -0
- package/dist/features/background-agent/parent-wake-notifier.d.ts +8 -19
- package/dist/help/schema/acp.d.ts +95 -0
- package/dist/help/schema/doctor.d.ts +147 -0
- package/dist/help/schema/sandbox.d.ts +74 -0
- package/dist/help/schema/status.d.ts +139 -0
- package/dist/hooks/keyword-detector/analyze/default.d.ts +1 -1
- package/dist/hooks/keyword-detector/hyperplan/default.d.ts +1 -1
- package/dist/hooks/keyword-detector/search/default.d.ts +1 -1
- package/dist/hooks/keyword-detector/team/default.d.ts +2 -7
- package/dist/hooks/keyword-detector/ultrawork/default.d.ts +1 -9
- package/dist/hooks/keyword-detector/ultrawork/gemini.d.ts +1 -16
- package/dist/hooks/keyword-detector/ultrawork/gpt.d.ts +1 -10
- package/dist/hooks/keyword-detector/ultrawork/planner.d.ts +1 -5
- package/dist/hooks/ralph-loop/no-progress-turn-detector.d.ts +7 -0
- package/dist/hooks/ralph-loop/pending-verification-handler.d.ts +1 -0
- package/dist/hooks/ralph-loop/types.d.ts +1 -0
- package/dist/hooks/runtime-fallback/error-classifier.d.ts +1 -0
- package/dist/hooks/tool-pair-validator/hook.d.ts +6 -1
- package/dist/index.js +51976 -50299
- package/dist/plugin-handlers/provider-config-handler.d.ts +1 -0
- package/dist/shared/migration/model-versions.d.ts +6 -0
- package/dist/shared/prompt-async-gate/pending-tool-turn.d.ts +1 -0
- package/dist/shared/prompt-async-gate/types.d.ts +4 -3
- package/package.json +19 -13
- package/dist/agents/atlas/default-prompt-sections.d.ts +0 -6
- package/dist/agents/atlas/default.d.ts +0 -2
- package/dist/agents/atlas/gemini-prompt-sections.d.ts +0 -6
- package/dist/agents/atlas/gemini.d.ts +0 -2
- package/dist/agents/atlas/gpt-prompt-sections.d.ts +0 -6
- package/dist/agents/atlas/gpt.d.ts +0 -2
- package/dist/agents/atlas/kimi-prompt-sections.d.ts +0 -6
- package/dist/agents/atlas/kimi.d.ts +0 -2
- package/dist/agents/atlas/opus-4-7-prompt-sections.d.ts +0 -6
- package/dist/agents/atlas/opus-4-7.d.ts +0 -2
- package/dist/agents/atlas/shared-prompt.d.ts +0 -9
- package/dist/agents/prometheus/behavioral-summary.d.ts +0 -6
- package/dist/agents/prometheus/high-accuracy-mode.d.ts +0 -6
- package/dist/agents/prometheus/identity-constraints.d.ts +0 -7
- package/dist/agents/prometheus/interview-mode.d.ts +0 -7
- package/dist/agents/prometheus/plan-generation.d.ts +0 -7
- package/dist/agents/prometheus/plan-template.d.ts +0 -7
- package/dist/agents/prometheus/spec-driven-mode.d.ts +0 -7
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
# Code Changes: `max_background_agents` Config Option
|
|
2
|
+
|
|
3
|
+
## 1. Schema Change
|
|
4
|
+
|
|
5
|
+
**File:** `src/config/schema/background-task.ts`
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { z } from "zod"
|
|
9
|
+
|
|
10
|
+
export const BackgroundTaskConfigSchema = z.object({
|
|
11
|
+
defaultConcurrency: z.number().min(1).optional(),
|
|
12
|
+
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
|
13
|
+
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
|
|
14
|
+
maxDepth: z.number().int().min(1).optional(),
|
|
15
|
+
maxDescendants: z.number().int().min(1).optional(),
|
|
16
|
+
/** Maximum number of background agents that can run simultaneously across all models/providers (default: no global limit, only per-model limits apply) */
|
|
17
|
+
maxBackgroundAgents: z.number().int().min(1).optional(),
|
|
18
|
+
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
|
|
19
|
+
staleTimeoutMs: z.number().min(60000).optional(),
|
|
20
|
+
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
|
|
21
|
+
messageStalenessTimeoutMs: z.number().min(60000).optional(),
|
|
22
|
+
syncPollTimeoutMs: z.number().min(60000).optional(),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**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`.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 2. ConcurrencyManager Changes
|
|
33
|
+
|
|
34
|
+
**File:** `src/features/background-agent/concurrency.ts`
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import type { BackgroundTaskConfig } from "../../config/schema"
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Queue entry with settled-flag pattern to prevent double-resolution.
|
|
41
|
+
*
|
|
42
|
+
* The settled flag ensures that cancelWaiters() doesn't reject
|
|
43
|
+
* an entry that was already resolved by release().
|
|
44
|
+
*/
|
|
45
|
+
interface QueueEntry {
|
|
46
|
+
resolve: () => void
|
|
47
|
+
rawReject: (error: Error) => void
|
|
48
|
+
settled: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ConcurrencyManager {
|
|
52
|
+
private config?: BackgroundTaskConfig
|
|
53
|
+
private counts: Map<string, number> = new Map()
|
|
54
|
+
private queues: Map<string, QueueEntry[]> = new Map()
|
|
55
|
+
private globalCount = 0
|
|
56
|
+
private globalQueue: QueueEntry[] = []
|
|
57
|
+
|
|
58
|
+
constructor(config?: BackgroundTaskConfig) {
|
|
59
|
+
this.config = config
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getGlobalLimit(): number {
|
|
63
|
+
const limit = this.config?.maxBackgroundAgents
|
|
64
|
+
if (limit === undefined) {
|
|
65
|
+
return Infinity
|
|
66
|
+
}
|
|
67
|
+
return limit
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getConcurrencyLimit(model: string): number {
|
|
71
|
+
const modelLimit = this.config?.modelConcurrency?.[model]
|
|
72
|
+
if (modelLimit !== undefined) {
|
|
73
|
+
return modelLimit === 0 ? Infinity : modelLimit
|
|
74
|
+
}
|
|
75
|
+
const provider = model.split('/')[0]
|
|
76
|
+
const providerLimit = this.config?.providerConcurrency?.[provider]
|
|
77
|
+
if (providerLimit !== undefined) {
|
|
78
|
+
return providerLimit === 0 ? Infinity : providerLimit
|
|
79
|
+
}
|
|
80
|
+
const defaultLimit = this.config?.defaultConcurrency
|
|
81
|
+
if (defaultLimit !== undefined) {
|
|
82
|
+
return defaultLimit === 0 ? Infinity : defaultLimit
|
|
83
|
+
}
|
|
84
|
+
return 5
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async acquire(model: string): Promise<void> {
|
|
88
|
+
const perModelLimit = this.getConcurrencyLimit(model)
|
|
89
|
+
const globalLimit = this.getGlobalLimit()
|
|
90
|
+
|
|
91
|
+
// Fast path: both limits have capacity
|
|
92
|
+
if (perModelLimit === Infinity && globalLimit === Infinity) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const currentPerModel = this.counts.get(model) ?? 0
|
|
97
|
+
|
|
98
|
+
if (currentPerModel < perModelLimit && this.globalCount < globalLimit) {
|
|
99
|
+
this.counts.set(model, currentPerModel + 1)
|
|
100
|
+
this.globalCount++
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return new Promise<void>((resolve, reject) => {
|
|
105
|
+
const entry: QueueEntry = {
|
|
106
|
+
resolve: () => {
|
|
107
|
+
if (entry.settled) return
|
|
108
|
+
entry.settled = true
|
|
109
|
+
resolve()
|
|
110
|
+
},
|
|
111
|
+
rawReject: reject,
|
|
112
|
+
settled: false,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Queue on whichever limit is blocking
|
|
116
|
+
if (currentPerModel >= perModelLimit) {
|
|
117
|
+
const queue = this.queues.get(model) ?? []
|
|
118
|
+
queue.push(entry)
|
|
119
|
+
this.queues.set(model, queue)
|
|
120
|
+
} else {
|
|
121
|
+
this.globalQueue.push(entry)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
release(model: string): void {
|
|
127
|
+
const perModelLimit = this.getConcurrencyLimit(model)
|
|
128
|
+
const globalLimit = this.getGlobalLimit()
|
|
129
|
+
|
|
130
|
+
if (perModelLimit === Infinity && globalLimit === Infinity) {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Try per-model handoff first
|
|
135
|
+
const queue = this.queues.get(model)
|
|
136
|
+
while (queue && queue.length > 0) {
|
|
137
|
+
const next = queue.shift()!
|
|
138
|
+
if (!next.settled) {
|
|
139
|
+
// Hand off the slot to this waiter (counts stay the same)
|
|
140
|
+
next.resolve()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// No per-model handoff - decrement per-model count
|
|
146
|
+
const current = this.counts.get(model) ?? 0
|
|
147
|
+
if (current > 0) {
|
|
148
|
+
this.counts.set(model, current - 1)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Try global handoff
|
|
152
|
+
while (this.globalQueue.length > 0) {
|
|
153
|
+
const next = this.globalQueue.shift()!
|
|
154
|
+
if (!next.settled) {
|
|
155
|
+
// Hand off the global slot - but the waiter still needs a per-model slot
|
|
156
|
+
// Since they were queued on global, their per-model had capacity
|
|
157
|
+
// Re-acquire per-model count for them
|
|
158
|
+
const waiterModel = this.findModelForGlobalWaiter()
|
|
159
|
+
if (waiterModel) {
|
|
160
|
+
const waiterCount = this.counts.get(waiterModel) ?? 0
|
|
161
|
+
this.counts.set(waiterModel, waiterCount + 1)
|
|
162
|
+
}
|
|
163
|
+
next.resolve()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// No handoff occurred - decrement global count
|
|
169
|
+
if (this.globalCount > 0) {
|
|
170
|
+
this.globalCount--
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Cancel all waiting acquires for a model. Used during cleanup.
|
|
176
|
+
*/
|
|
177
|
+
cancelWaiters(model: string): void {
|
|
178
|
+
const queue = this.queues.get(model)
|
|
179
|
+
if (queue) {
|
|
180
|
+
for (const entry of queue) {
|
|
181
|
+
if (!entry.settled) {
|
|
182
|
+
entry.settled = true
|
|
183
|
+
entry.rawReject(new Error(`Concurrency queue cancelled for model: ${model}`))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.queues.delete(model)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Clear all state. Used during manager cleanup/shutdown.
|
|
192
|
+
* Cancels all pending waiters.
|
|
193
|
+
*/
|
|
194
|
+
clear(): void {
|
|
195
|
+
for (const [model] of this.queues) {
|
|
196
|
+
this.cancelWaiters(model)
|
|
197
|
+
}
|
|
198
|
+
// Cancel global queue waiters
|
|
199
|
+
for (const entry of this.globalQueue) {
|
|
200
|
+
if (!entry.settled) {
|
|
201
|
+
entry.settled = true
|
|
202
|
+
entry.rawReject(new Error("Concurrency queue cancelled: manager shutdown"))
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
this.globalQueue = []
|
|
206
|
+
this.globalCount = 0
|
|
207
|
+
this.counts.clear()
|
|
208
|
+
this.queues.clear()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Get current count for a model (for testing/debugging)
|
|
213
|
+
*/
|
|
214
|
+
getCount(model: string): number {
|
|
215
|
+
return this.counts.get(model) ?? 0
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get queue length for a model (for testing/debugging)
|
|
220
|
+
*/
|
|
221
|
+
getQueueLength(model: string): number {
|
|
222
|
+
return this.queues.get(model)?.length ?? 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get current global count across all models (for testing/debugging)
|
|
227
|
+
*/
|
|
228
|
+
getGlobalCount(): number {
|
|
229
|
+
return this.globalCount
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get global queue length (for testing/debugging)
|
|
234
|
+
*/
|
|
235
|
+
getGlobalQueueLength(): number {
|
|
236
|
+
return this.globalQueue.length
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**What changed:**
|
|
242
|
+
- Added `globalCount` field to track total active agents across all keys
|
|
243
|
+
- Added `globalQueue` for tasks waiting on the global limit
|
|
244
|
+
- Added `getGlobalLimit()` method to read `maxBackgroundAgents` from config
|
|
245
|
+
- Modified `acquire()` to check both per-model AND global limits
|
|
246
|
+
- Modified `release()` to handle global queue handoff and decrement global count
|
|
247
|
+
- Modified `clear()` to reset global state
|
|
248
|
+
- Added `getGlobalCount()` and `getGlobalQueueLength()` for testing
|
|
249
|
+
|
|
250
|
+
**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:
|
|
251
|
+
|
|
252
|
+
### Refined approach (simpler, more correct)
|
|
253
|
+
|
|
254
|
+
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:
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
async acquire(model: string): Promise<void> {
|
|
258
|
+
const perModelLimit = this.getConcurrencyLimit(model)
|
|
259
|
+
const globalLimit = this.getGlobalLimit()
|
|
260
|
+
|
|
261
|
+
if (perModelLimit === Infinity && globalLimit === Infinity) {
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const currentPerModel = this.counts.get(model) ?? 0
|
|
266
|
+
|
|
267
|
+
if (currentPerModel < perModelLimit && this.globalCount < globalLimit) {
|
|
268
|
+
this.counts.set(model, currentPerModel + 1)
|
|
269
|
+
if (globalLimit !== Infinity) {
|
|
270
|
+
this.globalCount++
|
|
271
|
+
}
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return new Promise<void>((resolve, reject) => {
|
|
276
|
+
const queue = this.queues.get(model) ?? []
|
|
277
|
+
|
|
278
|
+
const entry: QueueEntry = {
|
|
279
|
+
resolve: () => {
|
|
280
|
+
if (entry.settled) return
|
|
281
|
+
entry.settled = true
|
|
282
|
+
resolve()
|
|
283
|
+
},
|
|
284
|
+
rawReject: reject,
|
|
285
|
+
settled: false,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
queue.push(entry)
|
|
289
|
+
this.queues.set(model, queue)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
release(model: string): void {
|
|
294
|
+
const perModelLimit = this.getConcurrencyLimit(model)
|
|
295
|
+
const globalLimit = this.getGlobalLimit()
|
|
296
|
+
|
|
297
|
+
if (perModelLimit === Infinity && globalLimit === Infinity) {
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Try per-model handoff first (same model queue)
|
|
302
|
+
const queue = this.queues.get(model)
|
|
303
|
+
while (queue && queue.length > 0) {
|
|
304
|
+
const next = queue.shift()!
|
|
305
|
+
if (!next.settled) {
|
|
306
|
+
// Hand off the slot to this waiter (per-model and global counts stay the same)
|
|
307
|
+
next.resolve()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// No per-model handoff - decrement per-model count
|
|
313
|
+
const current = this.counts.get(model) ?? 0
|
|
314
|
+
if (current > 0) {
|
|
315
|
+
this.counts.set(model, current - 1)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Decrement global count
|
|
319
|
+
if (globalLimit !== Infinity && this.globalCount > 0) {
|
|
320
|
+
this.globalCount--
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Try to drain any other model's queue that was blocked by global limit
|
|
324
|
+
if (globalLimit !== Infinity) {
|
|
325
|
+
this.tryDrainGlobalWaiters()
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private tryDrainGlobalWaiters(): void {
|
|
330
|
+
const globalLimit = this.getGlobalLimit()
|
|
331
|
+
if (this.globalCount >= globalLimit) return
|
|
332
|
+
|
|
333
|
+
for (const [model, queue] of this.queues) {
|
|
334
|
+
const perModelLimit = this.getConcurrencyLimit(model)
|
|
335
|
+
const currentPerModel = this.counts.get(model) ?? 0
|
|
336
|
+
|
|
337
|
+
if (currentPerModel >= perModelLimit) continue
|
|
338
|
+
|
|
339
|
+
while (queue.length > 0 && this.globalCount < globalLimit && currentPerModel < perModelLimit) {
|
|
340
|
+
const next = queue.shift()!
|
|
341
|
+
if (!next.settled) {
|
|
342
|
+
this.counts.set(model, (this.counts.get(model) ?? 0) + 1)
|
|
343
|
+
this.globalCount++
|
|
344
|
+
next.resolve()
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
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.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## 3. Schema Test Changes
|
|
357
|
+
|
|
358
|
+
**File:** `src/config/schema/background-task.test.ts`
|
|
359
|
+
|
|
360
|
+
Add after the `syncPollTimeoutMs` describe block:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
describe("maxBackgroundAgents", () => {
|
|
364
|
+
describe("#given valid maxBackgroundAgents (10)", () => {
|
|
365
|
+
test("#when parsed #then returns correct value", () => {
|
|
366
|
+
const result = BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 10 })
|
|
367
|
+
|
|
368
|
+
expect(result.maxBackgroundAgents).toBe(10)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
describe("#given maxBackgroundAgents of 1 (minimum)", () => {
|
|
373
|
+
test("#when parsed #then returns correct value", () => {
|
|
374
|
+
const result = BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 1 })
|
|
375
|
+
|
|
376
|
+
expect(result.maxBackgroundAgents).toBe(1)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
describe("#given maxBackgroundAgents below minimum (0)", () => {
|
|
381
|
+
test("#when parsed #then throws ZodError", () => {
|
|
382
|
+
let thrownError: unknown
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 0 })
|
|
386
|
+
} catch (error) {
|
|
387
|
+
thrownError = error
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
expect(thrownError).toBeInstanceOf(ZodError)
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
describe("#given maxBackgroundAgents is negative (-1)", () => {
|
|
395
|
+
test("#when parsed #then throws ZodError", () => {
|
|
396
|
+
let thrownError: unknown
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: -1 })
|
|
400
|
+
} catch (error) {
|
|
401
|
+
thrownError = error
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
expect(thrownError).toBeInstanceOf(ZodError)
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
describe("#given maxBackgroundAgents is non-integer (2.5)", () => {
|
|
409
|
+
test("#when parsed #then throws ZodError", () => {
|
|
410
|
+
let thrownError: unknown
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
BackgroundTaskConfigSchema.parse({ maxBackgroundAgents: 2.5 })
|
|
414
|
+
} catch (error) {
|
|
415
|
+
thrownError = error
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
expect(thrownError).toBeInstanceOf(ZodError)
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
describe("#given maxBackgroundAgents not provided", () => {
|
|
423
|
+
test("#when parsed #then field is undefined", () => {
|
|
424
|
+
const result = BackgroundTaskConfigSchema.parse({})
|
|
425
|
+
|
|
426
|
+
expect(result.maxBackgroundAgents).toBeUndefined()
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
})
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## 4. ConcurrencyManager Test Changes
|
|
435
|
+
|
|
436
|
+
**File:** `src/features/background-agent/concurrency.test.ts`
|
|
437
|
+
|
|
438
|
+
Add new describe block:
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
describe("ConcurrencyManager.globalLimit (maxBackgroundAgents)", () => {
|
|
442
|
+
test("should return Infinity when maxBackgroundAgents is not set", () => {
|
|
443
|
+
// given
|
|
444
|
+
const manager = new ConcurrencyManager()
|
|
445
|
+
|
|
446
|
+
// when
|
|
447
|
+
const limit = manager.getGlobalLimit()
|
|
448
|
+
|
|
449
|
+
// then
|
|
450
|
+
expect(limit).toBe(Infinity)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
test("should return configured maxBackgroundAgents", () => {
|
|
454
|
+
// given
|
|
455
|
+
const config: BackgroundTaskConfig = { maxBackgroundAgents: 3 }
|
|
456
|
+
const manager = new ConcurrencyManager(config)
|
|
457
|
+
|
|
458
|
+
// when
|
|
459
|
+
const limit = manager.getGlobalLimit()
|
|
460
|
+
|
|
461
|
+
// then
|
|
462
|
+
expect(limit).toBe(3)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test("should enforce global limit across different models", async () => {
|
|
466
|
+
// given
|
|
467
|
+
const config: BackgroundTaskConfig = {
|
|
468
|
+
maxBackgroundAgents: 2,
|
|
469
|
+
defaultConcurrency: 5,
|
|
470
|
+
}
|
|
471
|
+
const manager = new ConcurrencyManager(config)
|
|
472
|
+
await manager.acquire("model-a")
|
|
473
|
+
await manager.acquire("model-b")
|
|
474
|
+
|
|
475
|
+
// when
|
|
476
|
+
let resolved = false
|
|
477
|
+
const waitPromise = manager.acquire("model-c").then(() => { resolved = true })
|
|
478
|
+
await Promise.resolve()
|
|
479
|
+
|
|
480
|
+
// then - should be blocked by global limit even though per-model has capacity
|
|
481
|
+
expect(resolved).toBe(false)
|
|
482
|
+
expect(manager.getGlobalCount()).toBe(2)
|
|
483
|
+
|
|
484
|
+
// cleanup
|
|
485
|
+
manager.release("model-a")
|
|
486
|
+
await waitPromise
|
|
487
|
+
expect(resolved).toBe(true)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
test("should allow tasks when global limit not reached", async () => {
|
|
491
|
+
// given
|
|
492
|
+
const config: BackgroundTaskConfig = {
|
|
493
|
+
maxBackgroundAgents: 3,
|
|
494
|
+
defaultConcurrency: 5,
|
|
495
|
+
}
|
|
496
|
+
const manager = new ConcurrencyManager(config)
|
|
497
|
+
|
|
498
|
+
// when
|
|
499
|
+
await manager.acquire("model-a")
|
|
500
|
+
await manager.acquire("model-b")
|
|
501
|
+
await manager.acquire("model-c")
|
|
502
|
+
|
|
503
|
+
// then
|
|
504
|
+
expect(manager.getGlobalCount()).toBe(3)
|
|
505
|
+
expect(manager.getCount("model-a")).toBe(1)
|
|
506
|
+
expect(manager.getCount("model-b")).toBe(1)
|
|
507
|
+
expect(manager.getCount("model-c")).toBe(1)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test("should respect both per-model and global limits", async () => {
|
|
511
|
+
// given - per-model limit of 1, global limit of 3
|
|
512
|
+
const config: BackgroundTaskConfig = {
|
|
513
|
+
maxBackgroundAgents: 3,
|
|
514
|
+
defaultConcurrency: 1,
|
|
515
|
+
}
|
|
516
|
+
const manager = new ConcurrencyManager(config)
|
|
517
|
+
await manager.acquire("model-a")
|
|
518
|
+
|
|
519
|
+
// when - try second acquire on same model
|
|
520
|
+
let resolved = false
|
|
521
|
+
const waitPromise = manager.acquire("model-a").then(() => { resolved = true })
|
|
522
|
+
await Promise.resolve()
|
|
523
|
+
|
|
524
|
+
// then - blocked by per-model limit, not global
|
|
525
|
+
expect(resolved).toBe(false)
|
|
526
|
+
expect(manager.getGlobalCount()).toBe(1)
|
|
527
|
+
|
|
528
|
+
// cleanup
|
|
529
|
+
manager.release("model-a")
|
|
530
|
+
await waitPromise
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
test("should release global slot and unblock waiting tasks", async () => {
|
|
534
|
+
// given
|
|
535
|
+
const config: BackgroundTaskConfig = {
|
|
536
|
+
maxBackgroundAgents: 1,
|
|
537
|
+
defaultConcurrency: 5,
|
|
538
|
+
}
|
|
539
|
+
const manager = new ConcurrencyManager(config)
|
|
540
|
+
await manager.acquire("model-a")
|
|
541
|
+
|
|
542
|
+
// when
|
|
543
|
+
let resolved = false
|
|
544
|
+
const waitPromise = manager.acquire("model-b").then(() => { resolved = true })
|
|
545
|
+
await Promise.resolve()
|
|
546
|
+
expect(resolved).toBe(false)
|
|
547
|
+
|
|
548
|
+
manager.release("model-a")
|
|
549
|
+
await waitPromise
|
|
550
|
+
|
|
551
|
+
// then
|
|
552
|
+
expect(resolved).toBe(true)
|
|
553
|
+
expect(manager.getGlobalCount()).toBe(1)
|
|
554
|
+
expect(manager.getCount("model-a")).toBe(0)
|
|
555
|
+
expect(manager.getCount("model-b")).toBe(1)
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
test("should not enforce global limit when not configured", async () => {
|
|
559
|
+
// given - no maxBackgroundAgents set
|
|
560
|
+
const config: BackgroundTaskConfig = { defaultConcurrency: 5 }
|
|
561
|
+
const manager = new ConcurrencyManager(config)
|
|
562
|
+
|
|
563
|
+
// when - acquire many across different models
|
|
564
|
+
await manager.acquire("model-a")
|
|
565
|
+
await manager.acquire("model-b")
|
|
566
|
+
await manager.acquire("model-c")
|
|
567
|
+
await manager.acquire("model-d")
|
|
568
|
+
await manager.acquire("model-e")
|
|
569
|
+
await manager.acquire("model-f")
|
|
570
|
+
|
|
571
|
+
// then - all should succeed (no global limit)
|
|
572
|
+
expect(manager.getCount("model-a")).toBe(1)
|
|
573
|
+
expect(manager.getCount("model-f")).toBe(1)
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test("should reset global count on clear", async () => {
|
|
577
|
+
// given
|
|
578
|
+
const config: BackgroundTaskConfig = { maxBackgroundAgents: 5 }
|
|
579
|
+
const manager = new ConcurrencyManager(config)
|
|
580
|
+
await manager.acquire("model-a")
|
|
581
|
+
await manager.acquire("model-b")
|
|
582
|
+
|
|
583
|
+
// when
|
|
584
|
+
manager.clear()
|
|
585
|
+
|
|
586
|
+
// then
|
|
587
|
+
expect(manager.getGlobalCount()).toBe(0)
|
|
588
|
+
})
|
|
589
|
+
})
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
## Config Usage Example
|
|
595
|
+
|
|
596
|
+
User's `.opencode/oh-my-opencode.jsonc`:
|
|
597
|
+
|
|
598
|
+
```jsonc
|
|
599
|
+
{
|
|
600
|
+
"background_task": {
|
|
601
|
+
// Global limit: max 5 background agents total
|
|
602
|
+
"maxBackgroundAgents": 5,
|
|
603
|
+
// Per-model limits still apply independently
|
|
604
|
+
"defaultConcurrency": 3,
|
|
605
|
+
"providerConcurrency": {
|
|
606
|
+
"anthropic": 2
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
With this config:
|
|
613
|
+
- Max 5 background agents running simultaneously across all models
|
|
614
|
+
- Max 3 per model (default), max 2 for any Anthropic model
|
|
615
|
+
- If 2 Anthropic + 3 OpenAI agents are running (5 total), no more can start regardless of per-model capacity
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Execution Plan: Add `max_background_agents` Config Option
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Step-by-Step Plan
|
|
8
|
+
|
|
9
|
+
### Step 1: Create feature branch
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
git checkout -b feat/max-background-agents dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Step 2: Add `max_background_agents` to BackgroundTaskConfigSchema
|
|
16
|
+
|
|
17
|
+
**File:** `src/config/schema/background-task.ts`
|
|
18
|
+
|
|
19
|
+
- Add `maxBackgroundAgents` field to the Zod schema with `z.number().int().min(1).optional()`
|
|
20
|
+
- This follows the existing pattern of `maxDepth` and `maxDescendants` (integer, min 1, optional)
|
|
21
|
+
- The field name uses camelCase to match existing schema fields (`defaultConcurrency`, `maxDepth`, `maxDescendants`)
|
|
22
|
+
- No `.default()` needed since the hardcoded fallback of 5 lives in `ConcurrencyManager`
|
|
23
|
+
|
|
24
|
+
### Step 3: Modify `ConcurrencyManager` to enforce global limit
|
|
25
|
+
|
|
26
|
+
**File:** `src/features/background-agent/concurrency.ts`
|
|
27
|
+
|
|
28
|
+
- Add a `globalCount` field tracking total active agents across all keys
|
|
29
|
+
- Modify `acquire()` to check global count against `maxBackgroundAgents` before granting a slot
|
|
30
|
+
- Modify `release()` to decrement global count
|
|
31
|
+
- Modify `clear()` to reset global count
|
|
32
|
+
- Add `getGlobalCount()` for testing/debugging (follows existing `getCount()`/`getQueueLength()` pattern)
|
|
33
|
+
|
|
34
|
+
The global limit check happens **in addition to** the per-model limit. Both must have capacity for a task to proceed.
|
|
35
|
+
|
|
36
|
+
### Step 4: Add tests for the new config schema field
|
|
37
|
+
|
|
38
|
+
**File:** `src/config/schema/background-task.test.ts`
|
|
39
|
+
|
|
40
|
+
- Add test cases following the existing given/when/then pattern with nested describes
|
|
41
|
+
- Test valid value, below-minimum value, undefined (not provided), non-number type
|
|
42
|
+
|
|
43
|
+
### Step 5: Add tests for ConcurrencyManager global limit
|
|
44
|
+
|
|
45
|
+
**File:** `src/features/background-agent/concurrency.test.ts`
|
|
46
|
+
|
|
47
|
+
- Test that global limit is enforced across different model keys
|
|
48
|
+
- Test that tasks queue when global limit reached even if per-model limit has capacity
|
|
49
|
+
- Test that releasing a slot from one model allows a queued task from another model to proceed
|
|
50
|
+
- Test default behavior (5) when no config provided
|
|
51
|
+
- Test interaction between global and per-model limits
|
|
52
|
+
|
|
53
|
+
### Step 6: Run typecheck and tests
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
bun run typecheck
|
|
57
|
+
bun test src/config/schema/background-task.test.ts
|
|
58
|
+
bun test src/features/background-agent/concurrency.test.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Step 7: Verify LSP diagnostics clean
|
|
62
|
+
|
|
63
|
+
Check `src/config/schema/background-task.ts` and `src/features/background-agent/concurrency.ts` for errors.
|
|
64
|
+
|
|
65
|
+
### Step 8: Create PR
|
|
66
|
+
|
|
67
|
+
- Push branch to remote
|
|
68
|
+
- Create PR with structured description via `gh pr create`
|
|
69
|
+
|
|
70
|
+
## Files Modified (4 files)
|
|
71
|
+
|
|
72
|
+
| File | Change |
|
|
73
|
+
|------|--------|
|
|
74
|
+
| `src/config/schema/background-task.ts` | Add `maxBackgroundAgents` field |
|
|
75
|
+
| `src/features/background-agent/concurrency.ts` | Add global count tracking + enforcement |
|
|
76
|
+
| `src/config/schema/background-task.test.ts` | Add schema validation tests |
|
|
77
|
+
| `src/features/background-agent/concurrency.test.ts` | Add global limit enforcement tests |
|
|
78
|
+
|
|
79
|
+
## Files NOT Modified (intentional)
|
|
80
|
+
|
|
81
|
+
| File | Reason |
|
|
82
|
+
|------|--------|
|
|
83
|
+
| `src/config/schema/oh-my-opencode-config.ts` | No change needed - `BackgroundTaskConfigSchema` is already composed into root schema via `background_task` field |
|
|
84
|
+
| `src/create-managers.ts` | No change needed - `pluginConfig.background_task` already passed to `BackgroundManager` constructor |
|
|
85
|
+
| `src/features/background-agent/manager.ts` | No change needed - already passes config to `ConcurrencyManager` |
|
|
86
|
+
| `src/plugin-config.ts` | No change needed - `background_task` is a simple object field, uses default override merge |
|
|
87
|
+
| `src/config/schema.ts` | No change needed - barrel already exports `BackgroundTaskConfigSchema` |
|
|
88
|
+
|
|
89
|
+
## Design Decisions
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
5. **0 means Infinity** - Following the existing pattern where `defaultConcurrency: 0` means unlimited, `maxBackgroundAgents: 0` would also mean no global limit.
|