From ced78801ba9e82d8df90b6ce84e85d5e301c35ab Mon Sep 17 00:00:00 2001 From: z80 Date: Mon, 17 Aug 2026 20:43:03 -0400 Subject: [PATCH] fix(task): refresh model roles before agent discovery --- docs/task-agent-discovery.md | 13 ++-- packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/src/config/settings.ts | 63 +++++++++++++++++++ .../src/task/structured-subagent.ts | 1 + .../test/task/structured-subagent.test.ts | 29 +++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/docs/task-agent-discovery.md b/docs/task-agent-discovery.md index 05cff512d..3cf5bf9c6 100644 --- a/docs/task-agent-discovery.md +++ b/docs/task-agent-discovery.md @@ -71,7 +71,7 @@ modelRoles: review: openai/gpt-5.4:high ``` -`@review` resolves through `modelRoles.review`. Each `modelRoles.` value stores a concrete model selector and may append a thinking suffix such as `:high` (`src/config/model-resolver.ts`). Changing that mapping affects subsequent task resolutions without editing agent definitions. +`@review` resolves through `modelRoles.review`. Each `modelRoles.` value stores a concrete model selector and may append a thinking suffix such as `:high` (`src/config/model-resolver.ts`). Changing that mapping affects subsequent task resolutions without editing agent definitions. Task/eval preflight reloads the current global, project, and explicit overlay settings before rediscovering agents, so agent files and their role aliases added during a live session resolve from one refreshed configuration state. For a dispatch, set the agent name and task: @@ -185,11 +185,12 @@ Lookup is exact-name linear search: `resolveEffectiveSubagentPolicy()` is shared by task and eval-backed subagent launches. Before allocating artifacts it: -1. resolves the omitted or explicit agent name from the parent spawn policy -2. enforces depth, blocked-self-recursion, and parent spawn-policy guards -3. rediscovers agents with `discoverAgents(session.cwd)` and performs exact lookup -4. checks `task.disabledAgents` -5. resolves plan-mode restrictions, output schema, model policy, and isolation policy +1. atomically reloads the live session's persisted global, project, and explicit overlay settings while preserving runtime overrides +2. resolves the omitted or explicit agent name from the parent spawn policy +3. enforces depth, blocked-self-recursion, and parent spawn-policy guards +4. rediscovers agents with `discoverAgents(session.cwd)` and performs exact lookup +5. checks `task.disabledAgents` +6. resolves plan-mode restrictions, output schema, model policy, and isolation policy A missing name fails preflight with `Unknown agent "...". Available: ...`; no subprocess runs. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2f7b500ff..09a3fe65d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -89,6 +89,9 @@ - Fixed `hub jobs` and empty `hub wait` snapshots hiding running subagents that have no live turn, which removed the only way to discover and `hub cancel` a stale registration; such agents are listed again and flagged as having no turn in flight. - Fixed external thinking being offered on xAI reasoning-only Responses models (grok-4 family) that reject `reasoning.effort`, where the private scratchpad ran alongside native reasoning instead of replacing it. - Fixed the extension tool-call handler timeout rendering outside a titled section in `/settings` by registering its Extensions group on the Tools tab. +### Fixed + +- Fixed task and eval subagents discovering newly added agent definitions while resolving their role aliases from stale startup settings. Subagent preflight now atomically reloads persisted settings before agent discovery while preserving live runtime overrides. ## [17.3.4] - 2026-08-14 diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index f4d55e10c..f61d030f3 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -370,6 +370,8 @@ export class Settings { #savePromise?: Promise; #projectSaveTimer?: NodeJS.Timeout; #projectSavePromise?: Promise; + /** Coalesces concurrent persisted-layer refreshes into one atomic reload. */ + #reloadFromDiskPromise?: Promise; /** Whether to persist changes */ #persist: boolean; @@ -624,6 +626,67 @@ export class Settings { return cloned; } + /** + * Re-read the current global, project, and explicit overlay layers from disk + * without replacing this instance or discarding runtime overrides. + * + * All sources are loaded before any live layer is replaced, so readers never + * observe a partially refreshed configuration. Concurrent callers share the + * same reload. + */ + async reloadFromDisk(): Promise { + if (!this.#persist) return; + if (this.#reloadFromDiskPromise) return this.#reloadFromDiskPromise; + + const reload = this.#reloadPersistedLayers(); + this.#reloadFromDiskPromise = reload; + try { + await reload; + } finally { + if (this.#reloadFromDiskPromise === reload) { + this.#reloadFromDiskPromise = undefined; + } + } + } + + async #reloadPersistedLayers(): Promise { + await this.flush(); + const previousSignaledValues = { + modelRoles: this.get("modelRoles"), + sessionAccent: this.get("statusLine.sessionAccent"), + }; + const previousHookValues = new Map(); + for (const key of Object.keys(SETTING_HOOKS) as SettingPath[]) { + previousHookValues.set(key, this.get(key)); + } + + const [globalResult, projectResult, overlayResult] = await Promise.allSettled([ + this.#loadExistingMainYaml(), + this.#loadProjectSettings(), + this.#loadConfigOverlays(), + ]); + if (globalResult.status === "rejected") throw globalResult.reason; + if (projectResult.status === "rejected") throw projectResult.reason; + if (overlayResult.status === "rejected") throw overlayResult.reason; + + this.#global = globalResult.value ?? {}; + this.#project = projectResult.value; + this.#configOverlay = overlayResult.value; + this.#rebuildMerged(); + this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), previousSignaledValues.modelRoles); + this.#fireEffectiveSettingChanged( + "statusLine.sessionAccent", + this.get("statusLine.sessionAccent"), + previousSignaledValues.sessionAccent, + ); + for (const [key, previous] of previousHookValues) { + const next = this.get(key); + if (!Object.is(next, previous)) { + SETTING_HOOKS[key]?.(next, previous); + } + } + } + /** * Re-scope this instance to a new working directory *in place*: reload the * project layer (`.claude/settings.yml` etc.) from `cwd`, re-resolve diff --git a/packages/coding-agent/src/task/structured-subagent.ts b/packages/coding-agent/src/task/structured-subagent.ts index 8667fee56..e2fba2a94 100644 --- a/packages/coding-agent/src/task/structured-subagent.ts +++ b/packages/coding-agent/src/task/structured-subagent.ts @@ -245,6 +245,7 @@ function assertDepthAndSpawnAllowed(request: StructuredSubagentRequest, agentNam export async function resolveEffectiveSubagentPolicy( request: StructuredSubagentRequest, ): Promise { + await request.session.settings.reloadFromDisk(); const spawnPolicy = resolveSpawnPolicy(request.session.getSessionSpawns()); const agentName = request.agent?.trim() || spawnPolicy.defaultAgent; const planMode = request.session.getPlanModeState?.()?.enabled === true; diff --git a/packages/coding-agent/test/task/structured-subagent.test.ts b/packages/coding-agent/test/task/structured-subagent.test.ts index a24facde7..afb8c7612 100644 --- a/packages/coding-agent/test/task/structured-subagent.test.ts +++ b/packages/coding-agent/test/task/structured-subagent.test.ts @@ -167,6 +167,35 @@ describe("structured subagent primitive", () => { ).rejects.toThrow("isolation, apply, and merge controls are unavailable in plan mode"); expect(discover).not.toHaveBeenCalled(); }); + it("reloads model roles before resolving an agent added during the session", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-task-hot-reload-")); + const projectDir = path.join(root, "project"); + const agentDir = path.join(root, "agent"); + await fs.mkdir(projectDir, { recursive: true }); + const liveSettings = await Settings.loadIsolated({ cwd: projectDir, agentDir }); + const liveSession = { + ...session(), + cwd: projectDir, + settings: liveSettings, + } as ToolSession; + + try { + await Bun.write(path.join(projectDir, ".omp", "config.yml"), "modelRoles:\n hot_worker: kimi-code/k3:max\n"); + await Bun.write( + path.join(projectDir, ".omp", "agents", "hot-worker.md"), + '---\nname: hot-worker\ndescription: Newly added worker.\nmodel: "@hot_worker"\n---\n\nInspect the assignment.\n', + ); + + const policy = await resolveEffectiveSubagentPolicy(request({ session: liveSession, agent: "hot-worker" })); + + expect(policy.modelRole).toBe("hot_worker"); + expect(policy.modelOverride).toEqual(["kimi-code/k3:max"]); + } finally { + liveSettings.cancelPendingSaves(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("propagates a custom thinking-suffixed role alias through policy, dispatch, and settlement", async () => { const customAgent = { ...AGENT, model: ["@reviewer:high"] }; mockDiscovery(customAgent);