diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index cd02eea2b..36e487017 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -389,8 +389,9 @@ ade chat create --lane lane-id --provider codex --no-parent # spawned chats de ade chat read session-id --limit 20 --text ade chat message session-id --kind auto --text "status/context" ade chat steer session-id --text "active-turn context" -ade chat schedules session-id --pause # pause this chat's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm) +ade chat schedules session-id --pause # pause this agent session's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm) ade chat scheduled-work list [session-id] --all # list durable jobs; --all includes recent terminal history +ade chat scheduled-work create --cron "9,29,49 * * * *" --prompt "Check CI and report" --once --reason "CI check" --session session-id # chats and tracked provider CLIs; omit --session inside the bound agent ade chat scheduled-work cancel session-id job-id # cancel one job; Claude-native jobs request CronDelete in the owning chat ade chat wait session-id --for idle --timeout-ms 600000 ade chat recover session-id --turn turn-id --action nudge # wait | nudge | retry | resume diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 10e198bd4..14ca2a6d4 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -489,6 +489,21 @@ function createRuntime() { lastActivityAt: "2026-03-17T19:00:00.000Z", createdAt: "2026-03-17T19:00:00.000Z", })), + createScheduledWork: vi.fn(async ({ sessionId, cron, prompt, recurring = true }: { + sessionId: string; + cron: string; + prompt: string; + recurring?: boolean; + }) => ({ + item: { + id: `action:${sessionId}:schedule-1`, + sessionId, + cron, + prompt, + kind: recurring ? "cron" : "wakeup", + status: "scheduled", + }, + })), listScheduledWork: vi.fn(async ({ sessionId, includeTerminal }: { sessionId?: string; includeTerminal?: boolean; @@ -498,6 +513,12 @@ function createRuntime() { kind: "cron", status: includeTerminal ? "cancelled" : "scheduled", }]), + getScheduledWorkState: vi.fn(async ({ sessionId }: { sessionId: string }) => ({ + sessionId, + paused: false, + nextWakeAt: "2026-03-17T20:00:00.000Z", + items: [], + })), cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: { sessionId: string; scheduleId: string; @@ -506,6 +527,10 @@ function createRuntime() { providerCancellationRequested: false, providerCancellationConfirmed: true, })), + setScheduledWorkPaused: vi.fn(async ({ sessionId, paused }: { + sessionId: string; + paused: boolean; + }) => ({ sessionId, paused, nextWakeAt: null })), getChatTranscript: vi.fn(async ({ sessionId }: { sessionId: string }) => ({ sessionId, entries: [{ role: "assistant", text: "hello", timestamp: "2026-03-17T19:00:00.000Z" }], @@ -2811,6 +2836,16 @@ describe("adeRpcServer", () => { expect(allScheduledWork?.isError).toBeUndefined(); expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({}); + const scheduledWorkState = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getScheduledWorkState", + args: { sessionId: " chat-1 " }, + }); + expect(scheduledWorkState?.isError).toBeUndefined(); + expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledWith({ + sessionId: "chat-1", + }); + const cancelledWork = await callTool(handler, "run_ade_action", { domain: "chat", action: "cancelScheduledWork", @@ -3194,6 +3229,63 @@ describe("adeRpcServer", () => { text: "own-chat write", }); + const ownScheduledWorkCreate = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "createScheduledWork", + args: { cron: "0 * * * *", prompt: "Check CI." }, + }); + expect(ownScheduledWorkCreate?.isError).toBeUndefined(); + expect(fixture.runtime.agentChatService.createScheduledWork).toHaveBeenCalledWith({ + sessionId: "chat-1", + cron: "0 * * * *", + prompt: "Check CI.", + }); + + const deniedScheduledWorkCreate = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "createScheduledWork", + args: { sessionId: "chat-2", cron: "0 * * * *", prompt: "Cross-chat schedule." }, + }); + expect(deniedScheduledWorkCreate.isError).toBe(true); + expect(fixture.runtime.agentChatService.createScheduledWork).toHaveBeenCalledTimes(1); + + const ownScheduledWorkState = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getScheduledWorkState", + args: {}, + }); + expect(ownScheduledWorkState?.isError).toBeUndefined(); + expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledWith({ + sessionId: "chat-1", + }); + + const deniedScheduledWorkState = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getScheduledWorkState", + args: { sessionId: "chat-2" }, + }); + expect(deniedScheduledWorkState.isError).toBe(true); + expect(fixture.runtime.agentChatService.getScheduledWorkState).toHaveBeenCalledTimes(1); + + const ownScheduledWorkPause = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "setScheduledWorkPaused", + args: { paused: true }, + }); + expect(ownScheduledWorkPause?.isError).toBeUndefined(); + expect(fixture.runtime.agentChatService.setScheduledWorkPaused).toHaveBeenCalledWith({ + sessionId: "chat-1", + paused: true, + }); + + const deniedScheduledWorkPause = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "setScheduledWorkPaused", + args: { sessionId: "chat-2", paused: true }, + }); + expect(deniedScheduledWorkPause.isError).toBe(true); + expect(fixture.runtime.agentChatService.setScheduledWorkPaused).toHaveBeenCalledTimes(1); + const deniedScheduledWorkList = await callTool(handler, "run_ade_action", { domain: "chat", action: "listScheduledWork", @@ -3825,6 +3917,30 @@ describe("adeRpcServer", () => { expect(scheduledWorkList.isError).toBe(true); expect(fixture.runtime.agentChatService.listScheduledWork).not.toHaveBeenCalled(); + const scheduledWorkCreate = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "createScheduledWork", + args: { sessionId: "chat-2", cron: "0 * * * *", prompt: "Check CI." }, + }); + expect(scheduledWorkCreate.isError).toBe(true); + expect(fixture.runtime.agentChatService.createScheduledWork).not.toHaveBeenCalled(); + + const scheduledWorkState = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getScheduledWorkState", + args: { sessionId: "chat-2" }, + }); + expect(scheduledWorkState.isError).toBe(true); + expect(fixture.runtime.agentChatService.getScheduledWorkState).not.toHaveBeenCalled(); + + const scheduledWorkPause = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "setScheduledWorkPaused", + args: { sessionId: "chat-2", paused: true }, + }); + expect(scheduledWorkPause.isError).toBe(true); + expect(fixture.runtime.agentChatService.setScheduledWorkPaused).not.toHaveBeenCalled(); + const scheduledWorkCancel = await callTool(handler, "run_ade_action", { domain: "chat", action: "cancelScheduledWork", diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 03414b452..dcd468ff2 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2382,18 +2382,23 @@ function scopeTerminalAdeActionArgs( } } +const SCOPED_CHAT_ACTIONS = new Set([ + "readTranscript", + "sendMessage", + "createScheduledWork", + "listScheduledWork", + "getScheduledWorkState", + "cancelScheduledWork", + "setScheduledWorkPaused", +]); + function scopeChatAdeActionArgs( session: SessionState, action: string, chatArgs: Record, ): Record { const method = `run_ade_action:chat.${action}`; - if ( - action !== "readTranscript" - && action !== "sendMessage" - && action !== "listScheduledWork" - && action !== "cancelScheduledWork" - ) return chatArgs; + if (!SCOPED_CHAT_ACTIONS.has(action)) return chatArgs; if (isUnboundAdeCliCaller(session)) return chatArgs; const scopedArgs = { ...chatArgs }; @@ -3481,12 +3486,7 @@ async function runTool(args: { } else if ( !callerIsCto && domain === "chat" - && ( - action === "readTranscript" - || action === "sendMessage" - || action === "listScheduledWork" - || action === "cancelScheduledWork" - ) + && SCOPED_CHAT_ACTIONS.has(action) ) { scopedObjectArgs = scopeChatAdeActionArgs( session, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index f8788050e..9e89c2e48 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -4169,8 +4169,8 @@ describe("ADE CLI", () => { expect(inspect.steps[0]?.params).toMatchObject({ arguments: { domain: "chat", - action: "getSessionSummary", - argsList: ["chat-1"], + action: "getScheduledWorkState", + args: { sessionId: "chat-1" }, }, }); @@ -4198,6 +4198,69 @@ describe("ADE CLI", () => { }, }); + const create = expectExecutePlan(buildCliPlan([ + "chat", + "scheduled-work", + "create", + "--cron", + "9,29,49 * * * *", + "--prompt", + "Check CI and report", + ])); + expect(create.label).toBe("chat scheduled-work create"); + expect(create.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "createScheduledWork", + args: { + cron: "9,29,49 * * * *", + prompt: "Check CI and report", + recurring: true, + }, + }, + }); + + const createOnce = expectExecutePlan(buildCliPlan([ + "chat", + "scheduled-work", + "create", + "--cron", + "15 10 * * 1", + "--prompt", + "Prepare the weekly report", + "--once", + "--reason", + "Weekly report", + "--session", + "chat-1", + ])); + expect(createOnce.steps[0]?.params).toMatchObject({ + arguments: { + action: "createScheduledWork", + args: { + sessionId: "chat-1", + cron: "15 10 * * 1", + prompt: "Prepare the weekly report", + recurring: false, + reason: "Weekly report", + }, + }, + }); + expect(() => buildCliPlan([ + "chat", + "scheduled-work", + "create", + "--prompt", + "Missing cron", + ])).toThrow(/cron is required/); + expect(() => buildCliPlan([ + "chat", + "scheduled-work", + "create", + "--cron", + "0 * * * *", + ])).toThrow(/prompt is required/); + for (const alias of ["schedule", "schedules"]) { expect(expectExecutePlan(buildCliPlan(["chat", alias, "list", "chat-1"])).steps[0]?.params) .toMatchObject({ diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 07ee75e28..32a447749 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1641,9 +1641,11 @@ const HELP_BY_COMMAND: Record = { $ ade chat rewind-files --message --dry-run Preview or apply file/context rewind $ ade chat subagents --text List child agents for a chat - $ ade chat schedules --pause Pause this chat's durable wakeups/cron/loops + $ ade chat schedules --pause Pause this agent session's durable wakeups/cron/loops $ ade chat schedules Inspect pause state + next armed wake (--resume to re-arm) $ ade chat scheduled-work list [session] List durable jobs (--all includes recent history) + $ ade chat scheduled-work create --cron "" --prompt "" [--once] + Optional: --reason "" --session $ ade chat scheduled-work cancel Cancel one job; Claude crons also request CronDelete $ ade new chat --mode cli --lane --provider claude --reasoning-effort ultracode --prompt "fix" Start a tracked provider CLI session @@ -6633,7 +6635,7 @@ function buildChatPlan(args: string[]): CliPlan { || sub === "schedules" || sub === "schedule" ) - && (args[0] === "list" || args[0] === "cancel") + && (args[0] === "list" || args[0] === "create" || args[0] === "cancel") ? firstStandalonePositional(args) : null; const sessionId = @@ -7280,6 +7282,29 @@ function buildChatPlan(args: string[]): CliPlan { sub === "schedule" || sub === "scheduled-work" ) { + if (scheduledWorkOperation === "create") { + const cron = requireValue(readValue(args, ["--cron"]), "cron"); + const prompt = requireValue(readValue(args, ["--prompt", "--text"]), "prompt"); + const reason = readValue(args, ["--reason"]); + return { + kind: "execute", + label: "chat scheduled-work create", + steps: [ + actionStep( + "result", + "chat", + "createScheduledWork", + collectGenericObjectArgs(args, { + ...(sessionId ? { sessionId } : {}), + cron, + prompt, + recurring: !readFlag(args, ["--once"]), + ...(reason ? { reason } : {}), + }), + ), + ], + }; + } if (scheduledWorkOperation === "list") { return { kind: "execute", @@ -7316,9 +7341,8 @@ function buildChatPlan(args: string[]): CliPlan { ], }; } - // Per-chat scheduled-work control: pause/resume this chat's durable - // wakeups/cron/loop schedules, or inspect (no flag) the current pause - // state + next armed wake via getSessionSummary. + // Per-session scheduled-work control. The state query works for both chat + // sessions and tracked provider CLI terminals. const targetSession = requireValue(sessionId, "sessionId"); const pause = readFlag(args, ["--pause", "--pause-scheduled-work"]); const resume = readFlag(args, ["--resume", "--unpause"]); @@ -7326,14 +7350,13 @@ function buildChatPlan(args: string[]): CliPlan { throw new CliUsageError("Use either --pause or --resume, not both."); } if (!pause && !resume) { - // Inspection mode: summary carries nextWakeAt + scheduledWorkPaused. return { kind: "execute", label: "chat schedules", steps: [ - actionArgsListStep("result", "chat", "getSessionSummary", [ - targetSession, - ]), + actionStep("result", "chat", "getScheduledWorkState", { + sessionId: targetSession, + }), ], }; } @@ -11242,6 +11265,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--compare-to", "--content", "--context-file", + "--cron", "--cwd", "--data", "--cpu", diff --git a/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts b/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts index 64b095a9a..0ac163bac 100644 --- a/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts +++ b/apps/ade-cli/src/services/personalChats/personalChatScope.test.ts @@ -52,6 +52,11 @@ describe("PersonalChatScope", () => { interrupt: vi.fn(async () => undefined), respondToInput: vi.fn(async () => undefined), approveToolUse: vi.fn(async () => undefined), + createScheduledWork: vi.fn(async ({ sessionId, cron, prompt }: { + sessionId: string; + cron: string; + prompt: string; + }) => ({ item: { id: "cron-created", sessionId, cron, prompt, status: "scheduled" } })), cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: { sessionId: string; scheduleId: string; @@ -60,6 +65,10 @@ describe("PersonalChatScope", () => { providerCancellationRequested: true, providerCancellationConfirmed: true, })), + setScheduledWorkPaused: vi.fn(async ({ sessionId, paused }: { + sessionId: string; + paused: boolean; + }) => ({ sessionId, paused, nextWakeAt: null })), updateSession: vi.fn(async () => summary), archiveSession: vi.fn(async () => undefined), unarchiveSession: vi.fn(async () => undefined), @@ -179,6 +188,42 @@ describe("PersonalChatScope", () => { expect(service.cancelScheduledWork).toHaveBeenCalledTimes(1); }); + it("creates and pauses scheduled work only for an owned personal session", async () => { + const { createRuntime, service } = fixture(); + const scope = new PersonalChatScope({ createRuntime }); + + await expect(scope.call("createScheduledWork", { + sessionId: "chat-1", + cron: "*/20 * * * *", + prompt: "Check PR CI", + })).resolves.toMatchObject({ + action: "createScheduledWork", + result: { item: { id: "cron-created", sessionId: "chat-1", status: "scheduled" } }, + }); + expect(service.createScheduledWork).toHaveBeenCalledWith({ + sessionId: "chat-1", + cron: "*/20 * * * *", + prompt: "Check PR CI", + }); + + await expect(scope.call("setScheduledWorkPaused", { + sessionId: "chat-1", + paused: true, + })).resolves.toEqual({ + action: "setScheduledWorkPaused", + result: { sessionId: "chat-1", paused: true, nextWakeAt: null }, + }); + expect(service.setScheduledWorkPaused).toHaveBeenCalledWith({ + sessionId: "chat-1", + paused: true, + }); + await expect(scope.call("setScheduledWorkPaused", { + sessionId: "chat-1", + paused: "yes", + })).rejects.toThrow("paused must be a boolean"); + expect(service.setScheduledWorkPaused).toHaveBeenCalledTimes(1); + }); + it("exposes only the hard allowlisted personal-chat actions", () => { const scope = new PersonalChatScope(); const capabilities = scope.capabilities(); @@ -187,7 +232,9 @@ describe("PersonalChatScope", () => { "list", "create", "send", + "createScheduledWork", "cancelScheduledWork", + "setScheduledWorkPaused", "updateSession", "delete", ])); diff --git a/apps/ade-cli/src/services/personalChats/personalChatScope.ts b/apps/ade-cli/src/services/personalChats/personalChatScope.ts index 0185c5c83..1706c7fb9 100644 --- a/apps/ade-cli/src/services/personalChats/personalChatScope.ts +++ b/apps/ade-cli/src/services/personalChats/personalChatScope.ts @@ -31,6 +31,11 @@ function requiredString(value: unknown, label: string): string { return normalized; } +function requiredBoolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`${label} must be a boolean.`); + return value; +} + function readSessionId(args: ObjectArgs): string { return requiredString(args.sessionId, "sessionId"); } @@ -174,6 +179,12 @@ export class PersonalChatScope { await this.requirePersonalSession(service, readSessionId(args)); result = await service.approveToolUse(args as never); break; + case "createScheduledWork": { + const sessionId = readSessionId(args); + await this.requirePersonalSession(service, sessionId); + result = await service.createScheduledWork({ ...args, sessionId } as never); + break; + } case "cancelScheduledWork": { const sessionId = readSessionId(args); await this.requirePersonalSession(service, sessionId); @@ -183,6 +194,15 @@ export class PersonalChatScope { }); break; } + case "setScheduledWorkPaused": { + const sessionId = readSessionId(args); + await this.requirePersonalSession(service, sessionId); + result = await service.setScheduledWorkPaused({ + sessionId, + paused: requiredBoolean(args.paused, "paused"), + }); + break; + } case "updateSession": { const sessionId = readSessionId(args); await this.requirePersonalSession(service, sessionId); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 22e332c98..f6a75ca2b 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -229,6 +229,16 @@ describe("createSyncRemoteCommandService", () => { scope: "runtime", policy: { viewerAllowed: true, queueable: false }, }); + expect(service.getDescriptor("personalChats.createScheduledWork")).toEqual({ + action: "personalChats.createScheduledWork", + scope: "runtime", + policy: { viewerAllowed: false, queueable: false }, + }); + expect(service.getDescriptor("personalChats.setScheduledWorkPaused")).toEqual({ + action: "personalChats.setScheduledWorkPaused", + scope: "runtime", + policy: { viewerAllowed: true, queueable: false }, + }); expect(service.getDescriptor("personalChats.streamEvents")).toEqual({ action: "personalChats.streamEvents", scope: "runtime", @@ -261,6 +271,30 @@ describe("createSyncRemoteCommandService", () => { sessionId: "personal-1", scheduleId: "cron-1", }); + await expect(service.execute(makePayload("personalChats.createScheduledWork", { + sessionId: "personal-1", + cron: "*/20 * * * *", + prompt: "Check PR CI", + }))).resolves.toEqual({ + action: "createScheduledWork", + args: { sessionId: "personal-1", cron: "*/20 * * * *", prompt: "Check PR CI" }, + }); + expect(personalChatScope.call).toHaveBeenCalledWith("createScheduledWork", { + sessionId: "personal-1", + cron: "*/20 * * * *", + prompt: "Check PR CI", + }); + await expect(service.execute(makePayload("personalChats.setScheduledWorkPaused", { + sessionId: "personal-1", + paused: true, + }))).resolves.toEqual({ + action: "setScheduledWorkPaused", + args: { sessionId: "personal-1", paused: true }, + }); + expect(personalChatScope.call).toHaveBeenCalledWith("setScheduledWorkPaused", { + sessionId: "personal-1", + paused: true, + }); }); it("registers sync.getWebPairingInfo and returns the configured browser pairing info", async () => { @@ -478,6 +512,57 @@ describe("createSyncRemoteCommandService", () => { expect(cancelScheduledWork).toHaveBeenCalledTimes(1); }); + it("keeps scheduled-work creation owner-only while allowing mobile pause control", async () => { + const createScheduledWork = vi.fn(async (args: Record) => ({ item: args })); + const listScheduledWork = vi.fn(async (args: Record) => [{ id: "cron-1", ...args }]); + const setScheduledWorkPaused = vi.fn(async (args: { sessionId: string; paused: boolean }) => ({ + ...args, + nextWakeAt: null, + })); + const { service } = createService({ + agentChatService: { createScheduledWork, listScheduledWork, setScheduledWorkPaused }, + }); + + expect(service.getDescriptor("chat.createScheduledWork")).toEqual({ + action: "chat.createScheduledWork", + scope: "project", + policy: { viewerAllowed: false, queueable: false }, + }); + expect(service.getDescriptor("chat.listScheduledWork")).toEqual({ + action: "chat.listScheduledWork", + scope: "project", + policy: { viewerAllowed: true, queueable: false }, + }); + expect(service.getDescriptor("chat.setScheduledWorkPaused")).toEqual({ + action: "chat.setScheduledWorkPaused", + scope: "project", + policy: { viewerAllowed: true, queueable: false }, + }); + await service.execute(makePayload("chat.createScheduledWork", { + sessionId: "chat-1", + cron: "0 * * * *", + prompt: "Check CI.", + recurring: false, + reason: "CI watcher", + })); + expect(createScheduledWork).toHaveBeenCalledWith({ + sessionId: "chat-1", + cron: "0 * * * *", + prompt: "Check CI.", + recurring: false, + reason: "CI watcher", + }); + await expect(service.execute(makePayload("chat.listScheduledWork", { + sessionId: "chat-1", + }))).resolves.toEqual([{ id: "cron-1", sessionId: "chat-1" }]); + expect(listScheduledWork).toHaveBeenCalledWith({ sessionId: "chat-1" }); + await service.execute(makePayload("chat.setScheduledWorkPaused", { + sessionId: "chat-1", + paused: true, + })); + expect(setScheduledWorkPaused).toHaveBeenCalledWith({ sessionId: "chat-1", paused: true }); + }); + it("rejects unsupported Codex recovery actions before invoking chat", async () => { const recoverCodexTurn = vi.fn(); const { service } = createService({ agentChatService: { recoverCodexTurn } }); @@ -1116,7 +1201,10 @@ describe("createSyncRemoteCommandService", () => { "work.getSessionDelta", "chat.getSlashCommands", "chat.resolveSmartLinkPreview", + "chat.createScheduledWork", + "chat.listScheduledWork", "chat.cancelScheduledWork", + "chat.setScheduledWorkPaused", "chat.getParallelLaunchState", "chat.setParallelLaunchState", "chat.handoff", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index f2e504c7c..a965db757 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -183,7 +183,11 @@ import type { } from "../../../../desktop/src/shared/types"; import { isAdeUsageRangePreset, isAdeUsageScope } from "../../../../desktop/src/shared/types"; import type { OrchestrationRunCreateRequest } from "../../../../desktop/src/shared/types/orchestration"; -import { PERSONAL_CHAT_ACTIONS, isPersonalChatActionQueueable } from "../../../../desktop/src/shared/types/personalChats"; +import { + PERSONAL_CHAT_ACTIONS, + isPersonalChatActionQueueable, + isPersonalChatActionViewerAllowed, +} from "../../../../desktop/src/shared/types/personalChats"; import { buildTrackedCliLaunchCommand, deriveTrackedCliInitialInputSessionMeta, @@ -3818,11 +3822,37 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio }); register("chat.getSummary", { viewerAllowed: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); + register("chat.createScheduledWork", { viewerAllowed: false, queueable: false }, async (payload) => { + const recurring = asOptionalBoolean(payload.recurring); + const reason = asTrimmedString(payload.reason); + return requireService(args.agentChatService, "Agent chat service not available.").createScheduledWork({ + sessionId: requireString(payload.sessionId, "chat.createScheduledWork requires sessionId."), + cron: requireString(payload.cron, "chat.createScheduledWork requires cron."), + prompt: requireString(payload.prompt, "chat.createScheduledWork requires prompt."), + ...(recurring !== undefined ? { recurring } : {}), + ...(reason ? { reason } : {}), + }); + }); + register("chat.listScheduledWork", { viewerAllowed: true, queueable: false }, async (payload) => { + const sessionId = asTrimmedString(payload.sessionId); + return requireService(args.agentChatService, "Agent chat service not available.").listScheduledWork({ + ...(sessionId ? { sessionId } : {}), + ...(payload.includeTerminal === true ? { includeTerminal: true } : {}), + }); + }); register("chat.cancelScheduledWork", { viewerAllowed: true, queueable: false }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").cancelScheduledWork({ sessionId: requireString(payload.sessionId, "chat.cancelScheduledWork requires sessionId."), scheduleId: requireString(payload.scheduleId, "chat.cancelScheduledWork requires scheduleId."), })); + register("chat.setScheduledWorkPaused", { viewerAllowed: true, queueable: false }, async (payload) => { + const paused = asOptionalBoolean(payload.paused); + if (paused === undefined) throw new Error("chat.setScheduledWorkPaused requires paused."); + return requireService(args.agentChatService, "Agent chat service not available.").setScheduledWorkPaused({ + sessionId: requireString(payload.sessionId, "chat.setScheduledWorkPaused requires sessionId."), + paused, + }); + }); register("chat.getChatEventHistory", { viewerAllowed: true }, async (payload): Promise => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); const sessionId = requireString(payload.sessionId, "chat.getChatEventHistory requires sessionId."); @@ -3984,7 +4014,10 @@ function registerPersonalChatRemoteCommands({ args, register }: RemoteCommandReg for (const action of PERSONAL_CHAT_ACTIONS) { register( `personalChats.${action}`, - { viewerAllowed: true, queueable: isPersonalChatActionQueueable(action) }, + { + viewerAllowed: isPersonalChatActionViewerAllowed(action), + queueable: isPersonalChatActionQueueable(action), + }, async (payload) => (await scope.call(action, payload)).result, "runtime", ); diff --git a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx index 7eaa44866..8cb0b914b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/Drawer.test.tsx @@ -10,6 +10,7 @@ import { closedCliSessionStatusKind, deriveClosedCliSessions, deriveOpenDrawerSessions, + terminalSessionToChatSummary, type ClosedCliSessionSummary, } from "../closedCliSessions"; @@ -117,6 +118,22 @@ describe("Drawer diff stats", () => { }); describe("Drawer closed CLI sessions", () => { + it("projects durable schedule state onto tracked CLI summaries", () => { + const summary = terminalSessionToChatSummary(terminal({ terminalId: "cli-scheduled" }), { + sessionId: "cli-scheduled", + paused: true, + nextWakeAt: "2026-07-16T21:00:00.000Z", + items: [], + }); + + expect(summary).toMatchObject({ + sessionId: "cli-scheduled", + scheduledWorkPaused: true, + nextWakeAt: "2026-07-16T21:00:00.000Z", + scheduledWork: [], + }); + }); + it.each([ ["failed status", { status: "failed", exitCode: 1, runtimeState: "killed" }, "failed"], ["non-zero exit", { status: "completed", exitCode: 2, runtimeState: "exited" }, "failed"], diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 298abb988..b89601dad 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -369,6 +369,39 @@ describe("RightPane chat info", () => { expect(frame).toContain("⏰ next wake 12m"); }); + it("labels a paused schedule in the Schedule header", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("SCHEDULE (paused)"); + expect(frame).toContain("Check CI"); + expect(frame).toContain("cron · scheduled"); + expect(frame).toContain("*/20 * * * *"); + }); + it("does not render a next wake line for invalid or past timestamps", () => { for (const nextWakeAt of ["not-a-date", new Date(Date.now() - 1_000).toISOString()]) { const result = render( diff --git a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts index b316e9932..e4ee3a56b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts @@ -177,10 +177,13 @@ describe("deriveChatInfoSnapshot", () => { expect(snapshot.inspectedSubagentId).toBe("x1"); }); - it("carries the active session's earliest next wake into chat info", () => { + it("carries the active session's scheduled-work state into chat info", () => { const snapshot = deriveChatInfoSnapshot({ events: [], - activeSession: session({ nextWakeAt: "2026-07-09T12:12:00.000Z" }), + activeSession: session({ + scheduledWorkPaused: true, + nextWakeAt: "2026-07-09T12:12:00.000Z", + }), provider: "claude", modelLabel: "claude-opus-4-8", laneLabel: "lane", @@ -190,9 +193,50 @@ describe("deriveChatInfoSnapshot", () => { streaming: false, }); + expect(snapshot.scheduledWorkPaused).toBe(true); expect(snapshot.nextWakeAt).toBe("2026-07-09T12:12:00.000Z"); }); + it("keeps action-created providerless schedules in the normal schedule pipeline", () => { + const snapshot = deriveChatInfoSnapshot({ + events: [{ + sessionId: "session-1", + timestamp: "2026-07-09T12:00:00.000Z", + sequence: 1, + event: { + type: "scheduled_work_update", + id: "action:session-1:job-1", + kind: "cron", + status: "scheduled", + origin: "action", + title: "Check CI", + prompt: "Inspect the latest CI run", + reason: "Keep the PR moving", + cron: "*/20 * * * *", + recurring: true, + durable: true, + }, + }], + activeSession: session({ provider: "codex" }), + provider: "codex", + modelLabel: "gpt-5.5", + laneLabel: "lane", + snapshots: [], + tokenStats: null, + goal: null, + streaming: false, + }); + + expect(snapshot.scheduledWork).toEqual([ + expect.objectContaining({ + id: "action:session-1:job-1", + kind: "cron", + origin: "action", + title: "Check CI", + }), + ]); + }); + it("carries the Claude session tag into the chat info header", () => { const snapshot = deriveChatInfoSnapshot({ events: [], diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 9aea161c2..14f46a63e 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -34,6 +34,7 @@ import type { AgentChatOpenCodePermissionMode, AgentChatPermissionMode, AgentChatProvider, + AgentChatScheduledWorkState, AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, @@ -136,6 +137,15 @@ export async function listChatSessions( }); } +export async function getScheduledWorkState( + connection: AdeCodeConnection, + sessionId: string, +): Promise { + return await connection.action("chat", "getScheduledWorkState", { + sessionId, + }); +} + export async function archiveChatSession( connection: AdeCodeConnection, sessionId: string, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 146f6c53f..d23a7a01a 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -31,6 +31,7 @@ import type { AgentChatModelCatalogModel, AgentChatModelCatalogRefreshProvider, AgentChatModelInfo, + AgentChatScheduledWorkState, AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, @@ -68,6 +69,7 @@ import { toggleModelPickerFavorite, getOpenCodeRuntimeDiagnostics, getSlashCommands, + getScheduledWorkState, getStoredApiKeyProviders, getSubagentTranscript, interruptChat, @@ -2857,6 +2859,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [diffByLaneId, setDiffByLaneId] = useState>({}); const [sessions, setSessions] = useState([]); const [terminalSessions, setTerminalSessions] = useState([]); + const [terminalScheduledWorkById, setTerminalScheduledWorkById] = useState>({}); const [terminalPreview, setTerminalPreview] = useState(null); // Per-terminal seed snapshots for grid tiles (the single-view pane uses // `terminalPreview`). Live updates after the seed arrive via terminalLiveChunks, @@ -3809,25 +3812,45 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, () => terminalSessions.find((session) => session.terminalId === activeSessionId) ?? null, [activeSessionId, terminalSessions], ); + useEffect(() => { + if (!connection || !activeTerminalSession) return; + let cancelled = false; + void getScheduledWorkState(connection, activeTerminalSession.terminalId) + .then((state) => { + if (cancelled) return; + setTerminalScheduledWorkById((current) => ({ + ...current, + [activeTerminalSession.terminalId]: state, + })); + }) + .catch(() => undefined); + return () => { cancelled = true; }; + }, [activeTerminalSession, connection]); // Chat-shaped view of the active session that ALSO covers Claude terminal // sessions (which never appear in `sessions`). Drives chat-info availability // and the context resolver so terminal chats get the same chat-info default // pane as SDK chats instead of falling back to lane-details. const activeDisplaySession = useMemo( - () => activeSession ?? (activeTerminalSession ? terminalSessionToChatSummary(activeTerminalSession) : null), - [activeSession, activeTerminalSession], + () => activeSession ?? (activeTerminalSession + ? terminalSessionToChatSummary( + activeTerminalSession, + terminalScheduledWorkById[activeTerminalSession.terminalId], + ) + : null), + [activeSession, activeTerminalSession, terminalScheduledWorkById], ); const activeTerminalProvider = terminalSessionProvider(activeTerminalSession); const displaySessions = useMemo( () => sortSessionsByRecentActivity([ ...sessions.filter((session) => !session.archivedAt), - ...terminalSessions.map(terminalSessionToChatSummary), + ...terminalSessions.map((session) => + terminalSessionToChatSummary(session, terminalScheduledWorkById[session.terminalId])), ]), - [sessions, terminalSessions], + [sessions, terminalScheduledWorkById, terminalSessions], ); const closedCliSessions = useMemo( - () => deriveClosedCliSessions(terminalSessions), - [terminalSessions], + () => deriveClosedCliSessions(terminalSessions, terminalScheduledWorkById), + [terminalScheduledWorkById, terminalSessions], ); const openDrawerSessions = useMemo( () => deriveOpenDrawerSessions(displaySessions, closedCliSessions), @@ -6946,7 +6969,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const nextTerminalSessions = mergeOptimisticTerminalSessions(listedTerminalSessions, optimisticTerminalSessionsRef.current); const nextDisplaySessions = sortSessionsByRecentActivity([ ...nextSessions, - ...nextTerminalSessions.map(terminalSessionToChatSummary), + ...nextTerminalSessions.map((session) => terminalSessionToChatSummary(session)), ]); const draftMode = draftChatActiveRef.current; const target = resolveTuiChatRefreshTarget({ @@ -10827,7 +10850,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, session = freshChatSessions.find((entry) => entry.sessionId === sessionId) ?? (freshTerminalSessions - .map(terminalSessionToChatSummary) + .map((terminal) => terminalSessionToChatSummary(terminal)) .find((entry) => entry.sessionId === sessionId) ?? null); } } diff --git a/apps/ade-cli/src/tuiClient/chatInfo.ts b/apps/ade-cli/src/tuiClient/chatInfo.ts index 23575a58a..0272afae6 100644 --- a/apps/ade-cli/src/tuiClient/chatInfo.ts +++ b/apps/ade-cli/src/tuiClient/chatInfo.ts @@ -90,6 +90,7 @@ export function deriveChatInfoSnapshot(args: { // Schedule kinds only — background command tasks render in their own block. scheduledWork: mergeManagedScheduledWorkSnapshots(args.events, args.activeSession?.scheduledWork) .filter((item) => item.kind !== "background_task"), + scheduledWorkPaused: args.activeSession?.scheduledWorkPaused === true, nextWakeAt: args.activeSession?.nextWakeAt ?? null, backgroundWork: deriveBackgroundItems(args.events), pr: args.pr ?? null, diff --git a/apps/ade-cli/src/tuiClient/closedCliSessions.ts b/apps/ade-cli/src/tuiClient/closedCliSessions.ts index 4b86311b0..7c55dfba2 100644 --- a/apps/ade-cli/src/tuiClient/closedCliSessions.ts +++ b/apps/ade-cli/src/tuiClient/closedCliSessions.ts @@ -1,4 +1,4 @@ -import type { AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import type { AgentChatScheduledWorkState, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; import type { ChatTerminalSession } from "../../../desktop/src/shared/types/sessions"; import { formatRelativePastTime } from "./relativeTime"; import { theme } from "./theme"; @@ -54,7 +54,10 @@ function terminalSummaryProvider(session: ChatTerminalSession): AgentChatSession : "claude"; } -export function terminalSessionToChatSummary(session: ChatTerminalSession): ClosedCliSessionSummary { +export function terminalSessionToChatSummary( + session: ChatTerminalSession, + scheduledWorkState?: AgentChatScheduledWorkState | null, +): ClosedCliSessionSummary { const status: AgentChatSessionSummary["status"] = session.status === "running" ? session.runtimeState === "idle" ? "idle" : "active" : "ended"; @@ -73,7 +76,9 @@ export function terminalSessionToChatSummary(session: ChatTerminalSession): Clos lastActivityAt: session.endedAt ?? session.startedAt, lastOutputPreview: session.lastOutputPreview, summary: session.summary, - nextWakeAt: null, + nextWakeAt: scheduledWorkState?.nextWakeAt ?? null, + scheduledWorkPaused: scheduledWorkState?.paused === true, + scheduledWork: scheduledWorkState?.items ?? [], surface: "work", terminalStatus: session.status, terminalExitCode: session.exitCode, @@ -89,11 +94,14 @@ export function sortSessionsByRecentActivity = {}, +): ClosedCliSessionSummary[] { return sortSessionsByRecentActivity( terminalSessions .filter((session) => session.status !== "running" && terminalSessionProvider(session) != null) - .map(terminalSessionToChatSummary), + .map((session) => terminalSessionToChatSummary(session, scheduledWorkStateById[session.terminalId])), ); } diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index cbbb8b2a5..c89e9b2da 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -686,15 +686,28 @@ function selectedRosterDetail(snapshot: SubagentSnapshot, capability: SubagentCa || "…"; } -function ChatInfoSectionHead({ title, hint, color, width }: { title: string; hint?: string; color: string; width?: number }) { +function ChatInfoSectionHead({ + title, + dimSuffix, + hint, + color, + width, +}: { + title: string; + dimSuffix?: string; + hint?: string; + color: string; + width?: number; +}) { // Section header with a hairline rule that fills the gap to the hint, so each // block reads as a titled card divider rather than a bare label. const inner = Math.max(12, (width ?? 40) - 4); - const used = title.length + 2 + (hint ? hint.length + 1 : 0); + const used = title.length + (dimSuffix ? dimSuffix.length + 1 : 0) + 2 + (hint ? hint.length + 1 : 0); const ruleLen = Math.max(1, inner - used); return ( {title} + {dimSuffix ? {` ${dimSuffix}`} : null} {` ${"─".repeat(ruleLen)}${hint ? " " : ""}`} {hint ? {hint} : null} @@ -1127,7 +1140,13 @@ function ChatInfoScheduleBlock({ info, brandColor, width, viewState }: { info: C }; return ( - + {nextWake ? ( {` ⏰ next wake ${nextWake}`} diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 9ea11bb53..fd9f29338 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -188,6 +188,8 @@ export type ChatInfoSnapshot = { todos: ChatInfoTodoItem[]; /** Schedule kinds only (wakeup/cron/loop/remote_trigger). */ scheduledWork: ChatScheduledWorkSnapshot[]; + /** True when all durable schedules for the active chat are paused. */ + scheduledWorkPaused?: boolean; /** Earliest armed, unpaused wake reported by the active chat session. */ nextWakeAt?: string | null; /** Background command tasks (kind background_task). */ diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 12755690c..f04eb4fc6 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -148,6 +148,33 @@ Use `ade chat show --text` before messaging a chat you do not own: `ade chat send ...` with the new instruction. Do not send a second normal turn into an active chat and hope the provider interprets it as steering. +### Scheduled work + +Persistent ADE chats and tracked provider CLI sessions can schedule their own +durable wakeups. Use the typed `ade chat scheduled-work create|list|cancel` +commands or the generic `chat.createScheduledWork`, `chat.listScheduledWork`, +and `chat.cancelScheduledWork` actions. Pause or resume that session with +`ade chat schedules --pause|--resume` or +`chat.setScheduledWorkPaused`. Omit the pause/resume flag, or call +`chat.getScheduledWorkState`, to inspect pause state, the next wake, and active +jobs for either a chat or tracked provider CLI session. + +Omitting the target in an ADE-bound agent defaults create/list to +`ADE_CHAT_SESSION_ID`; an agent cannot schedule another session, and an +ordinary untracked shell fails instead of creating orphaned work. Chat delivery +starts a new turn at the next safe turn boundary. Tracked provider CLI delivery +waits for the provider's visible composer boundary, or resumes the same ended +CLI session before sending the prompt. Both survive brain restarts, and +recurring schedules expire seven days after creation. Users can pause chat jobs +in Chat Info or all scheduled work in Settings; CLI-owned jobs remain +manageable through these commands and the Settings recovery list. + +``` +ade actions run chat.createScheduledWork --input-json '{"cron":"9,29,49 * * * *","prompt":"Check CI and report"}' --text +ade chat scheduled-work list --all --text +ade chat schedules "$ADE_CHAT_SESSION_ID" --pause --text +``` + Compatibility commands still exist, but do not teach them as the first choice: ``` diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 5efc7ee57..3dd9131d9 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -506,7 +506,9 @@ export const ADE_ACTION_ALLOWLIST: Partial { + const record = readObjectActionArg(args, "chat.getScheduledWorkState"); + return agentChatService.getScheduledWorkState({ + sessionId: requireNonEmptyString(record.sessionId, "sessionId"), + }); + }; + } if (typeof base.cancelScheduledWork === "function") { service.cancelScheduledWork = (args?: unknown) => { const record = readObjectActionArg(args, "chat.cancelScheduledWork"); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index 969b442ed..32df62669 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -108,7 +108,8 @@ describe("buildCodingAgentSystemPrompt", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "codex-cli" }); expect(result).toContain("## Runtime Environment"); expect(result).toContain("Codex CLI"); - expect(result).toContain("No autonomous wake from ADE"); + expect(result).toContain("chat.createScheduledWork"); + expect(result).toContain("targets your own tracked agent session automatically"); }); it("describes the Codex app-server runtime", () => { @@ -116,7 +117,8 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("## Runtime Environment"); expect(result).toContain("Codex app-server protocol"); expect(result).toContain("JSON-RPC"); - expect(result).toContain("No autonomous wake from ADE"); + expect(result).toContain("chat.createScheduledWork"); + expect(result).toContain("next turn boundary"); }); it("describes durable Claude scheduled self-resume and pause controls", () => { @@ -125,12 +127,14 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("Claude Agent SDK stable `query()`"); expect(result).toContain("ScheduleWakeup"); expect(result).toContain("CronCreate"); - expect(result).toContain("can start a later unattended turn"); - expect(result).toContain("session-bound by default"); + expect(result).toContain("automatically mirrored into ADE's durable scheduler"); + expect(result).toContain("`durable: true` also persists Claude's provider copy"); + expect(result).toContain("next turn boundary even if the chat was busy"); + expect(result).toContain("`CronList` view is advisory; ADE state wins"); expect(result).toContain("CronCreate` always creates a new job"); - expect(result).toContain("`CronList` and `CronDelete`"); - expect(result).toContain("auto-expire after seven days"); - expect(result).toContain("project-wide manager in Settings"); + expect(result).toContain("`CronList` + `CronDelete`"); + expect(result).toContain("expire seven days after creation"); + expect(result).toContain("project-wide in Settings"); expect(result).not.toContain("unavailable in this ADE chat"); expect(result).not.toContain("will not start a later turn by itself"); }); @@ -138,16 +142,19 @@ describe("buildCodingAgentSystemPrompt", () => { it("describes the Cursor SDK runtime", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "cursor-sdk" }); expect(result).toContain("Cursor SDK"); + expect(result).toContain("chat.createScheduledWork"); }); it("describes the Droid SDK runtime", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "droid-sdk" }); expect(result).toContain("Factory Droid SDK"); + expect(result).toContain("chat.createScheduledWork"); }); it("describes the OpenCode runtime", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "opencode" }); expect(result).toContain("OpenCode session"); + expect(result).toContain("chat.createScheduledWork"); }); }); diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 4b97177e3..9ce6fbcf2 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -17,38 +17,40 @@ export type AdeRuntimeKind = | "droid-sdk" | "opencode"; +const adeScheduledWorkGuidance = "**Wake-up semantics:** Autonomous wake is available via `ade actions run chat.createScheduledWork --input-json '{\"cron\":\"<5-field>\",\"prompt\":\"\"}' --text`. Jobs recur by default; pass `{\"recurring\":false}` for a one-shot, and the action targets your own tracked agent session automatically. List, cancel, or pause with `chat.listScheduledWork`, `chat.cancelScheduledWork`, and `chat.setScheduledWorkPaused`, or the typed `ade chat scheduled-work ...` / `ade chat schedules ...` commands. Delivery starts a new turn at the next turn boundary, resumes an ended tracked provider CLI when necessary, and survives brain restarts; recurring jobs expire after seven days. Keep shell `sleep` for short waits inside the current turn."; + function describeRuntime(runtime: AdeRuntimeKind): string[] { switch (runtime) { case "claude-agent-sdk-query": return [ "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API.", - "**Wake-up semantics:** ADE follows the Claude Agent SDK schedule contract. `ScheduleWakeup`, `CronCreate`, and `/loop` can start a later unattended turn. `CronCreate` always creates a new job, so reset or replacement flows must use `CronList` and `CronDelete` to remove the prior job before creating another. `CronCreate` is session-bound by default; set `durable: true` only when the user wants it to survive restarts. Durable recurring crons auto-expire after seven days, matching Claude. ADE restores durable jobs after its brain restarts, late-fires overdue work once, and exposes pause/cancel controls in Chat Info plus a project-wide manager in Settings.", + "**Wake-up semantics:** Native `ScheduleWakeup`, `CronCreate`, and `/loop` are automatically mirrored into ADE's durable scheduler. `durable: true` also persists Claude's provider copy, while ADE's delivery guarantee does not depend on that flag. Jobs survive brain restarts and start a new turn at the next turn boundary even if the chat was busy when they became due. The SDK's own `CronList` view is advisory; ADE state wins. Pause schedules in Chat Info or project-wide in Settings. Recurring jobs expire seven days after creation. `CronCreate` always creates a new job, so replace one with `CronList` + `CronDelete` before creating another.", "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer `ScheduleWakeup`, `CronCreate`, or `/loop` and include a concise reason/prompt so ADE can show the pending work clearly.", ]; case "codex-cli": return [ "**Runtime:** ADE Work chat wrapping the Codex CLI as a subprocess. Your turns are driven through the Codex agent loop, but the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.", - "**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && ` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.", + adeScheduledWorkGuidance, ]; case "codex-app-server": return [ "**Runtime:** ADE Work chat hosted on the Codex app-server protocol. Your turns are driven through Codex app-server JSON-RPC, while the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.", - "**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && ` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.", + adeScheduledWorkGuidance, ]; case "cursor-sdk": return [ "**Runtime:** ADE Work chat hosted on the Cursor SDK (`@cursor/sdk`).", - "**Wake-up semantics:** Each turn is driven by ADE through the SDK agent run. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", + adeScheduledWorkGuidance, ]; case "droid-sdk": return [ "**Runtime:** ADE Work chat hosted on the Factory Droid SDK (`@factory/droid-sdk`) and backed by the local Droid CLI.", - "**Wake-up semantics:** Each turn is driven by ADE through the Droid SDK stream. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.", + adeScheduledWorkGuidance, ]; case "opencode": return [ "**Runtime:** ADE Work chat wrapping an OpenCode session.", - "**Wake-up semantics:** Turns are driven by ADE through the OpenCode HTTP session. There is no autonomous wake; use a shell `sleep` for waits.", + adeScheduledWorkGuidance, ]; } } diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 776b1019b..e160bdb27 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -853,6 +853,7 @@ import { acquireCursorSdkConnection, releaseCursorSdkConnection } from "./cursor import { acquireDroidSdkConnection } from "./droidSdkPool"; import { clearCursorCliModelsCache } from "./cursorModelsDiscovery"; import type { AgentChatCrossMachineHandoffCapsule, AgentChatEventEnvelope, ComputerUseBackendStatus, LaneLinearIssue, PendingInputRequest } from "../../../shared/types"; +import { PTY_SEND_PRE_DELIVERY_ERROR_CODE } from "../../../shared/types"; import { makeLinearIssueContextAttachment } from "../../../shared/chatContextAttachments"; import { stableStringify } from "../shared/utils"; import { @@ -1252,6 +1253,7 @@ function createMockSessionService() { id: args.sessionId, laneId: args.laneId, ptyId: args.ptyId ?? null, + tracked: args.tracked ?? true, title: args.title ?? "Chat", toolType: args.toolType ?? "opencode-chat", status: "running", @@ -1925,6 +1927,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); if (ORIGINAL_CURSOR_API_KEY === undefined) { delete process.env.CURSOR_API_KEY; @@ -12671,6 +12674,461 @@ describe("createAgentChatService", () => { expect(service.hasActiveWorkloads()).toBe(false); }); + it("creates durable recurring and one-shot scheduled work and validates inputs and session state", async () => { + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + const { service, sessionService } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-codex", + }); + + const recurring = await service.createScheduledWork({ + sessionId: session.id, + cron: "9,29,49 * * * *", + prompt: " Check CI and report. ", + reason: " CI watcher ", + }); + const oneShot = await service.createScheduledWork({ + sessionId: session.id, + cron: "15 10 * * 1", + prompt: "Prepare the weekly report.", + recurring: false, + }); + + expect(recurring.item).toMatchObject({ + id: `action:${session.id}:test-uuid-2`, + sessionId: session.id, + kind: "cron", + status: "scheduled", + prompt: "Check CI and report.", + reason: "CI watcher", + cron: "9,29,49 * * * *", + durable: true, + }); + expect(oneShot.item).toMatchObject({ + id: `action:${session.id}:test-uuid-3`, + kind: "wakeup", + status: "scheduled", + durable: true, + }); + expect(await service.listScheduledWork({ sessionId: session.id })).toEqual([ + recurring.item, + oneShot.item, + ]); + const storedSchedules = scheduledWork.readState()?.schedules ?? []; + expect(storedSchedules).toEqual([ + expect.objectContaining({ + id: recurring.item.id, + kind: "cron", + expiresAt: expect.any(Number), + }), + expect.objectContaining({ + id: oneShot.item.id, + kind: "wakeup", + }), + ]); + expect(storedSchedules[0]?.provider).toBeUndefined(); + expect(storedSchedules[1]?.provider).toBeUndefined(); + expect(storedSchedules[1]?.expiresAt).toBeUndefined(); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + sessionId: session.id, + event: expect.objectContaining({ + type: "scheduled_work_update", + id: recurring.item.id, + origin: "action", + status: "scheduled", + }), + }), + ])); + + await service.setScheduledWorkPaused({ sessionId: session.id, paused: true }); + const pausedCreate = await service.createScheduledWork({ + sessionId: session.id, + cron: "0 * * * *", + prompt: "Wait until scheduling resumes.", + }); + expect(pausedCreate.item.status).toBe("paused"); + const pausedCreateEvents = events.filter((envelope) => + envelope.event.type === "scheduled_work_update" + && envelope.event.id === pausedCreate.item.id + ); + expect(pausedCreateEvents.at(-1)?.event).toEqual(expect.objectContaining({ + status: "paused", + nextRunAt: expect.any(String), + })); + + await expect(service.createScheduledWork({ + sessionId: session.id, + cron: "not a cron", + prompt: "Check CI.", + })).rejects.toThrow(/cron.*valid 5-field cron/i); + await expect(service.createScheduledWork({ + sessionId: session.id, + cron: "0 * * * *", + prompt: " ", + })).rejects.toThrow(/prompt is required/i); + + sessionService.create({ + sessionId: "ended-chat", + laneId: "lane-1", + toolType: "codex-chat", + }); + sessionService.end({ sessionId: "ended-chat", status: "completed" }); + await expect(service.createScheduledWork({ + sessionId: "ended-chat", + cron: "0 * * * *", + prompt: "This must not be scheduled.", + })).rejects.toThrow(/ended or archived/i); + + sessionService.create({ + sessionId: "untracked-cli", + laneId: "lane-1", + toolType: "codex", + tracked: false, + }); + await expect(service.createScheduledWork({ + sessionId: "untracked-cli", + cron: "0 * * * *", + prompt: "This must not be scheduled.", + })).rejects.toThrow(/not found/i); + }); + + it("delivers a provider-neutral action schedule through messageSession for an idle live Claude chat", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + installClaudeWakeupFixture({ + sdkSessionId: "sdk-action-schedule", + delaySeconds: 600, + lingerAfterTurn: new Promise(() => undefined), + }); + const { service } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const foregroundTurn = service.runSessionTurn({ + sessionId: session.id, + text: "Keep the Claude runtime warm.", + }); + await vi.advanceTimersByTimeAsync(1_000); + await foregroundTurn; + + const created = await service.createScheduledWork({ + sessionId: session.id, + cron: "1 * * * *", + prompt: "Deliver this through the ADE wake path.", + recurring: false, + }); + await vi.advanceTimersByTimeAsync(60_000); + vi.useRealTimers(); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + sessionId: session.id, + event: expect.objectContaining({ + type: "user_message", + metadata: expect.objectContaining({ + scheduledWake: expect.objectContaining({ scheduleId: created.item.id }), + }), + }), + }), + ])); + service.forceDisposeAll(); + }); + + it("resumes an ended tracked CLI session when its durable one-shot becomes due", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const sendToSession = vi.fn(async () => ({ + ptyId: "pty-cli-scheduled", + sessionId: "cli-scheduled", + pid: 42, + session: null, + resumed: true, + reusedExistingRuntime: false, + })); + const { service, sessionService } = createService({ + db: scheduledWork.db, + ptyService: { + create: vi.fn(), + canAcceptScheduledTurn: () => true, + enrichSessions: (rows: Array>) => rows.map((row) => ({ + ...row, + runtimeState: "exited", + })), + sendToSession, + }, + }); + sessionService.create({ + sessionId: "cli-scheduled", + laneId: "lane-1", + toolType: "codex", + }); + sessionService.end({ sessionId: "cli-scheduled", status: "completed" }); + + const created = await service.createScheduledWork({ + sessionId: "cli-scheduled", + cron: "1 * * * *", + prompt: "Check CI and report the result.", + recurring: false, + }); + await vi.advanceTimersByTimeAsync(60_000); + + expect(sendToSession).toHaveBeenCalledWith({ + sessionId: "cli-scheduled", + text: "Check CI and report the result.", + }); + expect(await service.listScheduledWork({ + sessionId: "cli-scheduled", + includeTerminal: true, + })).toEqual([ + expect.objectContaining({ + id: created.item.id, + status: "completed", + }), + ]); + service.forceDisposeAll(); + }); + + it("reads durable schedule state for a tracked CLI session", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const { service, sessionService } = createService({ db: scheduledWork.db }); + sessionService.create({ + sessionId: "cli-state", + laneId: "lane-1", + toolType: "codex", + }); + const created = await service.createScheduledWork({ + sessionId: "cli-state", + cron: "1 * * * *", + prompt: "Inspect this from ADE Code.", + recurring: false, + }); + + expect(await service.getScheduledWorkState({ sessionId: "cli-state" })).toEqual({ + sessionId: "cli-state", + paused: false, + nextWakeAt: new Date(SCHEDULE_TEST_START + 60_000).toISOString(), + items: [expect.objectContaining({ id: created.item.id, status: "scheduled" })], + }); + await service.setScheduledWorkPaused({ sessionId: "cli-state", paused: true }); + expect(await service.getScheduledWorkState({ sessionId: "cli-state" })).toMatchObject({ + sessionId: "cli-state", + paused: true, + nextWakeAt: null, + }); + service.forceDisposeAll(); + }); + + it("waits for a tracked CLI turn boundary before delivering scheduled work", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + let canAcceptScheduledTurn = false; + const sendToSession = vi.fn(async () => ({ + ptyId: "pty-cli-busy", + sessionId: "cli-busy", + pid: 42, + session: null, + resumed: false, + reusedExistingRuntime: true, + })); + const { service, sessionService } = createService({ + db: scheduledWork.db, + ptyService: { + create: vi.fn(), + canAcceptScheduledTurn: () => canAcceptScheduledTurn, + enrichSessions: (rows: Array>) => rows.map((row) => ({ + ...row, + runtimeState: canAcceptScheduledTurn ? "waiting-input" : "running", + })), + sendToSession, + }, + }); + sessionService.create({ + sessionId: "cli-busy", + laneId: "lane-1", + toolType: "claude", + }); + await service.createScheduledWork({ + sessionId: "cli-busy", + cron: "1 * * * *", + prompt: "Continue after the foreground CLI turn.", + recurring: false, + }); + + await vi.advanceTimersByTimeAsync(60_000); + expect(sendToSession).not.toHaveBeenCalled(); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("scheduled"); + + canAcceptScheduledTurn = true; + await vi.advanceTimersByTimeAsync(20_000); + expect(sendToSession).toHaveBeenCalledOnce(); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("done"); + service.forceDisposeAll(); + }); + + it("retries a tracked CLI occurrence when resume fails before delivery", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const sendToSession = vi.fn(async () => { + throw Object.assign( + new Error("Terminal session 'cli-no-resume' does not have a resume command."), + { code: PTY_SEND_PRE_DELIVERY_ERROR_CODE }, + ); + }); + const { service, sessionService } = createService({ + db: scheduledWork.db, + ptyService: { + create: vi.fn(), + canAcceptScheduledTurn: () => true, + enrichSessions: (rows: Array>) => rows.map((row) => ({ + ...row, + runtimeState: "exited", + })), + sendToSession, + }, + }); + sessionService.create({ + sessionId: "cli-no-resume", + laneId: "lane-1", + toolType: "codex", + }); + sessionService.end({ sessionId: "cli-no-resume", status: "completed" }); + await service.createScheduledWork({ + sessionId: "cli-no-resume", + cron: "1 * * * *", + prompt: "Retry this only after a resumable target exists.", + recurring: false, + }); + + await vi.advanceTimersByTimeAsync(60_000); + expect(sendToSession).toHaveBeenCalledOnce(); + expect(scheduledWork.readState()?.schedules[0]).toEqual(expect.objectContaining({ + status: "scheduled", + fireAt: SCHEDULE_TEST_START + 60_000, + })); + expect(scheduledWork.readState()?.schedules[0]?.lastFiredAt).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(20_000); + expect(sendToSession).toHaveBeenCalledTimes(2); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("scheduled"); + service.forceDisposeAll(); + }); + + it("defers provider and ADE-action schedules while their live session is busy", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let releaseBusySend!: () => void; + const busySendGate = new Promise((resolve) => { releaseBusySend = resolve; }); + const send = vi.fn(async () => { await busySendGate; }); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-busy-backstop", slash_commands: [] }; + return; + } + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-busy-backstop", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + const { service } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const foregroundTurn = service.runSessionTurn({ + sessionId: session.id, + text: "Keep working through the backstop deadline.", + }); + for (let index = 0; index < 100 && !send.mock.calls.length; index += 1) { + await vi.advanceTimersByTimeAsync(1); + } + expect(send).toHaveBeenCalled(); + expect(service.hasActiveWorkloads()).toBe(true); + const options = vi.mocked(claudeSdkCreateSessionCompat).mock.calls.at(-1)?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + await options?.hooks?.PostToolUse?.[0]?.hooks[0]?.({ + hook_event_name: "PostToolUse", + session_id: "sdk-busy-backstop", + tool_name: "ScheduleWakeup", + tool_use_id: "tool-busy-backstop", + tool_input: { + delaySeconds: 60, + reason: "Check PR CI", + prompt: "Check PR CI after the foreground turn.", + }, + tool_response: { + scheduledFor: SCHEDULE_TEST_START, + clampedDelaySeconds: 60, + wasClamped: false, + }, + }); + const actionSchedule = await service.createScheduledWork({ + sessionId: session.id, + cron: "1 * * * *", + prompt: "Run the ADE-owned follow-up after the foreground turn.", + recurring: false, + }); + await vi.advanceTimersByTimeAsync(90_000); + + expect(scheduledWork.readState()?.schedules.find((item) => item.id === `wakeup:${session.id}`)).toEqual(expect.objectContaining({ + status: "scheduled", + fireAt: SCHEDULE_TEST_START, + lateFlag: false, + })); + expect(scheduledWork.readState()?.schedules.find((item) => item.id === actionSchedule.item.id)).toEqual(expect.objectContaining({ + status: "scheduled", + fireAt: SCHEDULE_TEST_START + 60_000, + })); + expect(scheduledWork.readState()?.schedules.every((item) => item.lastFiredAt == null)).toBe(true); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake != null + )).toBe(false); + + releaseBusySend(); + await vi.advanceTimersByTimeAsync(0); + await foregroundTurn; + await vi.advanceTimersByTimeAsync(20_000); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake?.scheduleId === actionSchedule.item.id + )).toBe(true); + service.forceDisposeAll(); + }); + it("projects the next durable wake onto the chat session summary", async () => { const before = Date.now(); let streamCall = 0; @@ -13618,7 +14076,7 @@ describe("createAgentChatService", () => { expect(service.hasActiveWorkloads()).toBe(false); }); - it("keys Claude cron create and delete events by the provider cron id", async () => { + it("mirrors Claude CronCreate without a durable flag and keys events by the provider cron id", async () => { const events: AgentChatEventEnvelope[] = []; const scheduledWork = createScheduledWorkDb(); const setPermissionMode = vi.fn().mockResolvedValue(undefined); @@ -13708,7 +14166,19 @@ describe("createAgentChatService", () => { tool_input: { cron: "*/15 * * * *", prompt: "Check CI status." }, tool_response: { id: "cron-sdk-1", recurring: true, durable: false }, }); - expect(scheduledWork.readState()?.schedules ?? []).toEqual([]); + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ + id: "cron-sdk-1", + kind: "cron", + cron: "*/15 * * * *", + prompt: "Check CI status.", + durable: true, + provider: "claude", + providerSessionId: "sdk-cron-provider-id", + providerScheduleId: "cron-sdk-1", + status: "scheduled", + }), + ]); await postToolUseHook?.({ hook_event_name: "PostToolUse", tool_name: "CronDelete", @@ -13734,6 +14204,7 @@ describe("createAgentChatService", () => { status: "cancelled", cron: "*/15 * * * *", prompt: "Check CI status.", + durable: true, sourceToolUseId: "tool-cron-delete", }); }); @@ -13900,7 +14371,7 @@ describe("createAgentChatService", () => { } finally { dateNow.mockRestore(); } - expect(scheduledWork.readState()?.schedules[0]?.expiresAt).toBeGreaterThan(initialExpiresAt ?? 0); + expect(scheduledWork.readState()?.schedules[0]?.expiresAt).toBe(initialExpiresAt); await service.runSessionTurn({ sessionId: session.id, @@ -13909,8 +14380,8 @@ describe("createAgentChatService", () => { expect(scheduledWork.readState()?.schedules).toEqual([ expect.objectContaining({ id: "cron-provider-1", - status: "paused", - pausedFlag: true, + status: "scheduled", + pausedFlag: false, providerSessionId: "sdk-cron-provider-id", }), ]); @@ -13930,7 +14401,7 @@ describe("createAgentChatService", () => { expect(scheduledWork.readState()?.schedules).toEqual([ expect.objectContaining({ id: "cron-provider-1", - status: "paused", + status: "scheduled", providerSessionId: "sdk-cron-provider-id", }), ]); @@ -14209,44 +14680,117 @@ describe("createAgentChatService", () => { await resumed.resumeSession({ sessionId: session.id }); await resumed.runSessionTurn({ sessionId: session.id, - text: "Reconcile the provider's scheduled-work snapshot.", + text: "Reconcile the provider's scheduled-work snapshot.", + }); + const resumeOptions = vi.mocked(claudeSdkResumeSessionCompat).mock.calls.at(-1)?.[1] as { + hooks?: Record Promise> }>>; + } | undefined; + const subagentStopHook = resumeOptions?.hooks?.SubagentStop?.[0]?.hooks[0]; + const stopHook = resumeOptions?.hooks?.Stop?.[0]?.hooks[0]; + + await subagentStopHook?.({ + hook_event_name: "SubagentStop", + session_id: sdkSessionId, + agent_id: "agent-1", + agent_type: "reviewer", + last_assistant_message: "Review complete.", + session_crons: [{ + id: "provider-loop-id", + schedule: "once", + prompt: "Continue provider loop.", + recurring: false, + }], + }); + expect(scheduledWork.readState()?.schedules).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "provider-wakeup", status: "cancelled" }), + expect.objectContaining({ id: "provider-loop", status: "scheduled" }), + expect.objectContaining({ id: "ade-local-wakeup", status: "scheduled" }), + expect.objectContaining({ id: "other-provider-wakeup", status: "scheduled" }), + ])); + + await stopHook?.({ + hook_event_name: "Stop", + session_id: sdkSessionId, + session_crons: [], + }); + expect(scheduledWork.readState()?.schedules).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "provider-loop", status: "cancelled" }), + expect.objectContaining({ id: "ade-local-wakeup", status: "scheduled" }), + expect.objectContaining({ id: "other-provider-wakeup", status: "scheduled" }), + ])); + resumed.forceDisposeAll(); + original.forceDisposeAll(); + }); + + it("keeps a previous provider session's mirrored cron after a fresh session reports an empty snapshot", async () => { + const previousProviderSessionId = "sdk-before-brain-restart"; + const currentProviderSessionId = "sdk-after-brain-restart"; + const sdkHandle = { + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: currentProviderSessionId, + slash_commands: [], + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close: vi.fn(), + sessionId: currentProviderSessionId, + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(sdkHandle); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(sdkHandle); + + const original = createService().service; + const session = await original.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + writePersistedChatState(session.id, { + ...readPersistedChatState(session.id), + sdkSessionId: previousProviderSessionId, + }); + const scheduledWork = createScheduledWorkDb({ + version: 1, + schedules: [storedWakeup(session.id, { + id: "cron-survives-restart", + kind: "cron", + cron: "*/15 * * * *", + durable: true, + provider: "claude", + providerSessionId: previousProviderSessionId, + providerScheduleId: "cron-survives-restart", + })], + pausedSessionIds: [], + }); + const resumed = createService({ db: scheduledWork.db }).service; + await resumed.resumeSession({ sessionId: session.id }); + await resumed.runSessionTurn({ + sessionId: session.id, + text: "Resume after the brain restart.", }); const resumeOptions = vi.mocked(claudeSdkResumeSessionCompat).mock.calls.at(-1)?.[1] as { hooks?: Record Promise> }>>; } | undefined; - const subagentStopHook = resumeOptions?.hooks?.SubagentStop?.[0]?.hooks[0]; const stopHook = resumeOptions?.hooks?.Stop?.[0]?.hooks[0]; - await subagentStopHook?.({ - hook_event_name: "SubagentStop", - session_id: sdkSessionId, - agent_id: "agent-1", - agent_type: "reviewer", - last_assistant_message: "Review complete.", - session_crons: [{ - id: "provider-loop-id", - schedule: "once", - prompt: "Continue provider loop.", - recurring: false, - }], - }); - expect(scheduledWork.readState()?.schedules).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: "provider-wakeup", status: "cancelled" }), - expect.objectContaining({ id: "provider-loop", status: "scheduled" }), - expect.objectContaining({ id: "ade-local-wakeup", status: "scheduled" }), - expect.objectContaining({ id: "other-provider-wakeup", status: "scheduled" }), - ])); - await stopHook?.({ hook_event_name: "Stop", - session_id: sdkSessionId, + session_id: currentProviderSessionId, session_crons: [], }); - expect(scheduledWork.readState()?.schedules).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: "provider-loop", status: "cancelled" }), - expect.objectContaining({ id: "ade-local-wakeup", status: "scheduled" }), - expect.objectContaining({ id: "other-provider-wakeup", status: "scheduled" }), - ])); + + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ + id: "cron-survives-restart", + status: "scheduled", + pausedFlag: false, + providerSessionId: previousProviderSessionId, + }), + ]); resumed.forceDisposeAll(); original.forceDisposeAll(); }); @@ -14583,6 +15127,7 @@ describe("createAgentChatService", () => { }); it("does not create task-id scheduled rows for ambiguous parentless cron runs", async () => { + const scheduledWork = createScheduledWorkDb(); const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); const send = vi.fn().mockResolvedValue(undefined); @@ -14646,6 +15191,7 @@ describe("createAgentChatService", () => { } as any); const { service } = createService({ + db: scheduledWork.db, onEvent: (event: AgentChatEventEnvelope) => events.push(event), }); const session = await service.createSession({ @@ -14663,6 +15209,25 @@ describe("createAgentChatService", () => { text: "Schedule two recurring crons.", }); + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const postToolUseHook = opts?.hooks?.PostToolUse?.[0]?.hooks[0]; + await postToolUseHook?.({ + hook_event_name: "PostToolUse", + session_id: "sdk-cron-parentless-ambiguous", + tool_name: "CronCreate", + tool_use_id: "tool-cron-ambiguous-ci", + tool_input: { cron: "*/15 * * * *", prompt: "Check CI status." }, + tool_response: { id: "cron-provider-ambiguous-ci", recurring: true }, + }); + await postToolUseHook?.({ + hook_event_name: "PostToolUse", + session_id: "sdk-cron-parentless-ambiguous", + tool_name: "CronCreate", + tool_use_id: "tool-cron-ambiguous-review", + tool_input: { cron: "*/20 * * * *", prompt: "Review issue comments." }, + tool_response: { id: "cron-provider-ambiguous-review", recurring: true }, + }); await stopHook?.({ hook_event_name: "Stop", session_crons: [ @@ -14681,14 +15246,20 @@ describe("createAgentChatService", () => { ], }); + await vi.advanceTimersByTimeAsync(15 * 60_000); startCronRun(); - await waitForEvent( - events, - (event): event is AgentChatEventEnvelope => - event.sessionId === session.id - && event.event.type === "subagent_result" - && event.event.taskId === "cron-run-task-ambiguous", - ); + for (let index = 0; index < 100 && !events.some((event) => + event.sessionId === session.id + && event.event.type === "subagent_result" + && event.event.taskId === "cron-run-task-ambiguous" + ); index += 1) { + await vi.advanceTimersByTimeAsync(1); + } + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "subagent_result" + && event.event.taskId === "cron-run-task-ambiguous" + )).toBe(true); const scheduledEvents = events .filter((event): event is AgentChatEventEnvelope & { @@ -14697,15 +15268,178 @@ describe("createAgentChatService", () => { event.sessionId === session.id && event.event.type === "scheduled_work_update" && event.event.kind === "cron"); - expect(scheduledEvents.map((event) => event.event.id)).toEqual([ - "cron-provider-ambiguous-ci", - "cron-provider-ambiguous-review", - ]); expect(scheduledEvents.map((event) => event.event.id)).not.toContain("cron-run-task-ambiguous"); + expect(scheduledEvents.every((event) => + event.event.id === "cron-provider-ambiguous-ci" + || event.event.id === "cron-provider-ambiguous-review" + )).toBe(true); + + const scheduledWakeMessages = events.filter((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake != null + ); + expect(scheduledWakeMessages).toHaveLength(1); + expect(scheduledWakeMessages[0]?.event).toMatchObject({ + type: "user_message", + metadata: { + scheduledWake: { scheduleId: "cron-provider-ambiguous-ci" }, + }, + }); const snapshots = deriveScheduledWorkSnapshots(events); expect(snapshots).toHaveLength(2); expect(snapshots.map((snapshot) => snapshot.status).sort()).toEqual(["scheduled", "scheduled"]); + expect(scheduledWork.readState()?.schedules.find((item) => + item.id === "cron-provider-ambiguous-ci" + )?.lastFiredAt).toEqual(expect.any(Number)); + service.forceDisposeAll(); + }); + + it("claims a native cron when unrelated idle output already opened the turn", async () => { + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let releaseIdle!: () => void; + const idleGate = new Promise((resolve) => { releaseIdle = resolve; }); + let releaseCron!: () => void; + const cronGate = new Promise((resolve) => { releaseCron = resolve; }); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-cron-existing-idle-turn", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-existing-idle-turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + await idleGate; + yield { + type: "tool_progress", + tool_name: "BackgroundTask", + elapsed_time_seconds: 1, + }; + await cronGate; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-cron-existing-idle-turn", + task_id: "cron-existing-idle-turn-task", + task_type: "cron", + description: "Check CI status.", + }; + yield { + type: "system", + subtype: "task_updated", + session_id: "sdk-cron-existing-idle-turn", + task_id: "cron-existing-idle-turn-task", + task_type: "cron", + patch: { status: "completed" }, + summary: "CI passed.", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-existing-idle-turn", + usage: { input_tokens: 2, output_tokens: 2 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-cron-existing-idle-turn", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const options = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + const postToolUseHook = options?.hooks?.PostToolUse?.[0]?.hooks[0]; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Keep watching CI in the background.", + }); + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + await postToolUseHook?.({ + hook_event_name: "PostToolUse", + session_id: "sdk-cron-existing-idle-turn", + tool_name: "CronCreate", + tool_use_id: "tool-cron-existing-idle-turn", + tool_input: { cron: "*/15 * * * *", prompt: "Check CI status." }, + tool_response: { id: "cron-provider-existing-idle-turn", recurring: true }, + }); + await options?.hooks?.Stop?.[0]?.hooks[0]?.({ + hook_event_name: "Stop", + session_crons: [{ + id: "cron-provider-existing-idle-turn", + schedule: "*/15 * * * *", + prompt: "Check CI status.", + recurring: true, + }], + }); + releaseIdle(); + for (let index = 0; index < 100 && !events.some((event) => + event.sessionId === session.id + && event.event.type === "activity" + && event.event.detail === "Tool 'BackgroundTask' running (1s)" + ); index += 1) { + await vi.advanceTimersByTimeAsync(1); + } + const idleActivity = events.find((event) => + event.sessionId === session.id + && event.event.type === "activity" + && event.event.detail === "Tool 'BackgroundTask' running (1s)" + ); + expect(idleActivity?.event.turnId).toBeTruthy(); + + await vi.advanceTimersByTimeAsync(15 * 60_000); + releaseCron(); + for (let index = 0; index < 100 && !events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake?.scheduleId === "cron-provider-existing-idle-turn" + ); index += 1) { + await vi.advanceTimersByTimeAsync(1); + } + + const scheduledWake = events.find((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake?.scheduleId === "cron-provider-existing-idle-turn" + ); + expect(scheduledWake?.event).toMatchObject({ + type: "user_message", + turnId: idleActivity?.event.turnId, + }); + const startedTurnIds = events + .filter((event) => event.sessionId === session.id && event.event.type === "status" && event.event.turnStatus === "started") + .map((event) => event.event.turnId); + expect(new Set(startedTurnIds).size).toBe(2); + service.forceDisposeAll(); }); it("does not create task-id scheduled rows for parentless cron runs when aliases are empty", async () => { @@ -14997,18 +15731,17 @@ describe("createAgentChatService", () => { service.forceDisposeAll(); }); - it("finishSession clears pending native scheduled wakes", async () => { + it("injects the overdue prompt when the live Claude native scheduler misses its tick", async () => { vi.useFakeTimers(); vi.setSystemTime(SCHEDULE_TEST_START); const scheduledWork = createScheduledWorkDb(); const events: AgentChatEventEnvelope[] = []; - installClaudeWakeupFixture({ - sdkSessionId: "sdk-pending-native-wake", + const { send } = installClaudeWakeupFixture({ + sdkSessionId: "sdk-backstop-input", delaySeconds: 60, - prompt: "Stale native wake must not survive dispose.", - // Keep the stable SDK query alive after the foreground result so the - // scheduler exercises the native pending-wake handoff instead of the - // cold-query fallback path. + prompt: "Deliver the missed native wake through ADE.", + // The provider never emits a native cron turn after the foreground + // result. ADE must push a real user message into this live query. lingerAfterTurn: new Promise(() => undefined), }); const { service } = createService({ @@ -15022,22 +15755,22 @@ describe("createAgentChatService", () => { }); const foregroundTurn = service.runSessionTurn({ sessionId: session.id, - text: "Queue a native wake.", + text: "Schedule a native wake.", }); await vi.advanceTimersByTimeAsync(1_000); await foregroundTurn; - await vi.advanceTimersByTimeAsync(59_000); + const sendsBeforeBackstop = send.mock.calls.length; + await vi.advanceTimersByTimeAsync(149_000); expect(scheduledWork.readState()?.schedules[0]?.status).toBe("fired"); - expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(1); - - await service.dispose({ sessionId: session.id }); - - expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(0); + expect(send).toHaveBeenCalledTimes(sendsBeforeBackstop + 1); expect(events.some((event) => event.sessionId === session.id && event.event.type === "user_message" - && event.event.metadata?.scheduledWake?.reason === "Stale native wake must not survive dispose." - )).toBe(false); + && event.event.metadata?.scheduledWake?.reason === "Check PR CI" + && event.event.text === "Deliver the missed native wake through ADE." + )).toBe(true); + + await service.dispose({ sessionId: session.id }); service.forceDisposeAll(); }); @@ -15245,7 +15978,7 @@ describe("createAgentChatService", () => { }); await restarted.service.refreshScheduledWork(); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(150_000); await vi.waitFor(() => { expect(events.some((event) => event.sessionId === session.id @@ -27717,7 +28450,7 @@ describe("createAgentChatService", () => { }, }); - expect(result).toMatchObject({ routedAction: "steer", delivery: "queued", queued: true }); + expect(result).toMatchObject({ routedAction: "sendMessage", delivery: "queued", queued: true }); const queued = events.find((event): event is AgentChatEventEnvelope & { event: Extract; } => @@ -27725,6 +28458,27 @@ describe("createAgentChatService", () => { && event.event.deliveryState === "queued" && event.event.text === "Check PR CI"); expect(queued?.event.metadata?.scheduledWake).toBeUndefined(); + expect(send.mock.calls.some(([payload]) => JSON.stringify(payload).includes("Check PR CI"))).toBe(false); + + const childCompletion = await service.messageSession({ + sessionId: session.id, + text: "Your subagent finished.", + kind: "wake", + metadata: { + spawnCompletion: { + childSessionId: "child-1", + childTitle: "Review agent", + spawnKind: "subagent", + status: "completed", + summary: "Review complete.", + }, + }, + }); + expect(childCompletion).toMatchObject({ + routedAction: "sendMessage", + delivery: "queued", + queued: true, + }); finishActiveTurn(); await activeTurn; @@ -27738,6 +28492,70 @@ describe("createAgentChatService", () => { expect(send).toHaveBeenCalledWith(expect.stringContaining("Check PR CI")); }); + it("queues an active Codex wake and dispatches it as a new turn at the boundary", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4-codex", + }); + await service.sendMessage({ + sessionId: session.id, + text: "Finish the foreground work.", + }, { awaitDispatch: true }); + + const result = await service.messageSession({ + sessionId: session.id, + text: "Check PR CI after the current turn.", + kind: "wake", + metadata: { + scheduledWake: { + scheduleId: "codex-wake-boundary-1", + kind: "wakeup", + firedAt: "2026-07-09T09:00:00.000Z", + reason: "Check PR CI", + }, + }, + }); + + expect(result).toMatchObject({ + routedAction: "sendMessage", + delivery: "queued", + queued: true, + }); + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(1); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/steer")).toBe(false); + + mockState.emitCodexPayload({ + method: "turn/completed", + params: { turn: { id: "turn-1", status: "completed" } }, + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(2); + }); + + const turnStarts = mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start"); + expect(turnStarts[1]?.params).toEqual(expect.objectContaining({ + input: expect.arrayContaining([ + expect.objectContaining({ text: expect.stringContaining("Check PR CI after the current turn.") }), + ]), + })); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/steer")).toBe(false); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "user_message", + metadata: expect.objectContaining({ + scheduledWake: expect.objectContaining({ scheduleId: "codex-wake-boundary-1" }), + }), + }), + }), + ])); + }); + it("throws when steering an unknown session", async () => { const { service } = createService(); await expect( diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 19a4cb08b..8e007ef84 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -75,6 +75,7 @@ import { type ChatScheduledWorkRecord, type ChatScheduledWorkScheduler, type ChatScheduledWorkStatus, + type ChatScheduledWorkUpsert, } from "./chatScheduledWorkScheduler"; import { drainRunningClaudeWorkflowAgents, @@ -142,6 +143,7 @@ import { } from "./threadPointerLedger"; import type { createFileService } from "../files/fileService"; import type { createProcessService } from "../processes/processService"; +import type { createPtyService } from "../pty/ptyService"; import type { DiskPressureMonitor, DiskPressureState } from "../storage/diskPressure"; import { readHistoryFileSync, @@ -176,6 +178,9 @@ import type { AgentChatAcceptCrossMachineHandoffResult, AgentChatArchiveArgs, AgentChatCancelSteerArgs, + AgentChatCreateScheduledWorkArgs, + AgentChatCreateScheduledWorkResult, + AgentChatGetScheduledWorkStateArgs, AgentChatClaudeOutputStyle, AgentChatClaudeOutputStylesArgs, AgentChatClaudePlugin, @@ -260,6 +265,7 @@ import type { AgentChatScheduledWorkItem, AgentChatSetScheduledWorkPausedArgs, AgentChatSetScheduledWorkPausedResult, + AgentChatScheduledWorkState, AgentChatSlashCommand, AgentChatSlashCommandsArgs, AgentChatKillDroidWorkerArgs, @@ -311,7 +317,11 @@ import type { LaneLinearIssue, SessionLinearIssueLink, } from "../../../shared/types"; -import { providerSupportsHandoffFork } from "../../../shared/types"; +import { + isPtySendPreDeliveryError, + isTrackedAgentCliToolType, + providerSupportsHandoffFork, +} from "../../../shared/types"; import { providerDisplayLabel } from "../../../shared/pendingInputLabels"; import { CROSS_MACHINE_FORK_BRIEF_STUB, @@ -1118,6 +1128,7 @@ type CodexRuntime = { turnId: string | null; followupText: string; }>; + pendingSteers: QueuedSteer[]; request: (method: string, params?: unknown, options?: CodexRequestOptions) => Promise; notify: (method: string, params?: unknown) => void; sendResponse: (id: string | number, result: unknown) => void; @@ -3542,6 +3553,16 @@ function isChatToolType( return toolType != null && CHAT_SESSION_TOOL_TYPES.includes(toolType as ChatSessionToolType); } +function isSchedulableAgentSession( + session: { + toolType: TerminalToolType | null | undefined; + tracked: boolean; + }, +): boolean { + return isChatToolType(session.toolType) + || (session.tracked && isTrackedAgentCliToolType(session.toolType)); +} + function providerFromToolType(toolType: TerminalToolType | null | undefined): AgentChatProvider { if (toolType === "opencode-chat") return "opencode"; if (toolType === "claude-chat") return "claude"; @@ -6343,7 +6364,10 @@ export function createAgentChatService(args: { processService?: ReturnType | null; diskPressureMonitor?: DiskPressureMonitor | null; getTestService?: () => { listSuites: () => any[]; run: (args: any) => Promise; stop: (args: any) => void; listRuns: (args?: any) => any[]; getLogTail: (args: any) => string } | null; - ptyService?: { create: (args: any) => Promise<{ ptyId: string; sessionId: string }> } | null; + ptyService?: Pick< + ReturnType, + "create" | "sendToSession" | "enrichSessions" | "canAcceptScheduledTurn" + > | null; getAutomationService?: () => { list: () => any[]; triggerManually: (args: any) => Promise; listRuns: (args?: any) => any[] } | null; getGitService?: () => CtoOperatorToolDeps["gitService"]; conflictService?: CtoOperatorToolDeps["conflictService"]; @@ -6978,15 +7002,6 @@ export function createAgentChatService(args: { let scheduledWorkScheduler: ChatScheduledWorkScheduler | null = null; let scheduledWorkReady: Promise = Promise.resolve(); const durableScheduleUiStatusById = new Map(); - type PendingNativeScheduledWake = { - scheduleId: string; - kind: "wakeup" | "cron" | "loop"; - prompt: string; - reason?: string; - firedAt: string; - late: boolean; - }; - const pendingNativeScheduledWakeBySession = new Map(); const runScheduledWorkMutation = (operation: string, mutation: Promise): void => { void mutation.catch((error) => { @@ -12469,6 +12484,39 @@ export function createAgentChatService(args: { || normalized.startsWith("/loop "); }; + const buildClaudeScheduleUpsert = (input: { + id: string; + sessionId: string; + kind: "wakeup" | "cron" | "loop"; + prompt: string; + reason?: string; + cron?: string; + fireAt?: number; + createdAt?: number; + recurring: boolean; + expiresAtBase?: number; + providerSessionId?: string; + providerScheduleId?: string; + }): ChatScheduledWorkUpsert => ({ + id: input.id, + sessionId: input.sessionId, + kind: input.kind, + prompt: input.prompt, + ...(input.reason ? { reason: input.reason } : {}), + ...(input.cron ? { cron: input.cron } : {}), + ...(Object.prototype.hasOwnProperty.call(input, "fireAt") ? { fireAt: input.fireAt } : {}), + ...(input.createdAt != null ? { createdAt: input.createdAt } : {}), + ...(input.recurring && input.expiresAtBase != null + ? { expiresAt: input.expiresAtBase + claudeRecurringCronTtlMs } + : {}), + status: "scheduled", + lateFlag: false, + durable: true, + provider: "claude", + ...(input.providerSessionId ? { providerSessionId: input.providerSessionId } : {}), + ...(input.providerScheduleId ? { providerScheduleId: input.providerScheduleId } : {}), + }); + const nextClaudeWakeupId = ( sessionId: string, sourceId: string, @@ -12505,18 +12553,13 @@ export function createAgentChatService(args: { }); }; - const adoptClaudeProviderSessionId = async ( + const adoptClaudeProviderSessionId = ( managed: ManagedChatSession, runtime: ClaudeRuntime, nextProviderSessionId: string | null | undefined, - ): Promise => { + ): void => { const next = nextProviderSessionId?.trim(); if (!next || runtime.sdkSessionId === next) return; - await quarantineClaudeScheduledWorkForProviderSessionChange( - managed, - runtime.sdkSessionId, - next, - ); runtime.sdkSessionId = next; mirrorClaudeSessionPointer(managed, next); persistChatState(managed); @@ -12617,7 +12660,7 @@ export function createAgentChatService(args: { const output = claudeScheduledWorkToolOutput(input.tool_response) ?? {}; const turnId = runtime.activeTurnId ?? undefined; const hookProviderSessionId = compactString(input.session_id); - await adoptClaudeProviderSessionId(managed, runtime, hookProviderSessionId); + adoptClaudeProviderSessionId(managed, runtime, hookProviderSessionId); const providerSessionId = hookProviderSessionId ?? runtime.sdkSessionId?.trim(); if (tool === "ScheduleWakeup") { @@ -12670,19 +12713,16 @@ export function createAgentChatService(args: { await Promise.all(activeWakeups.map((schedule) => scheduledWorkScheduler!.cancel(schedule.id))); } else { - await scheduledWorkScheduler.upsert({ + await scheduledWorkScheduler.upsert(buildClaudeScheduleUpsert({ id: wakeupId, sessionId: managed.session.id, kind, prompt, ...(compactString(args.reason) ? { reason: compactString(args.reason) } : {}), fireAt, - status: "scheduled", - lateFlag: false, - durable: true, - provider: "claude", + recurring: false, ...(providerSessionId ? { providerSessionId } : {}), - }); + })); } return; } @@ -12695,9 +12735,6 @@ export function createAgentChatService(args: { const recurring = typeof output.recurring === "boolean" ? output.recurring : args.recurring !== false; - const durable = typeof output.durable === "boolean" - ? output.durable - : args.durable === true; const kind: "cron" | "wakeup" | "loop" = recurring ? "cron" : isClaudeLoopPrompt(prompt) ? "loop" : "wakeup"; @@ -12714,20 +12751,17 @@ export function createAgentChatService(args: { prompt, ...(fireAt != null ? { nextRunAt: new Date(fireAt).toISOString() } : {}), recurring, - durable, + durable: true, sourceToolUseId: input.tool_use_id, ...(turnId ? { turnId } : {}), }); if (!scheduledWorkScheduler) return; - if (!durable) { - if (scheduledWorkScheduler.list(managed.session.id).some((schedule) => schedule.id === id)) { - await scheduledWorkScheduler.cancel(id); - } - return; - } + // ADE state wins, SDK view is advisory: every successful provider cron + // is mirrored durably whether or not Claude also persists its provider + // copy with durable: true; the SDK's CronList may drift after restarts. durableScheduleUiStatusById.set(id, "scheduled"); const createdAt = Date.now(); - await scheduledWorkScheduler.upsert({ + await scheduledWorkScheduler.upsert(buildClaudeScheduleUpsert({ id, sessionId: managed.session.id, kind, @@ -12735,14 +12769,11 @@ export function createAgentChatService(args: { cron, fireAt, createdAt, - ...(recurring ? { expiresAt: createdAt + claudeRecurringCronTtlMs } : {}), - status: "scheduled", - lateFlag: false, - durable: true, - provider: "claude", + recurring, + expiresAtBase: createdAt, ...(providerSessionId ? { providerSessionId } : {}), providerScheduleId: id, - }); + })); return; } @@ -12779,7 +12810,7 @@ export function createAgentChatService(args: { ): Promise => { const hookRecord = input as unknown as Record; const hookProviderSessionId = compactString(hookRecord.session_id); - await adoptClaudeProviderSessionId(managed, runtime, hookProviderSessionId); + adoptClaudeProviderSessionId(managed, runtime, hookProviderSessionId); const turnId = runtime.activeTurnId ?? undefined; const backgroundTasks = Array.isArray(hookRecord.background_tasks) ? hookRecord.background_tasks : []; const snapshotBackgroundTaskIds = new Set(); @@ -12871,6 +12902,8 @@ export function createAgentChatService(args: { .find((schedule) => schedule.id === scheduledWorkId); const ownerMatches = !durableSchedule?.providerSessionId || durableSchedule.providerSessionId === providerSessionId; + const canAdoptCurrentProvider = durableSchedule?.provider === "claude" + && durableSchedule.pausedFlag !== true; const kind: "cron" | "wakeup" | "loop" = recurring ? "cron" : isClaudeLoopPrompt(prompt) ? "loop" : "wakeup"; @@ -12892,11 +12925,15 @@ export function createAgentChatService(args: { if ( scheduledWorkScheduler && durableSchedule - && ownerMatches + && (ownerMatches || canAdoptCurrentProvider) && durableSchedule.status !== "done" && durableSchedule.status !== "cancelled" ) { - await scheduledWorkScheduler.upsert({ + // Stop/SubagentStop is an inventory snapshot, not a reschedule event. + // Preserve the original occurrence and seven-day creation TTL so an + // overdue tick cannot be skipped and repeated snapshots cannot extend + // the provider job indefinitely. + await scheduledWorkScheduler.upsert(buildClaudeScheduleUpsert({ id: scheduledWorkId, sessionId: managed.session.id, kind, @@ -12904,17 +12941,10 @@ export function createAgentChatService(args: { // the full PostToolUse prompt already stored under the canonical id. prompt: durableSchedule.prompt, ...(cronSchedule ? { cron: cronSchedule } : {}), - ...(cronSchedule - ? { fireAt: nextChatScheduledCronFireAt(cronSchedule, Date.now()) ?? undefined } - : {}), - ...(recurring ? { expiresAt: Date.now() + claudeRecurringCronTtlMs } : {}), - status: "scheduled", - lateFlag: false, - durable: true, - provider: "claude", + recurring, ...(providerSessionId ? { providerSessionId } : {}), providerScheduleId: id, - }); + })); } } if (hasSessionCrons && scheduledWorkScheduler) { @@ -14139,7 +14169,6 @@ export function createAgentChatService(args: { status: TerminalSessionStatus, options?: { exitCode?: number | null; summary?: string | null } ): Promise => { - pendingNativeScheduledWakeBySession.delete(managed.session.id); if (managed.endedNotified) return; managed.endedNotified = true; clearSubagentSnapshots(managed.session.id); @@ -15232,22 +15261,20 @@ export function createAgentChatService(args: { runtime: ClaudeRuntime, state: ClaudeIdleTurnState, detail = "Claude resumed background work", + nativeScheduleId?: string, + explicitNativeCron = false, ): string => { - if (state.turnId) return state.turnId; - const turnId = `claude-idle-${randomUUID()}`; - state.turnId = turnId; - captureTurnBeforeSha(managed); - runtime.interrupted = false; - runtime.interruptEventsEmitted = false; - runtime.busy = true; - runtime.activeTurnId = turnId; - setSessionActive(managed); - const pendingScheduledWakes = pendingNativeScheduledWakeBySession.get(managed.session.id) ?? []; - const pendingScheduledWake = pendingScheduledWakes[0]; - const claimedSchedule = !pendingScheduledWake - ? scheduledWorkScheduler?.claimNativeFire(managed.session.id, turnId) ?? null + const existingTurnId = state.turnId; + const turnId = existingTurnId ?? `claude-idle-${randomUUID()}`; + // Only an explicit native cron task may claim the provider-first window. + // Unrelated background/subagent output also arrives through this idle + // reader and must never consume a due schedule by proximity alone. When + // older SDK task events omit the provider id, the explicit cron task type + // still safely claims the earliest due Claude row. + const claimedSchedule = explicitNativeCron + ? scheduledWorkScheduler?.claimNativeFire(managed.session.id, turnId, nativeScheduleId) ?? null : null; - const scheduledWake = pendingScheduledWake ?? (claimedSchedule + const scheduledWake = claimedSchedule ? { scheduleId: claimedSchedule.id, kind: claimedSchedule.kind, @@ -15256,16 +15283,8 @@ export function createAgentChatService(args: { firedAt: new Date(claimedSchedule.lastFiredAt ?? Date.now()).toISOString(), late: claimedSchedule.lateFlag, } - : null); + : null; if (scheduledWake) { - if (pendingScheduledWake) { - pendingScheduledWakes.shift(); - if (pendingScheduledWakes.length) { - pendingNativeScheduledWakeBySession.set(managed.session.id, pendingScheduledWakes); - } else { - pendingNativeScheduledWakeBySession.delete(managed.session.id); - } - } state.scheduledWakeAttached = true; emitChatEvent(managed, { type: "user_message", @@ -15282,6 +15301,14 @@ export function createAgentChatService(args: { turnId, }); } + if (existingTurnId) return existingTurnId; + state.turnId = turnId; + captureTurnBeforeSha(managed); + runtime.interrupted = false; + runtime.interruptEventsEmitted = false; + runtime.busy = true; + runtime.activeTurnId = turnId; + setSessionActive(managed); emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); emitChatEvent(managed, { type: "activity", @@ -15418,10 +15445,24 @@ export function createAgentChatService(args: { const command = compactString(msg.command) ?? existing?.command; const description = compactString(msg.description) ?? existing?.description ?? "Background task"; const backgroundShell = classification.backgroundShell; + const nativeCronScheduleId = taskType === "cron" + ? resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg) + : null; + const explicitNativeCron = subtype === "task_started" && taskType === "cron"; // Progress from a task that outlives its parent turn must not manufacture a - // new main-thread turn. A genuine parent assistant frame (normally the - // completion wake-up) will start the next idle turn when it arrives. - const turnId = state.turnId ?? undefined; + // new main-thread turn. A native cron start is the exception: it is the + // provider's explicit ownership signal for a scheduled turn. Generic idle + // assistant/tool output is not sufficient evidence to claim a schedule. + const turnId = explicitNativeCron + ? startClaudeIdleTurn( + managed, + runtime, + state, + "Claude resumed scheduled work", + nativeCronScheduleId ?? undefined, + true, + ) + : state.turnId ?? undefined; // Background shell commands are tracked as background scheduled_work rows, // not subagents — mirror the main-loop gating in every idle subtype. if (backgroundShell) { @@ -15529,7 +15570,7 @@ export function createAgentChatService(args: { ...(workflowName ? { workflowName } : {}), }); if (taskType === "cron") { - const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg); + const scheduledWorkId = nativeCronScheduleId; if (scheduledWorkId) { emitClaudeScheduledWorkUpdate(managed, runtime, { type: "scheduled_work_update", @@ -15575,7 +15616,7 @@ export function createAgentChatService(args: { if (notificationAgentId) runtime.activeSubagents.delete(notificationAgentId); if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); if (taskType === "cron") { - const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg); + const scheduledWorkId = nativeCronScheduleId; if (scheduledWorkId) { emitClaudeScheduledWorkUpdate(managed, runtime, { type: "scheduled_work_update", @@ -15640,7 +15681,7 @@ export function createAgentChatService(args: { if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); const finalStatus = status === "completed" ? "completed" : status === "killed" ? "stopped" : "failed"; if (taskType === "cron") { - const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg); + const scheduledWorkId = nativeCronScheduleId; if (scheduledWorkId) { emitClaudeScheduledWorkUpdate(managed, runtime, { type: "scheduled_work_update", @@ -15709,7 +15750,7 @@ export function createAgentChatService(args: { const record = msg as unknown as Record; const messageSessionId = compactString(record.session_id); if (messageSessionId && runtime.sdkSessionId !== messageSessionId) { - await adoptClaudeProviderSessionId(managed, runtime, messageSessionId); + adoptClaudeProviderSessionId(managed, runtime, messageSessionId); } if (record.type === "conversation_reset") { @@ -16210,7 +16251,7 @@ export function createAgentChatService(args: { const startClaudeIdleReader = ( managed: ManagedChatSession, runtime: ClaudeRuntime, - reason: "turn_completed" | "query_ready" | "durable_schedule_fire" = "turn_completed", + reason: "turn_completed" | "query_ready" = "turn_completed", ): void => { if (managed.closed || managed.deleted) return; if (!runtime.query || !runtime.inputPump) return; @@ -16888,7 +16929,7 @@ export function createAgentChatService(args: { ? (msg as any).session_id.trim() : ""; if (messageSessionId.length > 0 && runtime.sdkSessionId !== messageSessionId) { - await adoptClaudeProviderSessionId(managed, runtime, messageSessionId); + adoptClaudeProviderSessionId(managed, runtime, messageSessionId); } if ((msg as any).type === "conversation_reset") { @@ -16925,7 +16966,7 @@ export function createAgentChatService(args: { ? initMsg.session_id.trim() : ""; if (initSessionId.length > 0 && runtime.sdkSessionId !== initSessionId) { - await adoptClaudeProviderSessionId(managed, runtime, initSessionId); + adoptClaudeProviderSessionId(managed, runtime, initSessionId); } reportedInitModel = normalizeReportedModelName(initMsg.model) ?? reportedInitModel; if (Array.isArray(initMsg.slash_commands)) { @@ -22959,6 +23000,9 @@ export function createAgentChatService(args: { sessionService.setHeadShaEnd(managed.session.id, endSha); } persistChatState(managed); + if (runtime.pendingSteers.length && managed.runtime === runtime) { + await deliverNextQueuedSteer(managed, runtime); + } } function isCodexSilentTurnStillCurrent( @@ -23406,6 +23450,9 @@ export function createAgentChatService(args: { } persistChatState(managed); + if (runtime.pendingSteers.length && managed.runtime === runtime) { + await deliverNextQueuedSteer(managed, runtime); + } return; } @@ -23666,6 +23713,9 @@ export function createAgentChatService(args: { ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), }); persistChatState(managed); + if (runtime.pendingSteers.length && managed.runtime === runtime) { + await deliverNextQueuedSteer(managed, runtime); + } return; } @@ -24012,6 +24062,7 @@ export function createAgentChatService(args: { stallReconcileInFlight: new Set(), mcpStartupNoticeKeys: new Set(), pendingPlanFollowups: [], + pendingSteers: [], slashCommands: [], rateLimits: null, collaborationModes: null, @@ -24757,8 +24808,8 @@ export function createAgentChatService(args: { append: [ "## Runtime Environment", "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API. The `claude_code` preset above is the same system prompt the Claude Code CLI uses, so you may think you're in the CLI — you are NOT. You are inside an ADE-hosted SDK session.", - "**Wake-up semantics:** ADE durably supports Claude Code scheduled self-resume. `ScheduleWakeup`, `CronCreate`, and `/loop` can start a later unattended turn even after the ADE brain restarts; overdue work fires once when ADE resumes, then recurring cron work returns to its normal cadence. The user can pause this chat's schedules in Chat Info or pause all scheduled work in Settings.", - "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer the SDK's scheduled/background tools and include a concise reason/prompt so ADE can show the pending work clearly.", + "**Wake-up semantics:** Native `ScheduleWakeup`, `CronCreate`, and `/loop` are automatically mirrored into ADE's durable scheduler. `durable: true` also persists Claude's provider copy, while ADE's delivery guarantee does not depend on that flag. Jobs survive brain restarts and start a new turn at the next turn boundary even if the chat was busy when they became due. The SDK's own `CronList` view is advisory; ADE state wins. Pause schedules in Chat Info or project-wide in Settings. Recurring jobs expire seven days after creation. `CronCreate` always creates a new job, so replace one with `CronList` + `CronDelete` before creating another.", + "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer `ScheduleWakeup`, `CronCreate`, or `/loop` and include a concise reason/prompt so ADE can show the pending work clearly.", "", "## ADE Workspace", `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`, @@ -25190,7 +25241,7 @@ export function createAgentChatService(args: { const deliverNextQueuedSteer = async ( managed: ManagedChatSession, - runtime: ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime, + runtime: CodexRuntime | ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime, ): Promise => { if (managed.closed) return false; // A user-selected priority dispatch owns the staged queue while its SDK @@ -25217,7 +25268,7 @@ export function createAgentChatService(args: { turnId: runtime.activeTurnId ?? undefined, }); - runtime.interrupted = false; + if (runtime.kind !== "codex") runtime.interrupted = false; persistChatState(managed); // Re-resolve lane context so that a lane switch that occurred while the @@ -25260,7 +25311,18 @@ export function createAgentChatService(args: { buildChatContextAttachmentPrompt(nextSteer.contextAttachments) || null, ]); - if (runtime.kind === "claude") { + if (runtime.kind === "codex") { + await sendCodexMessage(managed, { + promptText, + userText: trimmed, + displayText, + attachments: nextSteer.attachments, + contextAttachments: nextSteer.contextAttachments, + resolvedAttachments: nextSteer.resolvedAttachments, + metadata: nextSteer.metadata, + laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, + }); + } else if (runtime.kind === "claude") { await runClaudeTurn(managed, { promptText, userText: trimmed, @@ -25314,7 +25376,7 @@ export function createAgentChatService(args: { /** Enqueue a steer or drop it if the queue is full. Returns true if queued. */ const enqueueSteerOrDrop = ( managed: ManagedChatSession, - runtime: ClaudeRuntime | OpenCodeRuntime, + runtime: ChatRuntime, sessionId: string, steerId: string, text: string, @@ -27797,7 +27859,7 @@ export function createAgentChatService(args: { }); if (args.managed.runtime?.kind === "claude") { await resetClaudeQuerySession(args.managed, args.managed.runtime, "session_reset", { clearSdkSessionId: true }); - await adoptClaudeProviderSessionId(args.managed, args.managed.runtime, placed.newSessionId); + adoptClaudeProviderSessionId(args.managed, args.managed.runtime, placed.newSessionId); args.managed.runtime.forkFromSdkSessionId = null; args.managed.runtimeInvalidated = false; } @@ -28417,7 +28479,7 @@ export function createAgentChatService(args: { const managed = ensureManagedSession(created.id); if (managed.runtime?.kind === "claude") { await resetClaudeQuerySession(managed, managed.runtime, "session_reset", { clearSdkSessionId: true }); - await adoptClaudeProviderSessionId(managed, managed.runtime, targetClaudeSessionId); + adoptClaudeProviderSessionId(managed, managed.runtime, targetClaudeSessionId); managed.runtime.forkFromSdkSessionId = null; managed.runtimeInvalidated = false; } @@ -32858,10 +32920,16 @@ export function createAgentChatService(args: { if (awaitingInputBefore && normalizedKind !== "wake") { throw new Error(`${PENDING_INPUT_SEND_BLOCKED_MESSAGE} Use chat.respondToInput for the pending input instead.`); } + const runtimeBusy = managed.runtime?.kind === "codex" + ? managed.runtime.activeTurnId != null + : managed.runtime?.busy === true; + const wakeNeedsQueue = normalizedKind === "wake" + && (statusBefore === "active" || runtimeBusy); const steerTarget = normalizedKind === "queue" || - ((normalizedKind === "auto" || normalizedKind === "wake") && statusBefore === "active"); + ((normalizedKind === "auto" || (normalizedKind === "wake" && !wakeNeedsQueue)) + && statusBefore === "active"); if (steerTarget) { const result = await steer({ sessionId, @@ -32885,6 +32953,55 @@ export function createAgentChatService(args: { }; } + if (wakeNeedsQueue) { + const runtime = managed.runtime; + if (!runtime) { + throw new Error("Scheduled wake delivery requires an active queueable chat runtime."); + } + const preparedWake = prepareSendMessage({ + sessionId, + text, + attachments, + contextAttachments, + metadata, + }); + if (!preparedWake) { + return { + sessionId, + kind: normalizedKind, + routedAction: "sendMessage", + statusBefore, + awaitingInputBefore, + delivery: "sent", + }; + } + const wakeId = randomUUID(); + const queued = enqueueSteerOrDrop( + managed, + runtime, + sessionId, + wakeId, + preparedWake.submittedText, + preparedWake.attachments, + preparedWake.contextAttachments, + preparedWake.resolvedAttachments, + preparedWake.metadata, + { displayText: preparedWake.visibleText }, + ); + if (!queued) { + throw new Error("The scheduled wake queue is full; the wake was not queued."); + } + return { + sessionId, + kind: normalizedKind, + routedAction: "sendMessage", + statusBefore, + awaitingInputBefore, + delivery: "queued", + queued: true, + }; + } + if (normalizedKind === "interrupt-replace") { if (managed.session.provider !== "claude") { await interrupt({ sessionId }); @@ -33860,7 +33977,7 @@ export function createAgentChatService(args: { if (managed.session.provider === "claude") { const runtime = ensureClaudeSessionRuntime(managed); resetClaudeQuerySession(managed, runtime, "session_reset"); - await adoptClaudeProviderSessionId(managed, runtime, originalThreadId); + adoptClaudeProviderSessionId(managed, runtime, originalThreadId); delete managed.session.continuityRecovery; managed.runtimeInvalidated = false; try { @@ -34416,6 +34533,65 @@ export function createAgentChatService(args: { ...(schedule.outcomeSummary ? { outcomeSummary: schedule.outcomeSummary } : {}), }); + const createScheduledWork = async ( + args: AgentChatCreateScheduledWorkArgs, + ): Promise => { + await scheduledWorkReady; + if (!scheduledWorkScheduler) { + throw new Error("Scheduled work is unavailable for this project runtime."); + } + + const normalizedSessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + if (!normalizedSessionId) throw new Error("Agent session id is required."); + const row = sessionService.get(normalizedSessionId); + if (!row || !isSchedulableAgentSession(row)) { + throw new Error(`Agent session '${normalizedSessionId}' was not found.`); + } + const chatBacked = isChatToolType(row.toolType); + const summary = chatBacked ? summarizeSessionRow(row) : null; + if (row.archivedAt || summary?.status === "ended") { + throw new Error(`Agent session '${normalizedSessionId}' is ended or archived.`); + } + + const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""; + if (!prompt) throw new Error("Scheduled work prompt is required."); + if (prompt.length > 4_000) { + throw new Error("Scheduled work prompt must be 4000 characters or fewer."); + } + const cron = typeof args.cron === "string" ? args.cron.trim() : ""; + const createdAt = Date.now(); + const fireAt = nextChatScheduledCronFireAt(cron, createdAt); + if (fireAt == null) { + throw new Error("Scheduled work cron must be a valid 5-field cron expression."); + } + if (args.recurring != null && typeof args.recurring !== "boolean") { + throw new Error("Scheduled work recurring must be a boolean."); + } + if (args.reason != null && typeof args.reason !== "string") { + throw new Error("Scheduled work reason must be a string."); + } + + const recurring = args.recurring !== false; + const reason = args.reason?.trim(); + const id = `action:${normalizedSessionId}:${randomUUID()}`; + if (chatBacked) ensureManagedSession(normalizedSessionId); + const schedule = await scheduledWorkScheduler.upsert({ + id, + sessionId: normalizedSessionId, + kind: recurring ? "cron" : "wakeup", + prompt, + ...(reason ? { reason } : {}), + cron, + fireAt, + createdAt, + ...(recurring ? { expiresAt: createdAt + claudeRecurringCronTtlMs } : {}), + status: "scheduled", + lateFlag: false, + durable: true, + }); + return { item: toScheduledWorkItem(schedule) }; + }; + const listScheduledWork = async ({ sessionId, includeTerminal = false, @@ -34424,8 +34600,8 @@ export function createAgentChatService(args: { const normalizedSessionId = sessionId?.trim(); if (normalizedSessionId) { const row = sessionService.get(normalizedSessionId); - if (!row || !isChatToolType(row.toolType)) { - throw new Error(`Chat session '${normalizedSessionId}' was not found.`); + if (!row || !isSchedulableAgentSession(row)) { + throw new Error(`Agent session '${normalizedSessionId}' was not found.`); } } return (scheduledWorkScheduler?.list(normalizedSessionId) ?? []) @@ -34433,6 +34609,29 @@ export function createAgentChatService(args: { .map(toScheduledWorkItem); }; + const getScheduledWorkState = async ({ + sessionId, + }: AgentChatGetScheduledWorkStateArgs): Promise => { + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId) throw new Error("Agent session id is required."); + const row = sessionService.get(normalizedSessionId); + if (!row || !isSchedulableAgentSession(row)) { + throw new Error(`Agent session '${normalizedSessionId}' was not found.`); + } + await scheduledWorkReady; + const nextWakeAt = scheduledWorkScheduler?.nextWakeAt(normalizedSessionId) ?? null; + return { + sessionId: normalizedSessionId, + paused: + scheduledWorkScheduler?.isSessionPaused(normalizedSessionId) === true + || projectConfigService.get().effective.ai?.chat?.scheduledWorkPaused === true, + nextWakeAt: nextWakeAt == null ? null : new Date(nextWakeAt).toISOString(), + items: (scheduledWorkScheduler?.list(normalizedSessionId) ?? []) + .filter((schedule) => schedule.status !== "done" && schedule.status !== "cancelled") + .map(toScheduledWorkItem), + }; + }; + const requestClaudeScheduledWorkCancellation = async ( schedules: ChatScheduledWorkRecord[], options: { awaitConfirmation: boolean }, @@ -34619,10 +34818,10 @@ export function createAgentChatService(args: { paused, }: AgentChatSetScheduledWorkPausedArgs): Promise => { const normalizedSessionId = sessionId.trim(); - if (!normalizedSessionId) throw new Error("Chat session id is required."); + if (!normalizedSessionId) throw new Error("Agent session id is required."); const row = sessionService.get(normalizedSessionId); - if (!row || !isChatToolType(row.toolType)) { - throw new Error(`Chat session '${normalizedSessionId}' was not found.`); + if (!row || !isSchedulableAgentSession(row)) { + throw new Error(`Agent session '${normalizedSessionId}' was not found.`); } await scheduledWorkReady; if (!scheduledWorkScheduler) { @@ -34644,9 +34843,6 @@ export function createAgentChatService(args: { await scheduledWorkScheduler?.refreshGlobalPause(); }; - const pendingNativeScheduledWakeCountForTesting = (sessionId: string): number => - pendingNativeScheduledWakeBySession.get(sessionId)?.length ?? 0; - const hasActiveWorkloads = (): boolean => { for (const managed of managedSessions.values()) { if (managed.closed || managed.deleted) continue; @@ -36093,7 +36289,6 @@ export function createAgentChatService(args: { } eventHistoryBySession.delete(trimmedSessionId); transcriptHistoryCacheBySession.delete(trimmedSessionId); - pendingNativeScheduledWakeBySession.delete(trimmedSessionId); lastPersistedPointerFingerprints.delete(trimmedSessionId); const persistedMetadataPath = metadataPathFor(trimmedSessionId); @@ -36158,7 +36353,6 @@ export function createAgentChatService(args: { // ignore shutdown errors } } - pendingNativeScheduledWakeBySession.clear(); flushAllQueuedTranscriptWrites(); claudeSubprocessReaper.reapAll("dispose_all"); }; @@ -36190,7 +36384,6 @@ export function createAgentChatService(args: { revokeBuiltInBrowserActorCapability(sessionId); } managedSessions.clear(); - pendingNativeScheduledWakeBySession.clear(); flushAllQueuedTranscriptWrites(); claudeSubprocessReaper.reapAll("force_dispose_all"); }; @@ -38864,31 +39057,57 @@ export function createAgentChatService(args: { const row = sessionService.get(sessionId); if (!row) return "missing"; if (row.archivedAt) return "archived"; + // A tracked agent CLI can be resumed by ptyService.sendToSession after + // its process exits, so an ended runtime is still a valid durable target. + if (row.tracked && isTrackedAgentCliToolType(row.toolType)) return "active"; if (row.status === "running" || row.status === "detached") return "active"; return "ended"; }, - fire: async (schedule, context) => { - const firedAt = new Date(schedule.lastFiredAt ?? Date.now()).toISOString(); + shouldDefer: (schedule) => { + const row = sessionService.get(schedule.sessionId); + if (row?.tracked && isTrackedAgentCliToolType(row.toolType)) { + // A quiet-output heuristic is not a turn boundary: a CLI can think for + // longer than the idle timer without printing. The PTY service checks + // provider-specific visible composer markers plus a short quiet window. + return ptyService?.canAcceptScheduledTurn(schedule.sessionId) !== true; + } const liveManaged = managedSessions.get(schedule.sessionId); const liveRuntime = liveManaged?.runtime?.kind === "claude" ? liveManaged.runtime : null; - if ( - liveManaged - && liveRuntime?.query - && !liveRuntime.busy - && liveManaged.session.status !== "active" - ) { - const pending = pendingNativeScheduledWakeBySession.get(schedule.sessionId) ?? []; - pending.push({ - scheduleId: schedule.id, - kind: schedule.kind, - prompt: schedule.prompt, - ...(schedule.reason ? { reason: schedule.reason } : {}), - firedAt, - late: context.late, - }); - pendingNativeScheduledWakeBySession.set(schedule.sessionId, pending); - startClaudeIdleReader(liveManaged, liveRuntime, "durable_schedule_fire"); - return; + // Never enqueue a scheduler-owned wake behind a foreground turn. Queued + // input is owned by that turn's runtime lifecycle and can be discarded + // when the turn fails or is interrupted. Keep the durable row armed and + // retry at the next boundary instead, regardless of which runtime or API + // created it. + if (liveManaged?.session.status === "active") return true; + return schedule.provider === "claude" + && liveRuntime?.query != null + && liveRuntime.busy; + }, + fire: async (schedule, context) => { + const firedAt = new Date(schedule.lastFiredAt ?? Date.now()).toISOString(); + const row = sessionService.get(schedule.sessionId); + if (row?.tracked && isTrackedAgentCliToolType(row.toolType)) { + if (!ptyService) { + return { retry: true }; + } + try { + await ptyService.sendToSession({ + sessionId: schedule.sessionId, + text: schedule.prompt, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isPtySendPreDeliveryError(error)) { + logger.warn("agent_chat.scheduled_cli_delivery_retry", { + sessionId: schedule.sessionId, + scheduleId: schedule.id, + error: message, + }); + return { retry: true }; + } + throw error; + } + return { complete: true }; } await messageSession({ sessionId: schedule.sessionId, @@ -38925,14 +39144,19 @@ export function createAgentChatService(args: { ?? (status === "fired" && (row.status === "running" || row.status === "detached") ? ensureManagedSession(schedule.sessionId) : null); - if (!managed || managed.session.provider !== "claude") return; + if (!managed) return; const eventStatus: ScheduledWorkEvent["status"] = status === "done" ? "completed" : status; - const origin: ScheduledWorkEvent["origin"] = schedule.kind === "cron" - ? "cron" - : schedule.kind === "loop" - ? "loop" - : "schedule_wakeup"; + let origin: ScheduledWorkEvent["origin"]; + if (schedule.id.startsWith("action:")) { + origin = "action"; + } else if (schedule.kind === "cron") { + origin = "cron"; + } else if (schedule.kind === "loop") { + origin = "loop"; + } else { + origin = "schedule_wakeup"; + } emitChatEvent(managed, { type: "scheduled_work_update", id: schedule.id, @@ -38990,11 +39214,12 @@ export function createAgentChatService(args: { markCrossMachineHandoff, sendMessage, messageSession, + createScheduledWork, listScheduledWork, + getScheduledWorkState, cancelScheduledWork, setScheduledWorkPaused, refreshScheduledWork, - pendingNativeScheduledWakeCountForTesting, readTranscript, setOrchestrationFields, getCodexGoal, diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts index d276080df..1af2fe676 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts @@ -320,10 +320,10 @@ describe("createChatScheduledWorkScheduler", () => { sessionState: () => sessionState, fire: createFireMock(), }); - await scheduler.upsert(wakeup({ fireAt: START })); + await scheduler.upsert(wakeup({ fireAt: START, provider: "claude" })); sessionState = "ended"; - const claimed = scheduler.claimNativeFire("session-1", "native-turn-ended"); + const claimed = scheduler.claimNativeFire("session-1", "native-turn-ended", "wake-1"); expect(claimed).toBeNull(); expect(scheduler.list("session-1")[0]).toEqual(expect.objectContaining({ @@ -658,9 +658,9 @@ describe("createChatScheduledWorkScheduler", () => { sessionState: () => "active", fire, }); - await scheduler.upsert(wakeup({ fireAt: START + 500 })); + await scheduler.upsert(wakeup({ fireAt: START + 500, provider: "claude" })); - const claimed = scheduler.claimNativeFire("session-1", "native-turn-1"); + const claimed = scheduler.claimNativeFire("session-1", "native-turn-1", "wake-1"); expect(claimed).toMatchObject({ id: "wake-1", status: "fired", @@ -676,6 +676,411 @@ describe("createChatScheduledWorkScheduler", () => { scheduler.dispose(); }); + it("gives Claude native fire a grace window and cancels the ADE backstop after a native claim", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = null; + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => cloneState(state), + saveState: (next) => { state = structuredClone(next); }, + isGlobalPaused: () => false, + sessionState: () => "active", + fire, + }); + await scheduler.upsert(wakeup({ + fireAt: START, + durable: true, + provider: "claude", + providerScheduleId: "native-grace-claim", + })); + + await vi.advanceTimersByTimeAsync(5_000); + expect(fire).not.toHaveBeenCalled(); + expect(scheduler.claimNativeFire("session-1", "native-turn-grace", "native-grace-claim")).toMatchObject({ + id: "wake-1", + status: "fired", + activeTurnId: "native-turn-grace", + }); + await vi.advanceTimersByTimeAsync(90_000); + + expect(fire).not.toHaveBeenCalled(); + scheduler.dispose(); + }); + + it("does not let a native Claude turn claim provider-neutral scheduled work", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire, + }); + await scheduler.upsert(wakeup({ + id: "action:session-1:schedule-1", + fireAt: START, + durable: true, + })); + + expect(scheduler.claimNativeFire("session-1", "unrelated-native-turn")).toBeNull(); + await vi.advanceTimersByTimeAsync(0); + + expect(fire).toHaveBeenCalledWith( + expect.objectContaining({ id: "action:session-1:schedule-1" }), + { late: false }, + ); + expect(fire.mock.calls[0]?.[0]).not.toHaveProperty("provider"); + scheduler.dispose(); + }); + + it("uses the Claude grace deadline for normal and genuinely late backstop fires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + const onTimeFire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire: onTimeFire, + }); + await scheduler.upsert(wakeup({ + fireAt: START, + durable: true, + provider: "claude", + providerScheduleId: "native-grace-backstop", + })); + + await vi.advanceTimersByTimeAsync(89_999); + expect(onTimeFire).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(onTimeFire).toHaveBeenCalledWith( + expect.objectContaining({ id: "wake-1", lateFlag: false }), + { late: false }, + ); + scheduler.dispose(); + + vi.setSystemTime(START + 91_001); + const lateFire = createFireMock(); + const lateScheduler = createChatScheduledWorkScheduler({ + loadState: () => storedState([wakeup({ + id: "wake-late", + fireAt: START, + durable: true, + provider: "claude", + providerScheduleId: "native-grace-late", + })]), + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire: lateFire, + }); + await lateScheduler.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(lateFire).toHaveBeenCalledWith( + expect.objectContaining({ id: "wake-late", lateFlag: true }), + { late: true }, + ); + lateScheduler.dispose(); + }); + + it("defers a busy Claude cron without advancing it and late-fires exactly once", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = null; + let delivered = 0; + let deferChecks = 0; + const shouldDefer = vi.fn(() => { + deferChecks += 1; + return deferChecks <= 2; + }); + const fire = vi.fn(async ( + _schedule: ChatScheduledWorkRecord, + _context: { late: boolean }, + ) => { delivered += 1; }); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: (next) => { state = structuredClone(next); }, + isGlobalPaused: () => false, + sessionState: () => "active", + shouldDefer, + fire, + }); + await scheduler.upsert(wakeup({ + id: "cron-deferred", + kind: "cron", + cron: "* * * * *", + fireAt: START, + lastFiredAt: START - 60_000, + lateFlag: true, + durable: true, + provider: "claude", + providerScheduleId: "cron-deferred", + })); + + await vi.advanceTimersByTimeAsync(90_000); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "scheduled", + fireAt: START, + lastFiredAt: START - 60_000, + lateFlag: true, + })); + await vi.advanceTimersByTimeAsync(20_000); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "scheduled", + fireAt: START, + lastFiredAt: START - 60_000, + lateFlag: true, + })); + await vi.advanceTimersByTimeAsync(20_000); + + expect(shouldDefer).toHaveBeenCalledTimes(3); + expect(fire).toHaveBeenCalledOnce(); + expect(fire.mock.calls[0]?.[1]).toEqual({ late: true }); + expect(delivered).toBe(1); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "scheduled", + fireAt: START + 180_000, + lastFiredAt: START + 130_000, + lateFlag: true, + })); + scheduler.dispose(); + }); + + it("lets a native claim win during the busy-defer retry window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = null; + const fire = vi.fn(async () => undefined); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: (next) => { state = structuredClone(next); }, + isGlobalPaused: () => false, + sessionState: () => "active", + shouldDefer: () => true, + fire, + }); + await scheduler.upsert(wakeup({ + fireAt: START, + durable: true, + provider: "claude", + providerScheduleId: "native-defer-claim", + })); + + await vi.advanceTimersByTimeAsync(90_000); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "scheduled", + fireAt: START, + })); + await vi.advanceTimersByTimeAsync(5_000); + expect(scheduler.claimNativeFire("session-1", "native-turn-after-defer", "native-defer-claim")).toMatchObject({ + id: "wake-1", + status: "fired", + activeTurnId: "native-turn-after-defer", + }); + await vi.advanceTimersByTimeAsync(20_000); + + expect(fire).not.toHaveBeenCalled(); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "fired", + activeTurnId: "native-turn-after-defer", + })); + scheduler.dispose(); + }); + + it("expires a repeatedly deferred Claude wake without delivering it", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = null; + const fire = vi.fn(async () => undefined); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: (next) => { state = structuredClone(next); }, + isGlobalPaused: () => false, + sessionState: () => "active", + shouldDefer: () => true, + fire, + }); + await scheduler.upsert(wakeup({ + fireAt: START, + expiresAt: START + 105_000, + durable: true, + provider: "claude", + providerScheduleId: "native-defer-expiry", + })); + + await vi.advanceTimersByTimeAsync(90_000); + expect(requireState(state).schedules[0]?.status).toBe("scheduled"); + await vi.advanceTimersByTimeAsync(15_000); + + expect(fire).not.toHaveBeenCalled(); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "cancelled", + terminalAt: START + 105_000, + })); + scheduler.dispose(); + }); + + it("fires non-Claude schedules at their exact fire time", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire, + }); + await scheduler.upsert(wakeup({ fireAt: START + 1_000 })); + + await vi.advanceTimersByTimeAsync(999); + expect(fire).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(fire).toHaveBeenCalledWith( + expect.objectContaining({ id: "wake-1", lateFlag: false }), + { late: false }, + ); + scheduler.dispose(); + }); + + it("completes a one-shot immediately when its delivery owns no tracked turn", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = null; + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: (next) => { state = structuredClone(next); }, + isGlobalPaused: () => false, + sessionState: () => "active", + fire: async () => ({ complete: true }), + }); + await scheduler.upsert(wakeup({ fireAt: START })); + + await vi.advanceTimersByTimeAsync(0); + + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "done", + lastFiredAt: START, + terminalAt: START, + })); + scheduler.dispose(); + }); + + it("restores an occurrence when delivery proves it never started", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = null; + const fire = vi.fn() + .mockResolvedValueOnce({ retry: true }) + .mockResolvedValueOnce({ complete: true }); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: (next) => { state = structuredClone(next); }, + isGlobalPaused: () => false, + sessionState: () => "active", + fire, + }); + await scheduler.upsert(wakeup({ + fireAt: START, + lastFiredAt: START - 60_000, + lateFlag: true, + })); + + await vi.advanceTimersByTimeAsync(0); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "scheduled", + fireAt: START, + lastFiredAt: START - 60_000, + lateFlag: true, + })); + + await vi.advanceTimersByTimeAsync(20_000); + expect(fire).toHaveBeenCalledTimes(2); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + status: "done", + lastFiredAt: START + 20_000, + })); + scheduler.dispose(); + }); + + it("claims only the provider schedule named by an explicit native signal", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire: createFireMock(), + }); + await scheduler.upsert(wakeup({ + id: "cron-a", + kind: "cron", + cron: "* * * * *", + fireAt: START, + provider: "claude", + providerScheduleId: "provider-a", + })); + await scheduler.upsert(wakeup({ + id: "cron-b", + kind: "cron", + cron: "* * * * *", + fireAt: START, + provider: "claude", + providerScheduleId: "provider-b", + })); + + expect(scheduler.claimNativeFire("session-1", "unrelated-turn", "missing-provider")).toBeNull(); + expect(scheduler.claimNativeFire("session-1", "native-turn-b", "provider-b")).toMatchObject({ + id: "cron-b", + activeTurnId: "native-turn-b", + }); + expect(scheduler.list().find((item) => item.id === "cron-a")?.status).toBe("scheduled"); + scheduler.dispose(); + }); + + it("limits an ambiguous native cron signal to CronCreate-owned schedules", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => "active", + fire: createFireMock(), + }); + await scheduler.upsert(wakeup({ + id: "native-wakeup", + kind: "wakeup", + fireAt: START - 500, + provider: "claude", + })); + await scheduler.upsert(wakeup({ + id: "native-cron", + kind: "cron", + cron: "* * * * *", + fireAt: START, + provider: "claude", + providerScheduleId: "provider-cron", + })); + + expect(scheduler.claimNativeFire("session-1", "ambiguous-cron-turn")).toMatchObject({ + id: "native-cron", + activeTurnId: "ambiguous-cron-turn", + }); + expect(scheduler.list().find((item) => item.id === "native-wakeup")?.status).toBe("scheduled"); + expect(scheduler.claimNativeFire("session-1", "explicit-wakeup-turn", "native-wakeup")).toMatchObject({ + id: "native-wakeup", + activeTurnId: "explicit-wakeup-turn", + }); + scheduler.dispose(); + }); + it("clears a native cron claim's active turn before re-arming", async () => { vi.useFakeTimers(); vi.setSystemTime(START); @@ -694,6 +1099,8 @@ describe("createChatScheduledWorkScheduler", () => { kind: "cron", cron: "* * * * *", fireAt: START + 500, + provider: "claude", + providerScheduleId: "provider-cron-1", })); expect(scheduler.claimNativeFire("session-1", "native-turn-1")).toMatchObject({ diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts index 2e71af85b..f478af302 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts @@ -2,6 +2,14 @@ import { nextCronFireAt as nextChatScheduledCronFireAt } from "../../../shared/c export { nextChatScheduledCronFireAt }; +/** + * Durable scheduled-work delivery for chat sessions. + * + * ADE state wins, SDK view is advisory: the mirrored ADE record is the source + * of truth for delivery. Claude's in-process scheduler is only a latency + * optimization, and its CronList view may drift after restarts or busy ticks. + */ + export type ChatScheduledWorkKind = "wakeup" | "cron" | "loop"; export type ChatScheduledWorkStatus = @@ -62,7 +70,11 @@ export type ChatScheduledWorkSchedulerOptions = { timers?: ChatScheduledWorkTimerApi; isGlobalPaused(): boolean; sessionState(sessionId: string): "active" | "ended" | "archived" | "missing"; - fire(schedule: ChatScheduledWorkRecord, context: { late: boolean }): Promise; + shouldDefer?(schedule: ChatScheduledWorkRecord): boolean; + fire( + schedule: ChatScheduledWorkRecord, + context: { late: boolean }, + ): Promise; onTransition?: ( schedule: ChatScheduledWorkRecord, status: ChatScheduledWorkStatus, @@ -80,17 +92,23 @@ export type ChatScheduledWorkScheduler = { list(sessionId?: string): ChatScheduledWorkRecord[]; isSessionPaused(sessionId: string): boolean; nextWakeAt(sessionId: string): number | null; - claimNativeFire(sessionId: string, turnId: string): ChatScheduledWorkRecord | null; + claimNativeFire(sessionId: string, turnId: string, scheduleId?: string): ChatScheduledWorkRecord | null; recordTurnStarted(scheduleId: string, turnId: string): Promise; recordTurnFinished(turnId: string, outcomeSummary?: string): Promise; }; const MAX_TIMER_DELAY_MS = 2_147_483_647; const TIMER_LATE_TOLERANCE_MS = 1_000; +const NATIVE_FIRE_GRACE_MS = 90_000; +const BUSY_DEFER_RETRY_MS = 20_000; const TERMINAL_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; const MAX_TERMINAL_HISTORY_RECORDS = 200; const LEGACY_PROVISIONAL_CRON_PREFIX = "cron-tool:"; +function nativeFireGraceMs(schedule: ChatScheduledWorkRecord): number { + return schedule.provider === "claude" ? NATIVE_FIRE_GRACE_MS : 0; +} + const defaultTimers: ChatScheduledWorkTimerApi = { setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), clearTimeout: (handle) => clearTimeout(handle as ReturnType), @@ -315,16 +333,32 @@ export function createChatScheduledWorkScheduler( return; } + // Busy targets stay claimable until their next safe turn boundary. This + // check must happen before the durable row transitions to `fired`: Claude's + // native turn can otherwise arrive in the small persistence window and + // miss claimNativeFire, leaving both native and ADE delivery active. + if (options.shouldDefer?.(cloneSchedule(schedule)) === true) { + armBusyDeferRetry(schedule); + return; + } + inFlight.add(scheduleId); - const late = overdueWhenArmed || currentTime - schedule.fireAt > TIMER_LATE_TOLERANCE_MS; + const lateThresholdMs = nativeFireGraceMs(schedule) + TIMER_LATE_TOLERANCE_MS; + const late = overdueWhenArmed || currentTime - schedule.fireAt > lateThresholdMs; + const previousLastFiredAt = schedule.lastFiredAt; + const previousLateFlag = schedule.lateFlag; schedule.status = "fired"; schedule.lastFiredAt = currentTime; schedule.lateFlag = late; await persist(); await emitTransition(schedule, "fired"); + let complete = false; + let retry = false; try { - await options.fire(cloneSchedule(schedule), { late }); + const result = await options.fire(cloneSchedule(schedule), { late }); + complete = result != null && "complete" in result && result.complete === true; + retry = result != null && "retry" in result && result.retry === true; } catch { // A delivery may have reached the session before its caller failed. Keep // one-shots fired and advance crons so a retry cannot double-deliver. @@ -334,7 +368,25 @@ export function createChatScheduledWorkScheduler( const current = schedules.get(scheduleId); if (!current || current.status === "cancelled" || current.status === "done") return; - if (current.kind !== "cron") return; + if (retry) { + current.status = isEffectivelyPaused(current) ? "paused" : "scheduled"; + if (previousLastFiredAt == null) delete current.lastFiredAt; + else current.lastFiredAt = previousLastFiredAt; + current.lateFlag = previousLateFlag; + delete current.activeTurnId; + await persist(); + await emitTransition(current, current.status); + if (current.status === "scheduled") armBusyDeferRetry(current); + return; + } + if (current.kind !== "cron") { + if (!complete) return; + markTerminal(current, "done"); + pruneTerminalHistory(); + await persist(); + await emitTransition(current, "done"); + return; + } current.fireAt = current.cron ? nextChatScheduledCronFireAt(current.cron, now()) ?? undefined @@ -345,18 +397,38 @@ export function createChatScheduledWorkScheduler( arm(current); }; + const armBusyDeferRetry = (schedule: ChatScheduledWorkRecord): void => { + clearTimer(schedule.id); + if (disposed || inFlight.has(schedule.id) || schedule.status !== "scheduled") return; + const retryAt = now() + BUSY_DEFER_RETRY_MS; + const nextAt = schedule.expiresAt == null + ? retryAt + : Math.min(retryAt, schedule.expiresAt); + const handle = timers.setTimeout(() => { + timerHandles.delete(schedule.id); + void processDue(schedule.id, false).catch(() => undefined); + }, Math.max(0, nextAt - now())); + timerHandles.set(schedule.id, handle); + }; + const arm = (schedule: ChatScheduledWorkRecord): void => { clearTimer(schedule.id); if (disposed || inFlight.has(schedule.id) || schedule.status !== "scheduled") return; + // Claude normally fires at fireAt (plus provider jitter). ADE waits through + // this grace window so claimNativeFire can claim the row first; this timer + // remains the backstop for busy, skipped, or dead provider processes. + const providerAdjustedFireAt = schedule.fireAt == null + ? undefined + : schedule.fireAt + nativeFireGraceMs(schedule); const nextAt = schedule.expiresAt == null - ? schedule.fireAt - : schedule.fireAt == null + ? providerAdjustedFireAt + : providerAdjustedFireAt == null ? schedule.expiresAt - : Math.min(schedule.fireAt, schedule.expiresAt); + : Math.min(providerAdjustedFireAt, schedule.expiresAt); if (nextAt == null || !Number.isFinite(nextAt)) return; const currentTime = now(); - const overdueWhenArmed = schedule.fireAt != null && schedule.fireAt < currentTime; + const overdueWhenArmed = providerAdjustedFireAt != null && providerAdjustedFireAt < currentTime; const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, nextAt - currentTime)); const handle = timers.setTimeout(() => { timerHandles.delete(schedule.id); @@ -590,12 +662,15 @@ export function createChatScheduledWorkScheduler( return next; }, - claimNativeFire(sessionId, turnId): ChatScheduledWorkRecord | null { + claimNativeFire(sessionId, turnId, scheduleId): ChatScheduledWorkRecord | null { if (disposed || options.isGlobalPaused() || pausedSessionIds.has(sessionId)) return null; const currentTime = now(); const schedule = [...schedules.values()] .filter((candidate) => candidate.sessionId === sessionId + && (scheduleId == null || candidate.id === scheduleId || candidate.providerScheduleId === scheduleId) + && (scheduleId != null || (candidate.kind === "cron" && candidate.providerScheduleId != null)) + && candidate.provider === "claude" && candidate.status === "scheduled" && !candidate.pausedFlag && !isExpired(candidate, currentTime) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 313b13ca8..5b17b0d4a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -344,6 +344,8 @@ import type { AgentChatUpdateSessionArgs, AgentChatSetScheduledWorkPausedArgs, AgentChatSetScheduledWorkPausedResult, + AgentChatCreateScheduledWorkArgs, + AgentChatCreateScheduledWorkResult, AgentChatListScheduledWorkArgs, AgentChatScheduledWorkItem, AgentChatCancelScheduledWorkArgs, @@ -6685,6 +6687,14 @@ export function registerIpc({ }, ); + ipcMain.handle( + IPC.agentChatCreateScheduledWork, + async (_event, arg: AgentChatCreateScheduledWorkArgs): Promise => { + const ctx = ensureAgentChatContext(); + return ctx.agentChatService.createScheduledWork(arg); + }, + ); + ipcMain.handle( IPC.agentChatListScheduledWork, async (_event, arg: AgentChatListScheduledWorkArgs): Promise => { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 9fe526307..a695cb64d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { IPty } from "node-pty"; import type * as TerminalSessionSignals from "../../utils/terminalSessionSignals"; import { buildOpenCodeReplayResumeCommand as buildCanonicalOpenCodeReplayResumeCommand } from "../../../shared/cliLaunch"; +import { isPtySendPreDeliveryError } from "../../../shared/types"; import { expectNoJargon } from "../../../test/jargonGuard"; // --------------------------------------------------------------------------- @@ -2318,6 +2319,73 @@ describe("ptyService", () => { expect(loadPty).not.toHaveBeenCalled(); }); + it("accepts scheduled CLI turns only at a visible provider composer boundary", async () => { + vi.useFakeTimers(); + try { + const { service, mockPty } = createHarness(); + const created = await service.create({ + sessionId: "session-scheduled-boundary", + allowNewSessionId: true, + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex", + }); + + expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(false); + mockPty._emitter.emit("data", "\x1b[2J\x1b[HOpenAI Codex\nmodel: gpt-5.4\n› "); + await vi.advanceTimersByTimeAsync(599); + expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(true); + + // Output silence alone must not make an in-progress model turn safe. + mockPty._emitter.emit("data", "\x1b[2J\x1b[HWorking on the request…"); + await vi.advanceTimersByTimeAsync(12_500); + expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(false); + + mockPty._emitter.emit("data", "\x1b[2J\x1b[HOpenAI Codex\nmodel: gpt-5.4\n› "); + await vi.advanceTimersByTimeAsync(600); + expect(service.canAcceptScheduledTurn(created.sessionId)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps peer-owned tracked CLIs unavailable for scheduled delivery", async () => { + const processRegistry = { + pid: 12_345, + startedAt: "2026-07-16T20:00:00.000Z", + isPidLive: vi.fn((pid: number) => pid === 99_999), + isProcessIdentityLive: vi.fn((pid: number, startedAt: string | null) => ( + pid === 99_999 && startedAt === "2026-07-16T20:01:00.000Z" + )), + }; + const { service, sessionService, loadPty } = createHarness({ processRegistry }); + sessionService.get.mockReturnValue({ + id: "peer-owned-cli", + laneId: "lane-1", + tracked: true, + toolType: "codex", + status: "running", + ownerPid: 99_999, + ownerProcessStartedAt: "2026-07-16T20:01:00.000Z", + }); + + expect(service.canAcceptScheduledTurn("peer-owned-cli")).toBe(false); + await expect(service.sendToSession({ + sessionId: "peer-owned-cli", + text: "check CI", + })).rejects.toThrow("owned by another live ADE runtime"); + expect(loadPty).not.toHaveBeenCalled(); + expect(processRegistry.isProcessIdentityLive).toHaveBeenCalledWith( + 99_999, + "2026-07-16T20:01:00.000Z", + ); + }); + it("sendToSession uses line-submit for Droid CLI sessions", async () => { const { service, mockPty } = createHarness(); const created = await service.create({ @@ -2405,10 +2473,13 @@ describe("ptyService", () => { }); sessionService.readTranscriptTail.mockResolvedValueOnce("normal transcript without updater text"); - await expect(service.sendToSession({ + const error = await service.sendToSession({ sessionId: `session-${provider}-targetless`, text: "keep going", - })).rejects.toThrow(new RegExp(`${expectedName} exited before ADE could capture a concrete resume target`)); + }).catch((cause: unknown) => cause); + expect(isPtySendPreDeliveryError(error)).toBe(true); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(new RegExp(`${expectedName} exited before ADE could capture a concrete resume target`)); expect(loadPty).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 73ee43def..576e7b1db 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -63,6 +63,10 @@ import type { TerminalSessionSummary, TerminalToolType, } from "../../../shared/types"; +import { + isTrackedAgentCliToolType, + PTY_SEND_PRE_DELIVERY_ERROR_CODE, +} from "../../../shared/types"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import { sanitizeTrackedCliPromptSeed, @@ -274,6 +278,12 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function ptySendPreDeliveryError( + message: string, +): Error & { code: typeof PTY_SEND_PRE_DELIVERY_ERROR_CODE } { + return Object.assign(new Error(message), { code: PTY_SEND_PRE_DELIVERY_ERROR_CODE }); +} + function hasEnvKey(env: NodeJS.ProcessEnv, key: string): boolean { return Object.prototype.hasOwnProperty.call(env, key); } @@ -815,17 +825,6 @@ function attributionRootKindForToolType(toolType: TerminalToolType | null): Reso return "provider-agent"; } -function isTrackedCliToolType(toolType: TerminalToolType | null): toolType is "claude" | "codex" | "cursor-cli" | "droid" | "opencode" | "claude-orchestrated" | "codex-orchestrated" | "opencode-orchestrated" { - return toolType === "claude" - || toolType === "codex" - || toolType === "cursor-cli" - || toolType === "droid" - || toolType === "opencode" - || toolType === "claude-orchestrated" - || toolType === "codex-orchestrated" - || toolType === "opencode-orchestrated"; -} - function isCodexTrackedCliToolType(toolType: TerminalToolType | null | undefined): toolType is "codex" | "codex-orchestrated" { return toolType === "codex" || toolType === "codex-orchestrated"; } @@ -2223,7 +2222,7 @@ export function createPtyService({ const session = sessionService.get(sessionId); if (!session?.tracked) return false; const effectiveToolType = preferredToolType ?? session.toolType ?? null; - if (!isTrackedCliToolType(effectiveToolType)) return false; + if (!isTrackedAgentCliToolType(effectiveToolType)) return false; const existingTargetId = sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null); if (existingTargetId) { const cwd = sessionCwd ?? inferSessionCwdFromTranscriptPath(session.transcriptPath); @@ -2610,7 +2609,7 @@ export function createPtyService({ if (!entry) return; if (entry.disposed) return; entry.disposed = true; - if (!entry.chatSessionId && isTrackedCliToolType(entry.toolTypeHint)) { + if (!entry.chatSessionId && isTrackedAgentCliToolType(entry.toolTypeHint)) { revokeBuiltInBrowserActorCapability(entry.sessionId); } if (entry.aiTitleTimer) { @@ -3127,28 +3126,34 @@ export function createPtyService({ ): Promise => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const live = liveEntryBySessionId(sessionId); - if (!live) return false; - const entry = live[1]; - if (entry.disposed) return false; - const outputTail = stripAnsi(entry.recentOutputTail).replace(/\r/g, "\n"); - const visibleText = entry.terminalSnapshot - ? visibleRowsFromTerminal(entry.terminalSnapshot.terminal) - .map((row) => row.text) - .join("\n") - : ""; - const readinessText = visibleText.trim().length > 0 ? visibleText : outputTail; - const runtime = runtimeStates.get(sessionId); - const quietForMs = runtime ? Date.now() - runtime.lastActivityAt : 0; - if (providerReadyMarkerVisible(provider, readinessText) && quietForMs >= AGENT_CLI_READY_QUIET_MS) { - return true; - } + if (agentCliInputReadyNow(sessionId, provider)) return true; + if (!liveEntryBySessionId(sessionId)) return false; await delay(AGENT_CLI_READY_POLL_MS); } logger.warn("pty.agent_cli_ready_wait_timeout", { sessionId, provider, timeoutMs }); return false; }; + const agentCliInputReadyNow = ( + sessionId: string, + provider: TerminalResumeProvider, + ): boolean => { + const live = liveEntryBySessionId(sessionId); + if (!live || live[1].disposed) return false; + const entry = live[1]; + const outputTail = stripAnsi(entry.recentOutputTail).replace(/\r/g, "\n"); + const visibleText = entry.terminalSnapshot + ? visibleRowsFromTerminal(entry.terminalSnapshot.terminal) + .map((row) => row.text) + .join("\n") + : ""; + const readinessText = visibleText.trim().length > 0 ? visibleText : outputTail; + const runtime = runtimeStates.get(sessionId); + const quietForMs = runtime ? Date.now() - runtime.lastActivityAt : 0; + return providerReadyMarkerVisible(provider, readinessText) + && quietForMs >= AGENT_CLI_READY_QUIET_MS; + }; + const writeAgentCliInput = async ( write: (data: string) => boolean, inputText: string, @@ -3259,10 +3264,10 @@ export function createPtyService({ action: "continued" | "resumed", ): void => { if (session?.tracked === false) { - throw new Error(`Terminal session '${sessionId}' is not tracked and cannot be ${action}.`); + throw ptySendPreDeliveryError(`Terminal session '${sessionId}' is not tracked and cannot be ${action}.`); } if (session && (session.toolType === "shell" || session.toolType === "run-shell" || isPersistedChatToolType(session.toolType))) { - throw new Error(`Terminal session '${sessionId}' is not an agent CLI session.`); + throw ptySendPreDeliveryError(`Terminal session '${sessionId}' is not an agent CLI session.`); } }; @@ -3270,14 +3275,14 @@ export function createPtyService({ sessionId: string, session: TerminalSessionSummary | null, ): Promise<{ session: TerminalSessionSummary; provider: TerminalResumeProvider }> => { - if (!session) throw new Error(`Terminal session '${sessionId}' was not found.`); + if (!session) throw ptySendPreDeliveryError(`Terminal session '${sessionId}' was not found.`); const provider = session.resumeMetadata?.provider ?? providerFromTool(session.toolType); - if (!provider) throw new Error(`Terminal session '${sessionId}' does not have a resumable CLI provider.`); + if (!provider) throw ptySendPreDeliveryError(`Terminal session '${sessionId}' does not have a resumable CLI provider.`); const throwMissingResumeTarget = (): never => { const displayName = resumeProviderDisplayName(provider); - throw new Error( + throw ptySendPreDeliveryError( `${displayName} exited before ADE could capture a concrete resume target. Start a new ${displayName} session.`, ); }; @@ -3291,7 +3296,7 @@ export function createPtyService({ let resolvedSession = session; let storedResumeTargetId = resumeTargetIdFor(resolvedSession); - if (!storedResumeTargetId && provider !== "cursor" && isTrackedCliToolType(resolvedSession.toolType)) { + if (!storedResumeTargetId && provider !== "cursor" && isTrackedAgentCliToolType(resolvedSession.toolType)) { const cwd = inferSessionCwdFromTranscriptPath(resolvedSession.transcriptPath); const backfilled = await tryBackfillResumeTarget(sessionId, resolvedSession.toolType, "resume-launch", cwd); const updatedSession = backfilled ? sessionService.get(sessionId) : null; @@ -3307,7 +3312,7 @@ export function createPtyService({ ) { const transcript = await sessionService.readTranscriptTail(resolvedSession.transcriptPath, 220_000); if (isCodexCliUpdateTranscript(transcript)) { - throw new Error( + throw ptySendPreDeliveryError( "Codex updated and exited before ADE could create a resumable thread. Start a new Codex session.", ); } @@ -3449,13 +3454,13 @@ export function createPtyService({ ? sessionService.get(requestedSessionId) : null; if (requestedSessionId.length && !existingSession && isResumeAttempt && !allowNewSessionId) { - throw new Error(`Terminal session '${requestedSessionId}' was not found.`); + throw ptySendPreDeliveryError(`Terminal session '${requestedSessionId}' was not found.`); } if (existingSession && existingSession.laneId !== laneId) { throw new Error(`Terminal session '${requestedSessionId}' belongs to lane '${existingSession.laneId}', not '${laneId}'.`); } if (existingSession && !existingSession.tracked) { - throw new Error(`Terminal session '${requestedSessionId}' is not tracked and cannot be resumed.`); + throw ptySendPreDeliveryError(`Terminal session '${requestedSessionId}' is not tracked and cannot be resumed.`); } const liveAttachedEntry = existingSession ? Array.from(ptys.entries()).find(([, entry]) => entry.sessionId === existingSession.id && !entry.disposed) @@ -3501,7 +3506,7 @@ export function createPtyService({ // Reaching here always spawns a NEW PTY/process — the live-attach case // returned above — so resuming a tracked CLI session whose PTY is gone // is a new launch and must be gated too, not only brand-new sessions. - if (tracked && isTrackedCliToolType(toolTypeHint)) { + if (tracked && isTrackedAgentCliToolType(toolTypeHint)) { const decision = diskPressureMonitor?.canPerform("cli_launch"); if (decision && !decision.allowed) { throw Object.assign(new Error(decision.message), { code: decision.code }); @@ -3644,7 +3649,7 @@ export function createPtyService({ projectRoot, laneId, chatSessionId, - ownerSessionId: isTrackedCliToolType(toolTypeHint) ? sessionId : null, + ownerSessionId: isTrackedAgentCliToolType(toolTypeHint) ? sessionId : null, }); let launchEnv = withInteractiveTerminalColorEnv( getAdeCliAgentEnv?.(contextLaunchEnv) ?? contextLaunchEnv, @@ -3655,7 +3660,7 @@ export function createPtyService({ }); const shouldBackfillResumeTarget = existingSession - && isTrackedCliToolType(toolTypeHint) + && isTrackedAgentCliToolType(toolTypeHint) && !sanitizeResumeTargetId(existingSession.resumeMetadata?.targetId ?? null); if (shouldBackfillResumeTarget) { const backfilled = await tryBackfillResumeTarget(sessionId, toolTypeHint, "resume-launch", cwd); @@ -4164,6 +4169,18 @@ export function createPtyService({ return { ptyId, sessionId, pid: pty.pid ?? null }; }, + canAcceptScheduledTurn(sessionId: string): boolean { + const normalizedSessionId = sessionId.trim(); + const session = normalizedSessionId ? sessionService.get(normalizedSessionId) : null; + if (!session?.tracked || !isTrackedAgentCliToolType(session.toolType)) return false; + if (isOwnedByLivePeerRuntime(session)) return false; + const live = liveEntryBySessionId(normalizedSessionId); + if (!live) return true; + const provider = session.resumeMetadata?.provider + ?? providerFromTool(session.toolType ?? live[1].toolTypeHint); + return provider != null && agentCliInputReadyNow(normalizedSessionId, provider); + }, + async sendToSession(args: PtySendToSessionArgs): Promise { const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; const text = typeof args.text === "string" ? args.text.trim() : ""; @@ -4172,6 +4189,11 @@ export function createPtyService({ const session = sessionService.get(sessionId); assertAgentCliSessionAction(sessionId, session, "continued"); + if (session && isOwnedByLivePeerRuntime(session)) { + throw ptySendPreDeliveryError( + `Terminal session '${sessionId}' is owned by another live ADE runtime.`, + ); + } const writeSubmittedText = async ( targetSessionId: string, inputText: string, @@ -4214,7 +4236,7 @@ export function createPtyService({ if (live) { const [ptyId, entry] = live; const provider = session?.resumeMetadata?.provider ?? providerFromTool(session?.toolType ?? entry.toolTypeHint); - if (!provider) throw new Error(`Terminal session '${sessionId}' does not have a resumable CLI provider.`); + if (!provider) throw ptySendPreDeliveryError(`Terminal session '${sessionId}' does not have a resumable CLI provider.`); const written = await writeSubmittedText(sessionId, text, provider, { waitForReady: provider === "cursor", }); @@ -4260,7 +4282,7 @@ export function createPtyService({ const promptAtLaunch = !openCodeReplayCommand && !resumeFlightAlreadyInProgress && builtResume.promptAtLaunch; const resumeCommand = openCodeReplayCommand ?? builtResume.command; if (!resumeCommand) { - throw new Error(`Terminal session '${sessionId}' does not have a resume command.`); + throw ptySendPreDeliveryError(`Terminal session '${sessionId}' does not have a resume command.`); } const { flight, created: resumeFlightCreated } = getOrCreateResumeFlight(resumableSession, resumeCommand, args); @@ -4958,7 +4980,7 @@ export function createPtyService({ // so stale sessions do not get stuck in a "running" state forever. const endedAt = new Date().toISOString(); sessionService.end({ sessionId, endedAt, exitCode: null, status: "disposed" }); - if (!session.chatSessionId && isTrackedCliToolType(session.toolType)) { + if (!session.chatSessionId && isTrackedAgentCliToolType(session.toolType)) { revokeBuiltInBrowserActorCapability(sessionId); } backfillResumeTargetFromTranscriptBestEffort(sessionId, session.toolType ?? null, "orphan-dispose"); @@ -4993,7 +5015,7 @@ export function createPtyService({ } if (entry.disposed) return { disposed: false, reason: "already-disposed" }; entry.disposed = true; - if (!entry.chatSessionId && isTrackedCliToolType(entry.toolTypeHint)) { + if (!entry.chatSessionId && isTrackedAgentCliToolType(entry.toolTypeHint)) { revokeBuiltInBrowserActorCapability(entry.sessionId); } if (entry.aiTitleTimer) { diff --git a/apps/desktop/src/main/services/usage/usageStatsStore.ts b/apps/desktop/src/main/services/usage/usageStatsStore.ts index fdbccba5a..31b8d4b3a 100644 --- a/apps/desktop/src/main/services/usage/usageStatsStore.ts +++ b/apps/desktop/src/main/services/usage/usageStatsStore.ts @@ -70,6 +70,7 @@ const MEANINGFUL_ACTIONS = new Set([ "chat.restart", "chat.handoff", "chat.rewindFiles", + "chat.createScheduledWork", "chat.cancelScheduledWork", "chat.delete", "chat.archive", @@ -137,7 +138,14 @@ const MEANINGFUL_ACTIONS = new Set([ export function usageActionFromIpcChannel(channel: string): string { const action = channel.replace(/^ade\./, ""); - if (action.startsWith("agentChat.")) return `chat.${action.slice("agentChat.".length)}`; + if (action.startsWith("agentChat.")) { + const chatAction = action.slice("agentChat.".length); + const aliases: Record = { + "scheduledWork.create": "createScheduledWork", + "scheduledWork.cancel": "cancelScheduledWork", + }; + return `chat.${aliases[chatAction] ?? chatAction}`; + } if (action === "pty.create") return "work.startCliSession"; if (action === "pty.resumeSession") return "work.resumeCliSession"; if (action === "pty.sendToSession") return "work.sendToSession"; diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index dc7f16d8b..8c69f3ed1 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -4240,7 +4240,9 @@ describe("ADE database usage aggregation", () => { expect(usageClientSurfaceFromRpcName("ade-web-client")).toBe("web"); expect(usageClientSurfaceFromPeer("phone", "ios")).toBe("mobile"); expect(usageActionFromIpcChannel("ade.agentChat.send")).toBe("chat.send"); - expect(usageActionFromIpcChannel("ade.agentChat.cancelScheduledWork")).toBe("chat.cancelScheduledWork"); + expect(usageActionFromIpcChannel("ade.agentChat.scheduledWork.create")).toBe("chat.createScheduledWork"); + expect(isMeaningfulUsageAction("chat.createScheduledWork")).toBe(true); + expect(usageActionFromIpcChannel("ade.agentChat.scheduledWork.cancel")).toBe("chat.cancelScheduledWork"); expect(isMeaningfulUsageAction("chat.cancelScheduledWork")).toBe(true); expect(usageActionFromIpcChannel("ade.pty.create")).toBe("work.startCliSession"); expect(usageActionFromRpcDomain("lane", "create")).toBe("lanes.create"); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 3008298c8..3282b56e4 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -144,6 +144,8 @@ import type { AgentChatSetClaudeOutputStyleArgs, AgentChatSetScheduledWorkPausedArgs, AgentChatSetScheduledWorkPausedResult, + AgentChatCreateScheduledWorkArgs, + AgentChatCreateScheduledWorkResult, AgentChatListScheduledWorkArgs, AgentChatScheduledWorkItem, AgentChatCancelScheduledWorkArgs, @@ -1444,6 +1446,9 @@ declare global { updateSession: ( args: AgentChatUpdateSessionArgs, ) => Promise; + createScheduledWork: ( + args: AgentChatCreateScheduledWorkArgs, + ) => Promise; listScheduledWork: ( args?: AgentChatListScheduledWorkArgs, ) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 623e40147..e11c3069e 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -5213,10 +5213,25 @@ describe("preload OAuth bridge", () => { await expect( bridge.agentChat.send({ sessionId: "session-1", text: "hello" }), ).rejects.toThrow(/Project is switching/i); + await expect( + bridge.agentChat.createScheduledWork({ + sessionId: "session-1", + prompt: "Post the status update", + cron: "0 9 * * 1-5", + }), + ).rejects.toThrow(/Project is switching/i); + await expect( + bridge.agentChat.setScheduledWorkPaused({ + sessionId: "session-1", + paused: true, + }), + ).rejects.toThrow(/Project is switching/i); expect(invoke).toHaveBeenCalledWith(IPC.projectSwitchToPath, { rootPath: "/next" }); expect(invoke).not.toHaveBeenCalledWith(IPC.appGetWindowSession); expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatSend, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatCreateScheduledWork, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatSetScheduledWorkPaused, expect.anything()); resolveSwitch({ rootPath: "/next", displayName: "Next", baseRef: "main" }); await pendingSwitch; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 810844764..37fb63652 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -342,6 +342,8 @@ import type { AgentChatSetClaudeOutputStyleArgs, AgentChatSetScheduledWorkPausedArgs, AgentChatSetScheduledWorkPausedResult, + AgentChatCreateScheduledWorkArgs, + AgentChatCreateScheduledWorkResult, AgentChatListScheduledWorkArgs, AgentChatScheduledWorkItem, AgentChatCancelScheduledWorkArgs, @@ -1266,7 +1268,9 @@ const MUTATING_CHAT_ACTIONS = new Set([ "setClaudeOutputStyle", "reloadClaudePlugins", "setParallelLaunchState", + "createScheduledWork", "cancelScheduledWork", + "setScheduledWorkPaused", "ensureCtoSession", "warmupModel", "rewindFiles", @@ -5529,6 +5533,19 @@ contextBridge.exposeInMainWorld("ade", { agentChatSummaryCache.clear(); return session as AgentChatSession; }, + createScheduledWork: async ( + args: AgentChatCreateScheduledWorkArgs, + ): Promise => { + agentChatSummaryCache.clear(); + const result = await callProjectRuntimeActionOr( + "chat", + "createScheduledWork", + { args }, + () => ipcRenderer.invoke(IPC.agentChatCreateScheduledWork, args), + ); + agentChatSummaryCache.clear(); + return result; + }, listScheduledWork: async ( args: AgentChatListScheduledWorkArgs = {}, ): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 1d5083fba..39ae77ecb 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -5028,6 +5028,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { unarchive: resolvedArg(undefined), delete: resolvedArg(undefined), updateSession: resolvedArg({ id: "mock" }), + createScheduledWork: async () => { + throw new Error("Scheduled work is unavailable in the browser preview."); + }, listScheduledWork: resolved([]), cancelScheduledWork: async () => { throw new Error("Scheduled work is unavailable in the browser preview."); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index f98b5dc48..bcaa9fb8a 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -517,6 +517,36 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(onCancelScheduledWork).toHaveBeenCalledWith(item); }); + it("renders and cancels an action-created providerless schedule", () => { + const onCancelScheduledWork = vi.fn(); + const item = scheduledSnapshot({ + id: "action:session-1:job-1", + kind: "cron", + origin: "action", + title: "Check CI", + prompt: "Inspect the latest CI run", + reason: "Keep the PR moving", + cron: "*/20 * * * *", + durable: true, + cancellable: true, + }); + render( + , + ); + + expect(screen.getByText("Check CI")).toBeTruthy(); + expect(screen.getByText("cron")).toBeTruthy(); + expect(screen.getByText("*/20 * * * * · Keep the PR moving")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Cancel Check CI" })); + expect(onCancelScheduledWork).toHaveBeenCalledWith(item); + }); + it("does not offer ADE cancellation for a provider-only schedule", () => { render( { it("lists and cancels an active durable job", async () => { installAdeMocks(); (window as any).ade.agentChat.listScheduledWork.mockResolvedValueOnce([{ - id: "wake-1", + id: "action:chat-12345678:job-1", sessionId: "chat-12345678", - kind: "wakeup", + kind: "cron", status: "scheduled", title: "Check PR CI", prompt: "Check PR CI", + cron: "*/20 * * * *", createdAt: "2026-07-14T00:00:00.000Z", durable: true, cancellable: true, @@ -197,7 +198,7 @@ describe("AiFeaturesSection", () => { await waitFor(() => { expect((window as any).ade.agentChat.cancelScheduledWork).toHaveBeenCalledWith({ sessionId: "chat-12345678", - scheduleId: "wake-1", + scheduleId: "action:chat-12345678:job-1", }); }); }); diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index d3385fe79..d52e16c88 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -424,7 +424,7 @@ export function AiFeaturesSection() { {item.title}
- {item.kind} · {item.status} · chat {item.sessionId.slice(0, 8)}{item.nextRunAt ? ` · ${new Date(item.nextRunAt).toLocaleString()}` : ""} + {item.kind} · {item.status} · session {item.sessionId.slice(0, 8)}{item.nextRunAt ? ` · ${new Date(item.nextRunAt).toLocaleString()}` : ""}