From a53fe9a03ea9ffde90c32d3fee1141fc4d7aaeb9 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:12:33 +0800 Subject: [PATCH 1/3] feat(subagents): add profile-aware parallel fleets Problem: Custom subagent profiles could only be run serially through skill execution, making multi-document workloads unnecessarily slow and leaving concurrent writers without one session-wide safety boundary. Root cause: Task execution had no runtime profile selector or shared fleet scheduler, and write coordination was split across entry points without atomic parent reservations or enforceable path claims. Fix: - add profile-aware task and fleet execution with session-wide total/writer concurrency limits - normalize and enforce write path claims, including sandboxed Bash roots and parent write reservations - make background tasks queue asynchronously and keep parallel_tasks on the shared scheduler - expose concurrency and read-only profile controls in Desktop and document the contracts Verification: - go test ./... - cd desktop && go test ./... - go test -race ./internal/agent ./internal/boot ./internal/config ./internal/skill - cd desktop/frontend && pnpm typecheck && pnpm test:typecheck && pnpm test - ./scripts/cache-guard.sh --- desktop/app.go | 6 +- .../settings-refresh-snapshot.test.tsx | 2 +- .../frontend/src/components/SettingsPanel.tsx | 42 +- .../src/components/SubagentsPanel.tsx | 23 + desktop/frontend/src/lib/bridge.ts | 16 +- desktop/frontend/src/lib/types.ts | 4 + desktop/frontend/src/locales/en.ts | 8 + desktop/frontend/src/locales/zh-TW.ts | 8 + desktop/frontend/src/locales/zh.ts | 8 + desktop/settings_app.go | 69 ++- desktop/subagents_app.go | 5 + desktop/subagents_app_test.go | 49 ++- docs/GUIDE.md | 3 + docs/GUIDE.zh-CN.md | 3 + docs/SPEC.md | 17 +- docs/SUBAGENT_PROFILES.md | 35 +- docs/SUBAGENT_PROFILES.zh-CN.md | 31 +- docs/TOOL_CONTRACT.md | 2 +- docs/TOOL_CONTRACT.zh-CN.md | 2 +- internal/agent/agent.go | 74 +++- internal/agent/fleet.go | 353 ++++++++++++++++ internal/agent/fleet_test.go | 124 ++++++ internal/agent/parallel_tasks.go | 19 + internal/agent/path_bound_tools.go | 211 ++++++++++ internal/agent/path_bound_tools_test.go | 295 +++++++++++++ internal/agent/profile_spec.go | 184 ++++++++ internal/agent/profile_spec_test.go | 77 ++++ internal/agent/scheduler.go | 309 ++++++++++++++ internal/agent/scheduler_test.go | 115 +++++ internal/agent/subagent_registry_test.go | 7 +- internal/agent/task.go | 397 +++++++++++++++--- internal/agent/task_background_queue_test.go | 85 ++++ internal/agent/task_profile_test.go | 129 ++++++ internal/agent/write_claims.go | 270 ++++++++++++ internal/agent/write_claims_test.go | 123 ++++++ internal/boot/boot.go | 101 ++++- internal/boot/boot_test.go | 5 +- internal/cli/subagent.go | 7 +- internal/cli/subagent_test.go | 25 +- internal/config/config.go | 26 +- internal/config/render.go | 10 + internal/doctor/skill_health.go | 1 + internal/skill/profile.go | 2 +- internal/skill/tools.go | 15 +- internal/tool/builtin/confine.go | 23 + reasonix.example.toml | 2 + 46 files changed, 3191 insertions(+), 131 deletions(-) create mode 100644 internal/agent/fleet.go create mode 100644 internal/agent/fleet_test.go create mode 100644 internal/agent/path_bound_tools.go create mode 100644 internal/agent/path_bound_tools_test.go create mode 100644 internal/agent/profile_spec.go create mode 100644 internal/agent/profile_spec_test.go create mode 100644 internal/agent/scheduler.go create mode 100644 internal/agent/scheduler_test.go create mode 100644 internal/agent/task_background_queue_test.go create mode 100644 internal/agent/task_profile_test.go create mode 100644 internal/agent/write_claims.go create mode 100644 internal/agent/write_claims_test.go diff --git a/desktop/app.go b/desktop/app.go index cd0101a1be..f4d2d34b60 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -6076,7 +6076,10 @@ type SkillView struct { Model string `json:"model,omitempty"` Effort string `json:"effort,omitempty"` AllowedTools []string `json:"allowedTools,omitempty"` - Color string `json:"color,omitempty"` + // ReadOnly mirrors frontmatter read-only; omitted/false keeps the legacy + // writable default for older profiles. + ReadOnly bool `json:"readOnly,omitempty"` + Color string `json:"color,omitempty"` // Invocation is the user-facing slash name; InvocationMode preserves the // frontmatter policy used by the subagent profile editor. Invocation string `json:"invocation,omitempty"` @@ -6456,6 +6459,7 @@ func (a *App) SkillsSettings() SkillsSettingsView { Model: s.Model, Effort: s.Effort, AllowedTools: append([]string{}, s.AllowedTools...), + ReadOnly: s.ReadOnly, Color: s.Color, Invocation: "/" + s.SlashName(), InvocationMode: s.Invocation, diff --git a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx index 52b46fb930..9d1372dd95 100644 --- a/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx +++ b/desktop/frontend/src/__tests__/settings-refresh-snapshot.test.tsx @@ -78,7 +78,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting permissions: { mode: "ask", allow: [], ask: [], deny: [] }, sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" }, network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } }, - agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" }, + agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" }, bot: { enabled: false, model: "", diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index 7a9e820452..d2f13401ff 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -1246,7 +1246,7 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" }, }; - const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" }; + const agent = view.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" }; agent.plannerMaxSteps = Number.isFinite(agent.plannerMaxSteps) ? Math.max(0, Math.trunc(agent.plannerMaxSteps)) : 0; agent.maxSteps = Number.isFinite(agent.maxSteps) ? Math.max(0, Math.trunc(agent.maxSteps)) : 0; agent.maxSubagentDepth = Number.isFinite(agent.maxSubagentDepth) && agent.maxSubagentDepth <= 1 ? 1 : 2; @@ -3950,8 +3950,14 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps) : !providerIsConfigured(defaultProviderView) ? t("settings.modelNeedsKey", { provider: modelProviderLabel(defaultProvider, defaultProviderView, t) }) : ""; - const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" }; + const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" }; const subagentDepth = Number.isFinite(agent.maxSubagentDepth) && agent.maxSubagentDepth <= 1 ? 1 : 2; + const subagentConcurrency = Number.isFinite(agent.maxSubagentConcurrency) && agent.maxSubagentConcurrency > 0 + ? Math.max(1, Math.min(32, Math.floor(agent.maxSubagentConcurrency))) + : 6; + const parallelWriters = Number.isFinite(agent.maxParallelWriters) && agent.maxParallelWriters > 0 + ? Math.max(1, Math.min(subagentConcurrency, Math.floor(agent.maxParallelWriters))) + : Math.min(3, subagentConcurrency); useEffect(() => { if (subtab !== "usage") return; @@ -4077,6 +4083,38 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps) + + { + const n = Number(e.target.value); + if (!Number.isFinite(n)) return; + void apply(() => app.SetMaxSubagentConcurrency(n)); + }} + /> + + + + { + const n = Number(e.target.value); + if (!Number.isFinite(n)) return; + void apply(() => app.SetMaxParallelWriters(n)); + }} + /> + + {modelIssue &&
{modelIssue}
} diff --git a/desktop/frontend/src/components/SubagentsPanel.tsx b/desktop/frontend/src/components/SubagentsPanel.tsx index ae69b275e2..d743b68bbd 100644 --- a/desktop/frontend/src/components/SubagentsPanel.tsx +++ b/desktop/frontend/src/components/SubagentsPanel.tsx @@ -587,6 +587,7 @@ function SubagentProfileForm({ const [selectedTools, setSelectedTools] = useState>(() => new Set(editingSkill?.allowedTools ?? [])); const hasUsedCustomMode = useRef(Boolean(editingSkill?.allowedTools?.length)); const [systemPrompt, setSystemPrompt] = useState(editingSkill?.body ?? ""); + const [readOnly, setReadOnly] = useState(Boolean(editingSkill?.readOnly)); const [scope, setScope] = useState<"global" | "project">(editingSkill?.scope === "project" ? "project" : "global"); const [tryTask, setTryTask] = useState(""); const [tryRunning, setTryRunning] = useState(false); @@ -614,6 +615,7 @@ function SubagentProfileForm({ model, effort, allowedTools: toolMode === "custom" ? Array.from(selectedTools) : [], + readOnly, scope, }); @@ -706,6 +708,27 @@ function SubagentProfileForm({ {toolMode === "custom" && } {toolMode === "custom" && !toolsReady &&
{t("subagents.selectAtLeastOneTool")}
} + +
+ + +
+
{t("subagents.readOnlyHint")}
+