diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index e78f251dc8b..7083c61e179 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -35,12 +35,12 @@ function mockEmbeddedOk() { } /** - * Extract the provider and model from the last runEmbeddedPiAgent call. + * Extract select fields from the last runEmbeddedPiAgent call. */ -function lastEmbeddedCall(): { provider?: string; model?: string } { +function lastEmbeddedCall(): { provider?: string; model?: string; lane?: string } { const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; expect(calls.length).toBeGreaterThan(0); - return calls.at(-1)?.[0] as { provider?: string; model?: string }; + return calls.at(-1)?.[0] as { provider?: string; model?: string; lane?: string }; } const DEFAULT_MESSAGE = "do it"; @@ -106,6 +106,14 @@ describe("cron model formatting and precedence edge cases", () => { // ------ provider/model string splitting ------ describe("parseModelRef formatting", () => { + it("moves nested embedded runs off the cron lane to avoid self-deadlock", async () => { + await withTempHome(async (home) => { + const { res, call } = await runTurn(home); + expect(res.status).toBe("ok"); + expect(call.lane).toBe("nested"); + }); + }); + it("splits standard provider/model", async () => { await withTempHome(async (home) => { const { res, call } = await runTurn(home, { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 0666b752e5c..bea43d5e2d1 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -46,6 +46,7 @@ import { import type { AgentDefaultsConfig } from "../../config/types.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { logWarn } from "../../logger.js"; +import { CommandLane } from "../../process/lanes.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { buildSafeExternalPrompt, @@ -197,6 +198,17 @@ function appendCronDeliveryInstruction(params: { return `${params.commandBody}\n\nReturn your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); } +function resolveCronEmbeddedAgentLane(lane?: string) { + const trimmed = lane?.trim(); + // Cron jobs already execute inside the cron command lane. Reusing that same + // lane for the nested embedded-agent run deadlocks: the outer cron task holds + // the lane while the inner run waits to reacquire it. + if (!trimmed || trimmed === "cron") { + return CommandLane.Nested; + } + return trimmed; +} + export async function runCronIsolatedAgentTurn(params: { cfg: OpenClawConfig; deps: CliDeps; @@ -610,7 +622,7 @@ export async function runCronIsolatedAgentTurn(params: { config: cfgWithAgentDefaults, skillsSnapshot, prompt: promptText, - lane: params.lane ?? "cron", + lane: resolveCronEmbeddedAgentLane(params.lane), provider: providerOverride, model: modelOverride, authProfileId,