From d3e1fe48f656339847db9b1d4526e9c1e0443037 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Jul 2026 11:13:28 +0200 Subject: [PATCH] Scheduled and loop agents get workspaces; schedules UI speaks the user's timezone (#1909) * fix(app): schedules UI speaks the user's timezone * refactor(server): require explicit agent placement and bind create-agent command * fix(server): scheduled agents get a workspace and show in the sidebar * fix(server): loop agents get a workspace and show in the sidebar * fix(server): preserve scheduled and loop run semantics through the create command * fix(server): serialize all schedule record writes through the per-schedule queue * fix(server): tighten create-agent dependency contracts * fix(server): handle schedule restamps and loop cancellations * fix(server): preserve scheduled and loop create semantics * refactor(server): schedule store owns atomic mutations * fix(server): keep schedule stamp reuse deterministic * fix(server): handle loop workspace stamp rejection * refactor(server): schedule store owns typed identity upsert * fix(server): keep schedule workspace stamps server-owned * fix(server): preserve scheduled run semantics * refactor: required nullable workspaceId replaces placement union; pure cadence policy * fix(server): tighten scheduled and loop agent edges * fix(server): fail canceled scheduled runs * fix(server): close schedule and loop cwd edges * fix(server): refresh schedule workspace stamps * fix(server): preserve schedule timezone on replace * fix(server): close loop stop and cadence reset edges --- docs/data-model.md | 4 + .../components/schedules/cadence-editor.tsx | 79 +- packages/app/src/utils/device-timezone.ts | 3 + .../src/utils/schedule-cadence-policy.test.ts | 73 + .../app/src/utils/schedule-cadence-policy.ts | 14 + .../app/src/utils/schedule-format.test.ts | 37 +- packages/app/src/utils/schedule-format.ts | 16 +- .../protocol/src/schedule/rpc-schemas.test.ts | 26 + packages/protocol/src/schedule/rpc-schemas.ts | 6 +- packages/protocol/src/schedule/types.ts | 1 + .../server/src/server/agent/agent-loading.ts | 11 +- .../agent-manager-stream-coalescing.test.ts | 1 + .../src/server/agent/agent-manager.test.ts | 1252 +++++++---- .../server/src/server/agent/agent-manager.ts | 27 +- .../src/server/agent/agent-response-loop.ts | 14 +- .../server/agent/create-agent/create.test.ts | 49 + .../src/server/agent/create-agent/create.ts | 255 ++- .../src/server/agent/mcp-parity.e2e.test.ts | 16 +- .../src/server/agent/mcp-server.test.ts | 9 +- .../providers/mock-load-test-agent.test.ts | 1 + .../src/server/agent/rewind/rewind.test.ts | 12 +- packages/server/src/server/bootstrap.ts | 103 +- .../server/src/server/loop-service.test.ts | 517 ++++- packages/server/src/server/loop-service.ts | 198 +- .../src/server/schedule/service.test.ts | 1845 ++++++++++++++++- .../server/src/server/schedule/service.ts | 901 +++++--- .../server/src/server/schedule/store.test.ts | 195 +- packages/server/src/server/schedule/store.ts | 202 +- .../snapshot-mutation-ownership.test.ts | 14 +- 29 files changed, 4814 insertions(+), 1067 deletions(-) create mode 100644 packages/app/src/utils/device-timezone.ts create mode 100644 packages/app/src/utils/schedule-cadence-policy.test.ts create mode 100644 packages/app/src/utils/schedule-cadence-policy.ts create mode 100644 packages/protocol/src/schedule/rpc-schemas.test.ts diff --git a/docs/data-model.md b/docs/data-model.md index 7becabdc7..fc987d94e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,6 +4,10 @@ Paseo uses **file-based JSON persistence** instead of a traditional database. Al All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`). +## Store Surface Rules + +Store APIs own persistence atomicity and should not make services coordinate raw reads and writes. A good store method maps cleanly to one SQL statement or one SQL transaction, even when the current implementation is JSON files. If a caller needs a queue, lock, read-merge-write loop, or uniqueness race workaround, that behavior belongs behind the store surface. + --- ## Directory layout diff --git a/packages/app/src/components/schedules/cadence-editor.tsx b/packages/app/src/components/schedules/cadence-editor.tsx index 79b98e8d3..fe809effb 100644 --- a/packages/app/src/components/schedules/cadence-editor.tsx +++ b/packages/app/src/components/schedules/cadence-editor.tsx @@ -1,9 +1,19 @@ -import { type ReactNode, useCallback, useMemo, useReducer, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; import { Pressable, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { SegmentedControl } from "@/components/ui/segmented-control"; +import { getDeviceTimeZone } from "@/utils/device-timezone"; +import { nextCronCadence } from "@/utils/schedule-cadence-policy"; import { describeCron, everyMsToParts, @@ -20,8 +30,8 @@ interface CronPreset { expression: string; } -// 5-field expressions evaluated in UTC by the daemon. Each one round-trips -// through describeCron() so the chip and the live preview agree. +// 5-field expressions use the cadence timezone. Each one round-trips through +// describeCron() so the chip and the live preview agree. const CRON_PRESETS: CronPreset[] = [ { label: "Every hour", expression: "0 * * * *" }, { label: "Daily 9:00", expression: "0 9 * * *" }, @@ -57,6 +67,25 @@ function describeInterval(value: number, unit: IntervalUnit): string { return `Runs every ${value} ${noun}s`; } +function getCronPreview(expression: string, timezone: string, error: string | null): string | null { + if (error) { + return null; + } + if (!expression) { + return null; + } + + const described = describeCron({ type: "cron", expression, timezone }); + if (described) { + return described; + } + return expression; +} + +function intervalCadenceKey(cadence: Extract): string { + return `${cadence.type}:${cadence.everyMs}`; +} + export interface CadenceEditorProps { value: ScheduleCadence; onChange: (next: ScheduleCadence) => void; @@ -65,6 +94,13 @@ export interface CadenceEditorProps { export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) { const mode = value.type; + const deviceTimeZone = useMemo(getDeviceTimeZone, []); + const rememberedCronTimeZone = useRef( + value.type === "cron" ? (value.timezone ?? "UTC") : deviceTimeZone, + ); + const emittedIntervalCadenceKey = useRef(null); + const cronTimeZone = + value.type === "cron" ? (value.timezone ?? "UTC") : rememberedCronTimeZone.current; // The numeric/text fields are native-owned (AdaptiveTextInput). We seed them // once from the incoming cadence via lazy state initializers and bump @@ -88,6 +124,24 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) { value.type === "cron" ? value.expression : DEFAULT_CRON_EXPRESSION, ); + useEffect(() => { + if (value.type === "cron") { + emittedIntervalCadenceKey.current = null; + rememberedCronTimeZone.current = value.timezone ?? "UTC"; + lastCronExpression.current = value.expression; + return; + } + const cadenceKey = intervalCadenceKey(value); + if (emittedIntervalCadenceKey.current === cadenceKey) { + emittedIntervalCadenceKey.current = null; + return; + } + rememberedCronTimeZone.current = deviceTimeZone; + lastCronExpression.current = DEFAULT_CRON_EXPRESSION; + setCronText(DEFAULT_CRON_EXPRESSION); + bumpFieldResetKey(); + }, [deviceTimeZone, value]); + const parsedIntervalValue = useMemo(() => { const parsed = Number.parseInt(intervalValueText, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; @@ -95,17 +149,24 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) { const emitInterval = useCallback( (rawValue: number, unit: IntervalUnit) => { - onChange({ type: "every", everyMs: partsToEveryMs(rawValue, unit) }); + if (value.type === "cron") { + rememberedCronTimeZone.current = value.timezone ?? "UTC"; + } + const next = { type: "every" as const, everyMs: partsToEveryMs(rawValue, unit) }; + emittedIntervalCadenceKey.current = intervalCadenceKey(next); + onChange(next); }, - [onChange], + [onChange, value], ); const emitCron = useCallback( (expression: string) => { lastCronExpression.current = expression; - onChange({ type: "cron", expression }); + const next = nextCronCadence(value, expression, rememberedCronTimeZone.current); + rememberedCronTimeZone.current = next.timezone ?? "UTC"; + onChange(next); }, - [onChange], + [onChange, value], ); const handleModeChange = useCallback( @@ -161,7 +222,7 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) { const intervalPreview = describeInterval(parsedIntervalValue, intervalUnit); const trimmedCron = cronText.trim(); const cronError = trimmedCron ? validateCron(trimmedCron) : null; - const cronPreview = cronError ? null : (describeCron(trimmedCron) ?? trimmedCron); + const cronPreview = getCronPreview(trimmedCron, cronTimeZone, cronError); let cronFeedback: ReactNode = null; if (cronError) { @@ -231,7 +292,7 @@ export function CadenceEditor({ value, onChange, error }: CadenceEditorProps) { style={styles.cronInput} /> {cronFeedback} - Times are in UTC + Times are in {cronTimeZone} )} diff --git a/packages/app/src/utils/device-timezone.ts b/packages/app/src/utils/device-timezone.ts new file mode 100644 index 000000000..522d88b01 --- /dev/null +++ b/packages/app/src/utils/device-timezone.ts @@ -0,0 +1,3 @@ +export function getDeviceTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} diff --git a/packages/app/src/utils/schedule-cadence-policy.test.ts b/packages/app/src/utils/schedule-cadence-policy.test.ts new file mode 100644 index 000000000..d07356836 --- /dev/null +++ b/packages/app/src/utils/schedule-cadence-policy.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { nextCronCadence } from "./schedule-cadence-policy"; + +describe("nextCronCadence", () => { + it("preserves an existing cron cadence timezone when editing the expression", () => { + expect( + nextCronCadence( + { + type: "cron", + expression: "0 9 * * *", + timezone: "America/New_York", + }, + "30 9 * * *", + "America/Los_Angeles", + ), + ).toEqual({ + type: "cron", + expression: "30 9 * * *", + timezone: "America/New_York", + }); + }); + + it("emits UTC for legacy cron cadences without a timezone", () => { + expect( + nextCronCadence( + { + type: "cron", + expression: "0 9 * * *", + }, + "30 9 * * *", + "America/Los_Angeles", + ), + ).toEqual({ + type: "cron", + expression: "30 9 * * *", + timezone: "UTC", + }); + }); + + it("uses the device timezone when switching from interval to cron", () => { + expect( + nextCronCadence( + { + type: "every", + everyMs: 60 * 60_000, + }, + "0 9 * * *", + "America/Los_Angeles", + ), + ).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "America/Los_Angeles", + }); + }); + + it("lets callers preserve a remembered cron timezone when toggling back from interval", () => { + expect( + nextCronCadence( + { + type: "every", + everyMs: 60 * 60_000, + }, + "0 9 * * *", + "America/New_York", + ), + ).toEqual({ + type: "cron", + expression: "0 9 * * *", + timezone: "America/New_York", + }); + }); +}); diff --git a/packages/app/src/utils/schedule-cadence-policy.ts b/packages/app/src/utils/schedule-cadence-policy.ts new file mode 100644 index 000000000..590ef9007 --- /dev/null +++ b/packages/app/src/utils/schedule-cadence-policy.ts @@ -0,0 +1,14 @@ +import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types"; + +type CronCadence = Extract; + +export function nextCronCadence( + current: ScheduleCadence, + expression: string, + deviceTimeZone: string, +): CronCadence { + if (current.type === "cron") { + return { type: "cron", expression, timezone: current.timezone ?? "UTC" }; + } + return { type: "cron", expression, timezone: deviceTimeZone }; +} diff --git a/packages/app/src/utils/schedule-format.test.ts b/packages/app/src/utils/schedule-format.test.ts index b9d15581f..8bc4ea6aa 100644 --- a/packages/app/src/utils/schedule-format.test.ts +++ b/packages/app/src/utils/schedule-format.test.ts @@ -85,17 +85,38 @@ describe("interval formatting", () => { describe("describeCron", () => { it("humanizes common fixed-time cron shapes", () => { - expect(describeCron("0 * * * *")).toBe("Every hour"); - expect(describeCron("15 * * * *")).toBe("Every hour at :15"); - expect(describeCron("0 9 * * *")).toBe("Daily at 09:00 UTC"); - expect(describeCron("0 9 * * 1-5")).toBe("Weekdays at 09:00 UTC"); - expect(describeCron("0 9 * * 0,6")).toBe("Weekends at 09:00 UTC"); - expect(describeCron("0 9 * * 1")).toBe("Mondays at 09:00 UTC"); + expect(describeCron({ type: "cron", expression: "0 * * * *" })).toBe("Every hour"); + expect(describeCron({ type: "cron", expression: "15 * * * *" })).toBe("Every hour at :15"); + expect(describeCron({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC"); + expect(describeCron({ type: "cron", expression: "0 9 * * 1-5" })).toBe("Weekdays at 09:00 UTC"); + expect(describeCron({ type: "cron", expression: "0 9 * * 0,6" })).toBe("Weekends at 09:00 UTC"); + expect(describeCron({ type: "cron", expression: "0 9 * * 1" })).toBe("Mondays at 09:00 UTC"); + }); + + it("labels fixed-time cron cadences with their stored timezone", () => { + expect( + describeCron({ + type: "cron", + expression: "0 9 * * *", + timezone: "America/New_York", + }), + ).toBe("Daily at 09:00 America/New_York"); + expect( + formatCadence({ + type: "cron", + expression: "0 9 * * 1-5", + timezone: "Europe/Madrid", + }), + ).toBe("Weekdays at 09:00 Europe/Madrid"); + }); + + it("keeps timezone-less fixed-time cron cadences labeled as UTC", () => { + expect(formatCadence({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC"); }); it("returns null for invalid or unrecognized valid cron expressions", () => { - expect(describeCron("not a cron")).toBeNull(); - expect(describeCron("*/5 * * * *")).toBeNull(); + expect(describeCron({ type: "cron", expression: "not a cron" })).toBeNull(); + expect(describeCron({ type: "cron", expression: "*/5 * * * *" })).toBeNull(); expect(formatCadence({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC"); }); }); diff --git a/packages/app/src/utils/schedule-format.ts b/packages/app/src/utils/schedule-format.ts index 6fd816c35..f915927a1 100644 --- a/packages/app/src/utils/schedule-format.ts +++ b/packages/app/src/utils/schedule-format.ts @@ -2,6 +2,7 @@ import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedu import { validateCronExpression } from "@getpaseo/protocol/schedule/cron-expression"; export type IntervalUnit = "minutes" | "hours" | "days"; +type CronCadence = Extract; const MS_PER_MINUTE = 60_000; const MS_PER_HOUR = MS_PER_MINUTE * 60; @@ -82,7 +83,7 @@ export function formatCadence(cadence: ScheduleCadence): string { if (cadence.type === "every") { return formatEvery(cadence.everyMs); } - return describeCron(cadence.expression) ?? cadence.expression; + return describeCron(cadence) ?? cadence.expression; } /** @@ -90,8 +91,8 @@ export function formatCadence(cadence: ScheduleCadence): string { * expression is valid but not one of the recognized patterns (callers fall * back to showing the raw expression). */ -export function describeCron(expr: string): string | null { - const trimmed = expr.trim(); +export function describeCron(cadence: CronCadence): string | null { + const trimmed = cadence.expression.trim(); if (validateCron(trimmed) !== null) { return null; } @@ -121,20 +122,21 @@ export function describeCron(expr: string): string | null { return null; } const time = `${pad2(Number.parseInt(hour, 10))}:${pad2(minuteNum)}`; + const timezone = cadence.timezone ?? "UTC"; if (dayOfWeek === "*") { - return `Daily at ${time} UTC`; + return `Daily at ${time} ${timezone}`; } if (dayOfWeek === "1-5") { - return `Weekdays at ${time} UTC`; + return `Weekdays at ${time} ${timezone}`; } if (dayOfWeek === "0,6" || dayOfWeek === "6,0") { - return `Weekends at ${time} UTC`; + return `Weekends at ${time} ${timezone}`; } if (/^\d$/.test(dayOfWeek)) { const day = DAY_NAMES[Number.parseInt(dayOfWeek, 10)]; if (day) { - return `${day}s at ${time} UTC`; + return `${day}s at ${time} ${timezone}`; } } return null; diff --git a/packages/protocol/src/schedule/rpc-schemas.test.ts b/packages/protocol/src/schedule/rpc-schemas.test.ts new file mode 100644 index 000000000..da40ac740 --- /dev/null +++ b/packages/protocol/src/schedule/rpc-schemas.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { ScheduleCreateRequestSchema } from "./rpc-schemas.js"; + +describe("schedule RPC schemas", () => { + it("keeps new-agent workspace stamps out of create requests", () => { + const parsed = ScheduleCreateRequestSchema.parse({ + type: "schedule/create", + requestId: "request-1", + prompt: "Run the task", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + cwd: "/tmp/project", + workspaceId: "client-owned-workspace", + }, + }, + }); + + expect(parsed.target.type).toBe("new-agent"); + if (parsed.target.type === "new-agent") { + expect(parsed.target.config).not.toHaveProperty("workspaceId"); + } + }); +}); diff --git a/packages/protocol/src/schedule/rpc-schemas.ts b/packages/protocol/src/schedule/rpc-schemas.ts index 618f768d7..9a426f2d3 100644 --- a/packages/protocol/src/schedule/rpc-schemas.ts +++ b/packages/protocol/src/schedule/rpc-schemas.ts @@ -7,6 +7,10 @@ import { ScheduleTargetSchema, } from "./types.js"; +const ScheduleCreateNewAgentConfigSchema = ScheduleTargetSchema.options[1].shape.config.omit({ + workspaceId: true, +}); + const ScheduleCreateTargetSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("self"), @@ -18,7 +22,7 @@ const ScheduleCreateTargetSchema = z.discriminatedUnion("type", [ }), z.object({ type: z.literal("new-agent"), - config: ScheduleTargetSchema.options[1].shape.config, + config: ScheduleCreateNewAgentConfigSchema, }), ]); diff --git a/packages/protocol/src/schedule/types.ts b/packages/protocol/src/schedule/types.ts index ff41fa5df..e5cfbd16d 100644 --- a/packages/protocol/src/schedule/types.ts +++ b/packages/protocol/src/schedule/types.ts @@ -27,6 +27,7 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [ config: z.object({ provider: AgentProviderSchema, cwd: z.string().trim().min(1), + workspaceId: z.string().optional(), modeId: z.string().trim().min(1).optional(), model: z.string().trim().min(1).optional(), thinkingOptionId: z.string().trim().min(1).optional(), diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index cf6e521f1..578502195 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -13,8 +13,17 @@ import { const pendingAgentInitializations = new Map>(); +type AgentLoaderManager = Pick< + AgentManager, + | "createAgent" + | "getAgent" + | "getRegisteredProviderIds" + | "hydrateTimelineFromProvider" + | "resumeAgentFromPersistence" +>; + export interface EnsureAgentLoadedDeps { - agentManager: AgentManager; + agentManager: AgentLoaderManager; agentStorage: AgentStorage; validProviders?: Iterable; logger: Logger; diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 6db44fcda..b34e9a31b 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -278,6 +278,7 @@ async function createManagedSession( cwd: workdir, }, agentId, + { workspaceId: undefined }, ); return { agentId, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 02311cdb3..7635c58fc 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -462,10 +462,14 @@ test("normalizeConfig injects the provider default model when omitted", async () idFactory: () => "00000000-0000-4000-8000-000000000101", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); expect(snapshot.config.model).toBe("gpt-5.4"); expect(snapshot.config.modeId).toBe("auto"); @@ -493,6 +497,7 @@ test("createAgent forwards request env into the spawned provider process", async env: { CHUNK14_PROBE: "expected", }, + workspaceId: undefined, }, ); @@ -518,11 +523,15 @@ test("normalizeConfig strips legacy 'default' model id", async () => { idFactory: () => "00000000-0000-4000-8000-000000000102", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - model: "default", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + model: "default", + }, + undefined, + { workspaceId: undefined }, + ); expect(snapshot.config.model).toBe("gpt-5.4"); expect(snapshot.config.modeId).toBe("auto"); @@ -742,11 +751,15 @@ test("createAgent injects daemon append system prompt at runtime only", async () idFactory: () => "00000000-0000-4000-8000-000000000103", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - systemPrompt: "Agent instructions.", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + systemPrompt: "Agent instructions.", + }, + undefined, + { workspaceId: undefined }, + ); const record = await storage.get(snapshot.id); expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions."); @@ -774,11 +787,15 @@ test("daemon append system prompt is injected into Pi configs", async () => { idFactory: () => "00000000-0000-4000-8000-000000000104", }); - await manager.createAgent({ - provider: "pi", - cwd: workdir, - systemPrompt: "Agent instructions.", - }); + await manager.createAgent( + { + provider: "pi", + cwd: workdir, + systemPrompt: "Agent instructions.", + }, + undefined, + { workspaceId: undefined }, + ); expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions."); }); @@ -888,11 +905,15 @@ test("setAgentMode persists the selected mode across session reload", async () = idFactory: () => "00000000-0000-4000-8000-000000000301", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - modeId: "auto", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + modeId: "auto", + }, + undefined, + { workspaceId: undefined }, + ); await manager.setAgentMode(snapshot.id, "full-access"); @@ -954,10 +975,14 @@ test("reloadAgentSession completes when the previous session close hangs", async }); try { - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const reloaded = await manager.reloadAgentSession(snapshot.id); @@ -1006,10 +1031,14 @@ test("cancelAgentRun completes when provider interrupt hangs", async () => { }); try { - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await new Promise((resolve) => { const unsubscribe = manager.subscribe( @@ -1099,10 +1128,14 @@ test("createAgent passes daemon launch env through the provider launch context", idFactory: () => "00000000-0000-4000-8000-000000000103", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); expect(client.lastConfig).toEqual({ provider: "codex", @@ -1152,7 +1185,7 @@ test("createAgent passes persistSession to provider create options", async () => cwd: workdir, }, undefined, - { persistSession: false }, + { persistSession: false, workspaceId: undefined }, ); expect(client.lastCreateOptions).toEqual({ persistSession: false }); @@ -1218,16 +1251,20 @@ test("createAgent injects paseo MCP server only into provider launch config", as idFactory: () => "00000000-0000-4000-8000-000000000103", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - mcpServers: { - custom: { - type: "stdio", - command: "custom-mcp", + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + mcpServers: { + custom: { + type: "stdio", + command: "custom-mcp", + }, }, }, - }); + undefined, + { workspaceId: undefined }, + ); expect(snapshot.config.mcpServers).toEqual({ custom: { @@ -1299,16 +1336,20 @@ test("createAgent passes native Paseo tools through launch context without inter idFactory: () => "00000000-0000-4000-8000-000000000106", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - mcpServers: { - custom: { - type: "stdio", - command: "custom-mcp", + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + mcpServers: { + custom: { + type: "stdio", + command: "custom-mcp", + }, }, }, - }); + undefined, + { workspaceId: undefined }, + ); expect(client.lastLaunchContext?.paseoTools).toBe(paseoTools); expect(client.lastConfig?.mcpServers).toEqual({ @@ -1359,10 +1400,14 @@ test("createAgent injects the MCP auth token as a bearer header into the launch idFactory: () => "00000000-0000-4000-8000-000000000104", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); expect(manager.getMcpAuthToken()).toBe("cap-token"); expect(client.lastConfig?.mcpServers?.paseo).toEqual({ @@ -1487,16 +1532,20 @@ test("createAgent preserves a user-provided paseo MCP config", async () => { idFactory: () => "00000000-0000-4000-8000-000000000104", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - mcpServers: { - paseo: { - type: "http", - url: "https://example.com/custom-paseo", + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + mcpServers: { + paseo: { + type: "http", + url: "https://example.com/custom-paseo", + }, }, }, - }); + undefined, + { workspaceId: undefined }, + ); expect(snapshot.config.mcpServers).toEqual({ paseo: { @@ -1520,10 +1569,14 @@ test("createAgent fails when cwd does not exist", async () => { }); await expect( - manager.createAgent({ - provider: "codex", - cwd: join(workdir, "does-not-exist"), - }), + manager.createAgent( + { + provider: "codex", + cwd: join(workdir, "does-not-exist"), + }, + undefined, + { workspaceId: undefined }, + ), ).rejects.toThrow("Working directory does not exist"); }); @@ -1540,10 +1593,14 @@ test("createAgent reports configured providers when provider is unknown", async }); await expect( - manager.createAgent({ - provider: "missing-provider", - cwd: workdir, - }), + manager.createAgent( + { + provider: "missing-provider", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ), ).rejects.toThrow("Unknown provider 'missing-provider'. Configured providers: codex."); }); @@ -1568,10 +1625,14 @@ test("createAgent reports available providers when selected provider is unavaila }); await expect( - manager.createAgent({ - provider: "codex", - cwd: workdir, - }), + manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ), ).rejects.toThrow( "Provider 'codex' is not available. Available providers: claude. Use one of those providers, or install/configure 'codex'.", ); @@ -1607,10 +1668,14 @@ test("createAgent rejects a disabled provider without creating a session", async }); await expect( - manager.createAgent({ - provider: "codex", - cwd: workdir, - }), + manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ), ).rejects.toThrow("Provider 'codex' is disabled"); expect(disabledClient.createSessionCalls).toBe(0); expect(await storage.list()).toHaveLength(0); @@ -1630,16 +1695,20 @@ test("updateProviderRegistry re-enables a previously disabled provider", async ( logger, }); - await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow( - "Provider 'codex' is disabled", - ); + await expect( + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }), + ).rejects.toThrow("Provider 'codex' is disabled"); manager.updateProviderRegistry({ providerDefinitions: { codex: { enabled: true } }, clients: { codex: client }, }); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); expect(snapshot.config.provider).toBe("codex"); }); @@ -1662,9 +1731,11 @@ test("updateProviderRegistry disables a previously enabled provider", async () = clients: { codex: client }, }); - await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow( - "Provider 'codex' is disabled", - ); + await expect( + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }), + ).rejects.toThrow("Provider 'codex' is disabled"); }); test("updateProviderRegistry registers a previously unknown provider", async () => { @@ -1686,7 +1757,9 @@ test("updateProviderRegistry registers a previously unknown provider", async () }); expect(manager.getRegisteredProviderIds()).toContain("codex"); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); expect(snapshot.config.provider).toBe("codex"); }); @@ -1711,11 +1784,15 @@ test("createAgent passes explicit model strings through to the provider", async logger, }); - await manager.createAgent({ - provider: "codex", - cwd: workdir, - model: "not-a-real-model", - }); + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + model: "not-a-real-model", + }, + undefined, + { workspaceId: undefined }, + ); expect(client.lastConfig?.model).toBe("not-a-real-model"); }); @@ -1966,10 +2043,14 @@ test("reloadAgentSession passes daemon launch env through the provider launch co idFactory: () => "00000000-0000-4000-8000-000000000108", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); expect(client.lastCreateLaunchContext).toEqual({ agentId: snapshot.id, @@ -2051,10 +2132,14 @@ test("reloadAgentSession preserves timeline and does not force history replay", idFactory: () => "00000000-0000-4000-8000-000000000113", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", @@ -2089,10 +2174,14 @@ test("reloadAgentSession preserves current title when config title is unset", as idFactory: () => "00000000-0000-4000-8000-000000000126", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.setTitle(snapshot.id, "Generated title"); const beforeReload = await storage.get(snapshot.id); @@ -2119,10 +2208,14 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a idFactory: () => "00000000-0000-4000-8000-000000000127", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const before = await storage.get(snapshot.id); expect(before).not.toBeNull(); @@ -2151,10 +2244,14 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => { idFactory: () => "00000000-0000-4000-8000-000000000128", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.closeAgent(snapshot.id); const closed = await storage.get(snapshot.id); @@ -2190,13 +2287,17 @@ test("persists live mode, model, and thinking changes without an external snapsh idFactory: () => "00000000-0000-4000-8000-000000000132", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - modeId: "plan", - model: "gpt-5.2-codex", - thinkingOptionId: "low", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + modeId: "plan", + model: "gpt-5.2-codex", + thinkingOptionId: "low", + }, + undefined, + { workspaceId: undefined }, + ); await manager.setAgentMode(snapshot.id, "build"); await manager.setAgentModel(snapshot.id, "gpt-5.4"); @@ -2230,13 +2331,17 @@ test("session config drift events update state through the stream channel", asyn idFactory: () => "00000000-0000-4000-8000-000000000133", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - modeId: "plan", - model: "gpt-5.2-codex", - thinkingOptionId: "low", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + modeId: "plan", + model: "gpt-5.2-codex", + thinkingOptionId: "low", + }, + undefined, + { workspaceId: undefined }, + ); const streams: AgentStreamEvent[] = []; manager.subscribe( (event) => { @@ -2301,11 +2406,15 @@ test("setLabels merges and persists labels", async () => { idFactory: () => "00000000-0000-4000-8000-000000000133", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Label test", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Label test", + }, + undefined, + { workspaceId: undefined }, + ); await manager.setLabels(snapshot.id, { surface: "mobile" }); await manager.setLabels(snapshot.id, { phase: "1a" }); @@ -2329,11 +2438,15 @@ test("detachAgent removes only the parent label from a live agent and emits stat logger, }); - const parent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const parent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const child = await manager.createAgent( { provider: "codex", @@ -2346,6 +2459,7 @@ test("detachAgent removes only the parent label from a live agent and emits stat [PARENT_AGENT_ID_LABEL]: parent.id, team: "infra", }, + workspaceId: undefined, }, ); const emittedLabels: Array> = []; @@ -2382,11 +2496,15 @@ test("detachAgent removes the parent label from a stored-only agent", async () = logger, }); - const parent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const parent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const child = await manager.createAgent( { provider: "codex", @@ -2399,6 +2517,7 @@ test("detachAgent removes the parent label from a stored-only agent", async () = [PARENT_AGENT_ID_LABEL]: parent.id, role: "reviewer", }, + workspaceId: undefined, }, ); await manager.closeAgent(child.id); @@ -2423,11 +2542,15 @@ test("archiveAgent does not cascade to a detached former child", async () => { logger, }); - const parent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const parent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const child = await manager.createAgent( { provider: "codex", @@ -2435,7 +2558,7 @@ test("archiveAgent does not cascade to a detached former child", async () => { title: "Child", }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, ); await manager.detachAgent(child.id); @@ -2458,11 +2581,15 @@ test("runAgent persists finished attention and idle status without an external s idFactory: () => "00000000-0000-4000-8000-000000000134", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Finished attention test", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Finished attention test", + }, + undefined, + { workspaceId: undefined }, + ); await manager.runAgent(snapshot.id, "say hello"); await manager.flush(); @@ -2487,11 +2614,15 @@ test("archiveSnapshot clears persisted attention and normalizes running status", idFactory: () => "00000000-0000-4000-8000-000000000135", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Archive attention test", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Archive attention test", + }, + undefined, + { workspaceId: undefined }, + ); const live = manager.getAgent(snapshot.id); expect(live).not.toBeNull(); @@ -2529,11 +2660,15 @@ test("archiveSnapshot dispatches archived state for stored-only agents", async ( logger, }); - const created = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Stored archive dispatch", - }); + const created = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Stored archive dispatch", + }, + undefined, + { workspaceId: undefined }, + ); await manager.closeAgent(created.id); const events: ManagedAgent[] = []; @@ -2676,10 +2811,14 @@ test("reloadAgentSession cancels active run and resumes existing session once th idFactory: () => "00000000-0000-4000-8000-000000000114", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); expect(snapshot.persistence).toBeNull(); const stream = manager.streamAgent(snapshot.id, "hello"); @@ -2727,10 +2866,14 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", idFactory: () => "00000000-0000-4000-8000-000000000118", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", @@ -2796,10 +2939,14 @@ test("getTimelineRows falls back to the in-memory timeline when no durable store idFactory: () => "00000000-0000-4000-8000-000000000140", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", @@ -2843,10 +2990,14 @@ test("getAgent does not expose committed history internals once manager owns the idFactory: () => "00000000-0000-4000-8000-000000000138", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.appendTimelineItem(snapshot.id, { type: "user_message", @@ -2896,10 +3047,14 @@ test("coalesces assistant chunks and persists the canonical row", async () => { idFactory: () => "00000000-0000-4000-8000-000000000120", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const streamEvents: Array<{ seq?: number; @@ -2978,10 +3133,14 @@ test("fetchTimeline supports older-history pagination with before seq", async () idFactory: () => "00000000-0000-4000-8000-000000000119", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", @@ -3032,10 +3191,14 @@ test("does not trim committed history", async () => { idFactory: () => "00000000-0000-4000-8000-000000000120", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", @@ -3132,10 +3295,14 @@ test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline hi idFactory: () => "00000000-0000-4000-8000-000000000121", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.hydrateTimelineFromProvider(snapshot.id); @@ -3211,10 +3378,14 @@ test("hydrateTimeline preserves reasoning between assistant chunks", async () => idFactory: () => "00000000-0000-4000-8000-000000000122", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.hydrateTimelineFromProvider(snapshot.id); @@ -3242,10 +3413,14 @@ test("createAgent fails when generated agent ID is not a UUID", async () => { }); await expect( - manager.createAgent({ - provider: "codex", - cwd: workdir, - }), + manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ), ).rejects.toThrow("createAgent: agentId must be a UUID"); }); @@ -3268,6 +3443,7 @@ test("createAgent fails when explicit agent ID is not a UUID", async () => { cwd: workdir, }, "not-a-uuid", + { workspaceId: undefined }, ), ).rejects.toThrow("createAgent: agentId must be a UUID"); }); @@ -3286,11 +3462,15 @@ test("createAgent persists provided title before returning", async () => { idFactory: () => agentId, }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Fix Login Bug", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Fix Login Bug", + }, + undefined, + { workspaceId: undefined }, + ); expect(snapshot.id).toBe(agentId); expect(snapshot.lifecycle).toBe("idle"); @@ -3313,12 +3493,16 @@ test("createAgent populates runtimeInfo after session creation", async () => { idFactory: () => "00000000-0000-4000-8000-000000000103", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - model: "gpt-5.2-codex", - modeId: "full-access", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + model: "gpt-5.2-codex", + modeId: "full-access", + }, + undefined, + { workspaceId: undefined }, + ); expect(snapshot.runtimeInfo).toBeDefined(); expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex"); @@ -3338,10 +3522,14 @@ test("runAgent refreshes runtimeInfo after completion", async () => { idFactory: () => "00000000-0000-4000-8000-000000000104", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); expect(snapshot.runtimeInfo?.model).toBe("gpt-5.4"); @@ -3385,10 +3573,14 @@ test("waitForAgentEvent does not resolve idle until foreground turn is finalized idFactory: () => "00000000-0000-4000-8000-000000000124", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const stream = manager.streamAgent(snapshot.id, "hello"); const consumePromise = (async () => { @@ -3431,10 +3623,14 @@ test("waitForAgentRunStart resolves while a foreground run is still only pending idFactory: () => "00000000-0000-4000-8000-000000000124", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const run = manager.streamAgent(snapshot.id, "fast"); const drainRun = (async () => { @@ -3501,10 +3697,14 @@ test("replaceAgentRun does not emit idle or resolve waiters between interrupted idFactory: () => "00000000-0000-4000-8000-000000000125", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const lifecycleUpdates: string[] = []; const unsubscribe = manager.subscribe( @@ -3624,10 +3824,14 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the idFactory: () => "00000000-0000-4000-8000-000000000126", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const stateUpdates: Array<{ lifecycle: string; updatedAt: number }> = []; const unsubscribe = manager.subscribe( @@ -3719,10 +3923,14 @@ test("applies live autonomous events while no foreground run is active", async ( idFactory: () => "00000000-0000-4000-8000-000000000125", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const lifecycleUpdates: string[] = []; let sawRunningState = false; @@ -3808,10 +4016,14 @@ test("cancelAgentRun can interrupt autonomous running state without a foreground idFactory: () => "00000000-0000-4000-8000-000000000129", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const capturedSession = client.lastSession!; @@ -3872,10 +4084,14 @@ test("waitForAgentEvent waitForActive resolves for autonomous live-event run", a idFactory: () => "00000000-0000-4000-8000-000000000126", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const autonomousTurnId = "autonomous-wait-1"; const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true }); @@ -3931,10 +4147,14 @@ test("autonomous events arriving during foreground run are processed via subscri idFactory: () => "00000000-0000-4000-8000-000000000127", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const foreground = manager.streamAgent(snapshot.id, "foreground run"); const foregroundResults = (async () => { @@ -4026,10 +4246,14 @@ test("subscribe error isolation: throwing subscriber does not break event flow", idFactory: () => "00000000-0000-4000-8000-000000000128", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const receivedEvents: string[] = []; const settled = new Promise((resolve) => { @@ -4092,10 +4316,14 @@ test("keeps updatedAt monotonic when user message and run start happen in the sa idFactory: () => "00000000-0000-4000-8000-000000000120", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000); try { @@ -4244,10 +4472,14 @@ test("runAgent assembles finalText from trailing assistant chunks", async () => idFactory: () => "00000000-0000-4000-8000-000000000113", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const result = await manager.runAgent(snapshot.id, "generate commit message"); expect(result.finalText).toBe(expectedFinalText); @@ -4272,19 +4504,27 @@ test("listAgents excludes internal agents", async () => { }); // Create a normal agent - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Normal Agent", - }); + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Normal Agent", + }, + undefined, + { workspaceId: undefined }, + ); // Create an internal agent - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }, + undefined, + { workspaceId: undefined }, + ); const agents = manager.listAgents(); expect(agents).toHaveLength(1); @@ -4305,12 +4545,16 @@ test("getAgent returns internal agents by ID", async () => { idFactory: () => internalAgentId, }); - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }, + undefined, + { workspaceId: undefined }, + ); const agent = manager.getAgent(internalAgentId); expect(agent).not.toBeNull(); @@ -4343,19 +4587,27 @@ test("subscribe does not emit state events for internal agents to global subscri }); // Create a normal agent - should emit - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Normal Agent", - }); + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Normal Agent", + }, + undefined, + { workspaceId: undefined }, + ); // Create an internal agent - should NOT emit to global subscriber - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }, + undefined, + { workspaceId: undefined }, + ); // Should only have events from the normal agent expect(receivedEvents.filter((id) => id === generatedAgentIds[0]).length).toBeGreaterThan(0); @@ -4387,12 +4639,16 @@ test("subscribe emits state events for internal agents when subscribed by agentI { agentId: internalAgentId, replayState: false }, ); - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }, + undefined, + { workspaceId: undefined }, + ); // Should receive events when subscribed by specific agentId expect(receivedEvents.filter((id) => id === internalAgentId).length).toBeGreaterThan(0); @@ -4431,12 +4687,16 @@ test("onAgentAttention is not called for internal agents", async () => { }, }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }, + undefined, + { workspaceId: undefined }, + ); // Run and complete the agent (which normally triggers attention) await manager.runAgent(agent.id, "hello"); @@ -4470,7 +4730,7 @@ test("onAgentAttention is not called for delegated child agents", async () => { title: "Delegated Child Agent", }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" } }, + { labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" }, workspaceId: undefined }, ); await manager.runAgent(agent.id, "hello"); @@ -4536,11 +4796,15 @@ test("clearAgentAttention on errored agent stays cleared until a new error trans }, }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Attention transition test", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Attention transition test", + }, + undefined, + { workspaceId: undefined }, + ); await expect(manager.runAgent(agent.id, "fail once")).rejects.toThrow("boom-1"); await manager.flush(); @@ -4635,11 +4899,15 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist idFactory: () => "00000000-0000-4000-8000-000000000131", }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Start turn failure cleanup", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Start turn failure cleanup", + }, + undefined, + { workspaceId: undefined }, + ); await expect(manager.runAgent(agent.id, "fail before turn id")).rejects.toThrow( "Invalid request: missing field `text`", @@ -4666,11 +4934,15 @@ test("archiveAgent persists archivedAt and updatedAt before emitting closed stat idFactory: () => "00000000-0000-4000-8000-000000000131", }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Archive target", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Archive target", + }, + undefined, + { workspaceId: undefined }, + ); const lifecycles: string[] = []; manager.subscribe( @@ -4713,15 +4985,19 @@ test("fires onAgentArchived for archived parent and cascaded children", async () archivedIds.push(agentId); }); - const liveParent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const liveParent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const liveChild = await manager.createAgent( { provider: "codex", cwd: workdir, title: "Child" }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: liveParent.id } }, + { labels: { [PARENT_AGENT_ID_LABEL]: liveParent.id }, workspaceId: undefined }, ); await manager.archiveAgent(liveParent.id); @@ -4742,11 +5018,15 @@ test("fires onAgentArchived for stored-only snapshot archives", async () => { archivedIds.push(agentId); }); - const storedOnly = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Stored only", - }); + const storedOnly = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Stored only", + }, + undefined, + { workspaceId: undefined }, + ); await manager.closeAgent(storedOnly.id); await manager.archiveSnapshot(storedOnly.id, new Date().toISOString()); @@ -4764,11 +5044,15 @@ test("unarchiveSnapshot skips native provider unarchive for active records", asy logger, }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Active unarchive target", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Active unarchive target", + }, + undefined, + { workspaceId: undefined }, + ); const unarchived = await manager.unarchiveSnapshot(agent.id); @@ -4787,11 +5071,15 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi logger, }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Native unarchive target", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Native unarchive target", + }, + undefined, + { workspaceId: undefined }, + ); await manager.archiveAgent(agent.id); client.readArchivedAtDuringUnarchive = async () => (await storage.get(agent.id))?.archivedAt; @@ -4816,11 +5104,15 @@ test("unarchiveSnapshotByHandle unarchives native provider storage for the match logger, }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Native unarchive by handle target", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Native unarchive by handle target", + }, + undefined, + { workspaceId: undefined }, + ); await manager.archiveAgent(agent.id); const archived = await storage.get(agent.id); if (!archived?.persistence) { @@ -4845,11 +5137,15 @@ test("unarchiveSnapshot keeps the stored record archived when native unarchive f logger, }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Native unarchive failure target", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Native unarchive failure target", + }, + undefined, + { workspaceId: undefined }, + ); await manager.archiveAgent(agent.id); client.unarchiveFailure = new Error("provider still archived"); @@ -4872,11 +5168,15 @@ test("archiveAgent cascade archives in-memory children with the full archive con logger, }); - const parent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const parent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const child = await manager.createAgent( { provider: "codex", @@ -4884,13 +5184,17 @@ test("archiveAgent cascade archives in-memory children with the full archive con title: "Child", }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, + ); + const unrelated = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Unrelated", + }, + undefined, + { workspaceId: undefined }, ); - const unrelated = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Unrelated", - }); await manager.archiveAgent(parent.id); @@ -4945,11 +5249,15 @@ test("archiveAgent cascade closes a running child runtime", async () => { registry: storage, logger, }); - const parent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const parent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const child = await manager.createAgent( { provider: "codex", @@ -4957,7 +5265,7 @@ test("archiveAgent cascade closes a running child runtime", async () => { title: "Running Child", }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, ); const childSession = client.sessions[1]; const childLifecycleEvents: string[] = []; @@ -4999,11 +5307,15 @@ test("archiveAgent cascade archives off-memory children with the full archive co registry: storage, logger, }); - const parent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const parent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const child = await manager.createAgent( { provider: "codex", @@ -5011,7 +5323,7 @@ test("archiveAgent cascade archives off-memory children with the full archive co title: "Off-memory Child", }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, ); const managerInternals = manager as unknown as { agents: Map; @@ -5034,11 +5346,15 @@ test("archiveAgent cascade notifies subscribers for in-memory and off-memory chi registry: storage, logger, }); - const parent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const parent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const inMemoryChild = await manager.createAgent( { provider: "codex", @@ -5046,7 +5362,7 @@ test("archiveAgent cascade notifies subscribers for in-memory and off-memory chi title: "In-memory Child", }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, ); const offMemoryChild = await manager.createAgent( { @@ -5055,7 +5371,7 @@ test("archiveAgent cascade notifies subscribers for in-memory and off-memory chi title: "Off-memory Child", }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, ); const managerInternals = manager as unknown as { agents: Map; @@ -5108,11 +5424,15 @@ test("archiveAgent cascade surfaces partial child archive failures", async () => registry: storage, logger, }); - const parent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Parent", - }); + const parent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Parent", + }, + undefined, + { workspaceId: undefined }, + ); const child = await manager.createAgent( { provider: "codex", @@ -5120,7 +5440,7 @@ test("archiveAgent cascade surfaces partial child archive failures", async () => title: "Failing Child", }, undefined, - { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, ); failingChildId = child.id; @@ -5179,11 +5499,15 @@ test("turn_failed emits a system error assistant timeline message and keeps erro idFactory: () => "00000000-0000-4000-8000-000000000131", }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Turn failed test", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Turn failed test", + }, + undefined, + { workspaceId: undefined }, + ); await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("invalid model id"); @@ -5253,11 +5577,15 @@ test("turn_failed surfaces provider code and diagnostic in system error message" idFactory: () => "00000000-0000-4000-8000-000000000132", }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Detailed failure test", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Detailed failure test", + }, + undefined, + { workspaceId: undefined }, + ); await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed"); @@ -5344,11 +5672,15 @@ test("permission request notifies once without forcing unread attention state", }, }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Permission transition test", - }); + const agent = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Permission transition test", + }, + undefined, + { workspaceId: undefined }, + ); const stream = manager.streamAgent(agent.id, "permission flow"); await stream.next(); // turn_started @@ -5471,11 +5803,15 @@ test("respondToPermission updates currentModeId after plan approval", async () = }); // Create agent in plan mode - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - modeId: "plan", - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + modeId: "plan", + }, + undefined, + { workspaceId: undefined }, + ); expect(snapshot.currentModeId).toBe("plan"); @@ -5573,10 +5909,14 @@ test("respondToPermission refreshes features and runtime info after provider-man idFactory: () => "00000000-0000-4000-8000-000000000133", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const agent = manager.getAgent(snapshot.id); if (!agent) { @@ -5682,10 +6022,14 @@ test("respondToPermission emits refreshed state before permission_resolved", asy idFactory: () => "00000000-0000-4000-8000-000000000134", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const seen: string[] = []; manager.subscribe((event) => { @@ -5847,10 +6191,14 @@ test("close during in-flight stream does not clear persistence sessionId", async idFactory: () => "00000000-0000-4000-8000-000000000113", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); const stream = manager.streamAgent(snapshot.id, "hello"); await stream.next(); @@ -5887,10 +6235,14 @@ test("closeAgent persists one final closed snapshot", async () => { }); try { - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.flush(); const persistCountBeforeClose = applySnapshotSpy.mock.calls.length; @@ -5963,10 +6315,14 @@ test("hydrateTimeline keeps provider user_message items when no canonical user h idFactory: () => "00000000-0000-4000-8000-000000000203", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.hydrateTimelineFromProvider(snapshot.id); @@ -6024,10 +6380,14 @@ test("hydrateTimeline preserves provider replay timestamps and marks missing one idFactory: () => "00000000-0000-4000-8000-000000000204", }); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); await manager.hydrateTimelineFromProvider(snapshot.id); const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0 }).rows; @@ -6091,7 +6451,9 @@ test("provider user_message is recorded from the live stream", async () => { idFactory: () => "00000000-0000-4000-8000-000000000401", }); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); await manager.runAgent(snapshot.id, { text: "do something" }); @@ -6143,7 +6505,9 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a }); try { - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); await manager.runAgent(snapshot.id, "hello from composer", { messageId: "msg-client-1" }); @@ -6208,7 +6572,9 @@ test("replaceAgentRun succeeds when foreground turn terminal event is never deli idFactory: () => "00000000-0000-4000-8000-000000000500", }); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); // Start first foreground run — it will hang (no terminal event) const firstRun = manager.streamAgent(snapshot.id, "hanging prompt"); @@ -6401,7 +6767,9 @@ test("user_message events wrapping a paseo-system envelope are not added to the idFactory: () => "00000000-0000-4000-8000-0000000005a1", }); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); await manager.runAgent(snapshot.id, { text: "do something" }); @@ -6440,7 +6808,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri idFactory: () => "00000000-0000-4000-8000-0000000005a2", }); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); await manager.hydrateTimelineFromProvider(snapshot.id); @@ -6512,7 +6882,9 @@ test("onWorkspaceStateMayHaveChanged is called when a completed shell tool call onWorkspaceStateMayHaveChanged, }); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); await manager.runAgent(snapshot.id, { text: "merge it" }); @@ -6546,7 +6918,9 @@ test("onWorkspaceStateMayHaveChanged is not called for non-shell tool calls", as onWorkspaceStateMayHaveChanged, }); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); await manager.runAgent(snapshot.id, { text: "read it" }); @@ -6579,7 +6953,9 @@ test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls" onWorkspaceStateMayHaveChanged, }); - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); await manager.runAgent(snapshot.id, { text: "merge it" }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 720edbfcb..48ad8bcf7 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -196,6 +196,16 @@ interface ProviderEnabledFlag { type ProviderEnabledMap = Partial>; type ProviderClientMap = Partial>; +export interface CreateAgentOptions { + labels?: Record; + initialPrompt?: string; + env?: Record; + persistSession?: boolean; + initialTitle?: string | null; + // undefined is an explicit decision: the agent never appears in the sidebar. + workspaceId: string | undefined; +} + export interface AgentManagerOptions { clients?: ProviderClientMap; providerDefinitions?: ProviderEnabledMap; @@ -914,15 +924,8 @@ export class AgentManager { async createAgent( config: AgentSessionConfig, - agentId?: string, - options?: { - labels?: Record; - initialPrompt?: string; - env?: Record; - persistSession?: boolean; - initialTitle?: string | null; - workspaceId?: string; - }, + agentId: string | undefined, + options: CreateAgentOptions, ): Promise { const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId); @@ -935,9 +938,9 @@ export class AgentManager { const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); return this.registerSession(session, storedConfig, resolvedAgentId, { - labels: options?.labels, - initialTitle: options?.initialTitle, - workspaceId: options?.workspaceId, + labels: options.labels, + initialTitle: options.initialTitle, + workspaceId: options.workspaceId, }); } diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index 7bf55173d..d3a3de010 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -182,6 +182,15 @@ function buildBasePrompt(prompt: string, jsonSchema: JsonSchema): string { ].join("\n"); } +export function buildStructuredAgentResponsePrompt(options: { + prompt: string; + schema: z.ZodType | JsonSchema; + schemaName?: string; +}): string { + const validator = buildValidator(options.schema, options.schemaName ?? "Response"); + return buildBasePrompt(options.prompt, validator.jsonSchema); +} + function buildRetryPrompt(basePrompt: string, errors: string[]): string { const formattedErrors = errors.map((error) => `- ${error}`).join("\n"); return [ @@ -347,7 +356,10 @@ export async function generateStructuredAgentResponse( ): Promise { const { manager, agentConfig, agentId, persistSession, prompt, schema, maxRetries, schemaName } = options; - const agent = await manager.createAgent(agentConfig, agentId, { persistSession }); + const agent = await manager.createAgent(agentConfig, agentId, { + persistSession, + workspaceId: undefined, + }); try { const caller: AgentCaller = async (nextPrompt) => { const result = await manager.runAgent(agent.id, nextPrompt); diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index 0e3037596..2c264501f 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -66,6 +66,7 @@ test("session create forwards clientMessageId to the initial prompt run options" await createAgentCommand(dependencies, { kind: "session", config: { provider: "codex", cwd: "/tmp/paseo-create-test" }, + workspaceId: "ws-create-test", initialPrompt: "hello from create", clientMessageId: "msg-create-1", labels: {}, @@ -79,6 +80,52 @@ test("session create forwards clientMessageId to the initial prompt run options" }); }); +test("mcp create accepts provider-only internal input and leaves model undefined", async () => { + const snapshot = { + id: "agent-1", + provider: "claude", + cwd: "/tmp/paseo-create-test", + runtimeInfo: null, + } as ManagedAgent; + const createAgent = vi.fn(async () => snapshot); + const dependencies: Parameters[0] = { + agentManager: { + createAgent, + getAgent: vi.fn(() => snapshot), + } as unknown as Parameters[0]["agentManager"], + agentStorage: {} as Parameters[0]["agentStorage"], + logger: createTestLogger(), + providerSnapshotManager: { + resolveCreateConfig: vi.fn(async (input) => { + expect(input.provider).toBe("claude"); + return {}; + }), + } as Parameters[0]["providerSnapshotManager"], + }; + + await createAgentCommand(dependencies, { + kind: "mcp", + provider: "claude", + cwd: "/tmp/paseo-create-test", + workspaceId: "ws-create-test", + title: "provider default", + initialPrompt: "hello", + background: true, + notifyOnFinish: false, + }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "claude", + model: undefined, + }), + undefined, + expect.objectContaining({ + workspaceId: "ws-create-test", + }), + ); +}); + test("session create stamps the requested workspaceId when no worktree setup runs", async () => { const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-")); const storage = new AgentStorage(join(workdir, "agents"), logger); @@ -212,6 +259,7 @@ test("session create keeps the prompt title after the initial prompt settles", a { kind: "session", config: { provider: "codex", cwd: workdir }, + workspaceId: "ws-title-source", initialPrompt: `${title}\n\ninclude tests`, labels: {}, provisionalTitle: title, @@ -249,6 +297,7 @@ test("session create keeps an explicit title after the initial prompt settles", { kind: "session", config: { provider: "codex", cwd: workdir, title }, + workspaceId: "ws-explicit-title-source", initialPrompt: "Implement auth retries with backoff", labels: {}, provisionalTitle: title, diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 0346c648c..62b06bfcd 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -12,7 +12,7 @@ import type { CreatePaseoWorktreeWorkflowResult, } from "../../worktree-session.js"; import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; -import type { AgentManager, ManagedAgent } from "../agent-manager.js"; +import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js"; import type { AgentPromptContentBlock, AgentPromptInput, @@ -22,8 +22,9 @@ import type { import type { AgentStorage } from "../agent-storage.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; +import { resolveCreateAgentTitles } from "../create-agent-title.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; -import { resolveRequiredProviderModel } from "../mcp-shared.js"; +import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -37,7 +38,7 @@ export interface CreateAgentSessionWorktreeResult { createdWorkspaceId?: string; } -interface CreateAgentCommandDependencies { +export interface CreateAgentCommandDependencies { agentManager: AgentManager; agentStorage: AgentStorage; logger: Logger; @@ -47,16 +48,18 @@ interface CreateAgentCommandDependencies { providerSnapshotManager: ProviderSnapshotManager; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; // Mints a fresh directory workspace for a cwd and returns its id. - ensureWorkspaceForCreate?: ( - cwd: string, - firstAgentContext?: FirstAgentContext, - ) => Promise; + ensureWorkspaceForCreate?: EnsureWorkspaceForCreate; } +export type EnsureWorkspaceForCreate = ( + cwd: string, + firstAgentContext?: FirstAgentContext, +) => Promise; + export interface CreateAgentFromSessionInput { kind: "session"; config: AgentSessionConfig; - workspaceId?: string; + workspaceId: string; worktreeName?: string; initialPrompt?: string; clientMessageId?: string; @@ -80,15 +83,19 @@ export interface CreateAgentFromMcpInput { kind: "mcp"; provider: string; title: string; - initialPrompt: string; + initialPrompt?: string; + config?: Partial; cwd?: string; workspaceId?: string; thinking?: string; features?: Record; labels?: Record; mode?: string; + unattended?: boolean; + promptFailure?: CreateAgentPromptFailureMode; background: boolean; notifyOnFinish: boolean; + internal?: boolean; detached?: boolean; callerAgentId?: string; callerContext?: { @@ -107,33 +114,56 @@ export interface CreateAgentFromMcpInput { } export type CreateAgentCommandInput = CreateAgentFromSessionInput | CreateAgentFromMcpInput; +export type CreateAgentPromptFailureMode = "throw" | "log" | "return-error"; export interface CreateAgentCommandResult { snapshot: ManagedAgent; liveSnapshot: ManagedAgent; background: boolean; initialPromptStarted: boolean; + initialPromptError: unknown | null; +} + +export type BoundCreateAgentCommand = ( + input: CreateAgentCommandInput, +) => Promise; + +function requireResolvedWorkspaceId(workspaceId: string | undefined): string { + if (!workspaceId) { + throw new Error("createAgentCommand requires a resolved workspaceId"); + } + return workspaceId; +} + +export function formatProviderModel(provider: string, model: string | null | undefined): string { + if (!model || provider.includes("/")) { + return provider; + } + return `${provider}/${model}`; +} + +function resolveProviderModel(providerValue: string): ResolvedProviderModel { + const providerInput = providerValue.trim(); + if (providerInput.includes("/")) { + return resolveRequiredProviderModel(providerInput); + } + if (!providerInput) { + throw new Error("provider is required"); + } + return { provider: providerInput, model: undefined }; } interface ResolvedCreateAgent { config: AgentSessionConfig; - createOptions?: AgentCreateOptions; + createOptions: CreateAgentOptions; prompt?: AgentPromptInput; runOptions?: AgentRunOptions; setupContinuation?: AgentWorktreeSetupContinuation; background: boolean; - promptFailure: "throw" | "log"; + promptFailure: CreateAgentPromptFailureMode; promptLogger?: Logger; } -interface AgentCreateOptions { - labels?: Record; - initialPrompt?: string; - env?: Record; - initialTitle?: string | null; - workspaceId?: string; -} - export async function createAgentCommand( dependencies: CreateAgentCommandDependencies, input: CreateAgentCommandInput, @@ -155,10 +185,12 @@ export async function createAgentCommand( let liveSnapshot = snapshot; let initialPromptStarted = false; + let initialPromptError: unknown | null = null; if (resolved.prompt !== undefined) { const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot); initialPromptStarted = sendResult.started; liveSnapshot = sendResult.liveSnapshot; + initialPromptError = sendResult.error ?? null; } if (input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && initialPromptStarted) { @@ -176,6 +208,7 @@ export async function createAgentCommand( liveSnapshot, background: resolved.background, initialPromptStarted, + initialPromptError, }; } @@ -200,6 +233,7 @@ async function resolveSessionCreateAgent( ...(clientMessageId ? { messageId: clientMessageId } : {}), } : undefined; + const workspaceId = setupContinuation ? createdWorkspaceId : input.workspaceId; return { config: sessionConfig, @@ -209,9 +243,9 @@ async function resolveSessionCreateAgent( env: input.env, initialTitle: input.provisionalTitle, // A legacy git/worktreeName worktree creates a fresh workspace, so the - // agent belongs to that workspace, not the source one (mirrors the MCP - // path). createdWorkspaceId is the freshly created worktree's workspace. - workspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId, + // agent belongs to that workspace, not the source one. createdWorkspaceId + // is the freshly created worktree's workspace. + workspaceId: requireResolvedWorkspaceId(workspaceId), }, prompt: hasPromptContent ? prompt : undefined, runOptions, @@ -228,45 +262,34 @@ async function resolveMcpCreateAgent( dependencies: CreateAgentCommandDependencies, input: CreateAgentFromMcpInput, ): Promise { - const resolvedProviderModel = resolveRequiredProviderModel(input.provider); + const resolvedProviderModel = resolveProviderModel(input.provider); const provider = resolvedProviderModel.provider; const parentAgent = input.callerAgentId ? requireParentAgent(dependencies.agentManager, input.callerAgentId) : null; - const cwd = parentAgent - ? resolveChildAgentCwd({ - parentCwd: parentAgent.cwd, - requestedCwd: input.cwd, - lockedCwd: input.callerContext?.lockedCwd, - allowCustomCwd: input.callerContext?.allowCustomCwd ?? true, - }) - : expandUserPath(input.cwd ?? process.cwd()); + const cwd = resolveMcpInitialCwd(input, parentAgent); const { resolvedCwd, setupContinuation, createdWorkspaceId } = await resolveMcpCwd({ dependencies, cwd, worktree: input.worktree, - initialPrompt: input.initialPrompt, + initialPrompt: input.initialPrompt ?? "", }); - // MCP callers resolve workspace ownership before this point. Worktree - // creation wins because the new agent lives in the fresh worktree workspace. - // Otherwise use the explicit workspace id, then the parent workspace for - // direct internal callers. Ownership is never resolved from cwd. - const workspaceId = setupContinuation - ? createdWorkspaceId - : (input.workspaceId ?? - parentAgent?.workspaceId ?? - (await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt))); - - const { modeId: resolvedMode, featureValues: resolvedFeatures } = - await dependencies.providerSnapshotManager.resolveCreateConfig({ - cwd: resolvedCwd, - provider, - requestedMode: input.mode, - featureValues: input.features, - parent: parentAgent, - unattended: false, - }); + const workspaceId = await resolveMcpWorkspaceId({ + dependencies, + input, + parentAgent, + setupContinuation, + createdWorkspaceId, + resolvedCwd, + }); + const resolvedCreateConfig = await resolveMcpProviderCreateConfig({ + dependencies, + input, + provider, + resolvedCwd, + parentAgent, + }); const labels = mergeLabels({ callerAgentId: input.callerAgentId, @@ -275,31 +298,122 @@ async function resolveMcpCreateAgent( labels: input.labels, }); - const trimmedPrompt = input.initialPrompt.trim(); + const trimmedPrompt = input.initialPrompt?.trim() ?? ""; return { - config: { + config: buildMcpSessionConfig({ + input, + resolvedProviderModel, provider, - cwd: resolvedCwd, - modeId: resolvedMode, - title: input.title.trim(), - model: resolvedProviderModel.model, - thinkingOptionId: input.thinking, - ...(resolvedFeatures ? { featureValues: resolvedFeatures } : {}), + resolvedCwd, + trimmedPrompt, + resolvedMode: resolvedCreateConfig.modeId, + resolvedFeatures: resolvedCreateConfig.featureValues, + }), + createOptions: { + ...(labels ? { labels } : {}), + workspaceId: requireResolvedWorkspaceId(workspaceId), }, - createOptions: - labels || workspaceId - ? { - ...(labels ? { labels } : {}), - ...(workspaceId ? { workspaceId } : {}), - } - : undefined, - prompt: trimmedPrompt, + prompt: trimmedPrompt ? trimmedPrompt : undefined, setupContinuation, background: input.background, - promptFailure: "log", + promptFailure: input.promptFailure ?? "log", }; } +function resolveMcpInitialCwd( + input: CreateAgentFromMcpInput, + parentAgent: ManagedAgent | null, +): string { + if (!parentAgent) { + return expandUserPath(input.cwd ?? process.cwd()); + } + return resolveChildAgentCwd({ + parentCwd: parentAgent.cwd, + requestedCwd: input.cwd, + lockedCwd: input.callerContext?.lockedCwd, + allowCustomCwd: input.callerContext?.allowCustomCwd ?? true, + }); +} + +async function resolveMcpWorkspaceId(params: { + dependencies: CreateAgentCommandDependencies; + input: CreateAgentFromMcpInput; + parentAgent: ManagedAgent | null; + setupContinuation?: AgentWorktreeSetupContinuation; + createdWorkspaceId?: string; + resolvedCwd: string; +}): Promise { + // MCP callers resolve workspace ownership before this point. Worktree + // creation wins because the new agent lives in the fresh worktree workspace. + // Otherwise use the explicit workspace id, then the parent workspace for + // direct internal callers. Ownership is never resolved from cwd. + if (params.setupContinuation) { + return params.createdWorkspaceId; + } + if (params.input.workspaceId) { + return params.input.workspaceId; + } + if (params.parentAgent?.workspaceId) { + return params.parentAgent.workspaceId; + } + return ensureWorkspaceForMcpCreate( + params.dependencies, + params.resolvedCwd, + params.input.initialPrompt ?? "", + ); +} + +async function resolveMcpProviderCreateConfig(params: { + dependencies: CreateAgentCommandDependencies; + input: CreateAgentFromMcpInput; + provider: string; + resolvedCwd: string; + parentAgent: ManagedAgent | null; +}): Promise<{ modeId?: string; featureValues?: Record }> { + const passthroughConfig = params.input.config; + return params.dependencies.providerSnapshotManager.resolveCreateConfig({ + cwd: params.resolvedCwd, + provider: params.provider, + requestedMode: params.input.mode ?? passthroughConfig?.modeId, + featureValues: params.input.features ?? passthroughConfig?.featureValues, + parent: params.parentAgent, + unattended: params.input.unattended ?? false, + }); +} + +function buildMcpSessionConfig(params: { + input: CreateAgentFromMcpInput; + resolvedProviderModel: ResolvedProviderModel; + provider: string; + resolvedCwd: string; + trimmedPrompt: string; + resolvedMode?: string; + resolvedFeatures?: Record; +}): AgentSessionConfig { + const passthroughConfig = params.input.config; + const { provisionalTitle } = resolveCreateAgentTitles({ + configTitle: passthroughConfig?.title ?? params.input.title, + initialPrompt: params.trimmedPrompt, + }); + const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues; + const config: AgentSessionConfig = { + ...passthroughConfig, + provider: params.provider, + cwd: params.resolvedCwd, + modeId: params.resolvedMode ?? passthroughConfig?.modeId, + model: params.resolvedProviderModel.model ?? passthroughConfig?.model, + thinkingOptionId: params.input.thinking ?? passthroughConfig?.thinkingOptionId, + internal: params.input.internal ?? passthroughConfig?.internal, + }; + if (provisionalTitle) { + config.title = provisionalTitle; + } + if (featureValues) { + config.featureValues = featureValues; + } + return config; +} + async function ensureWorkspaceForMcpCreate( dependencies: CreateAgentCommandDependencies, cwd: string, @@ -315,7 +429,7 @@ async function sendInitialPrompt( dependencies: CreateAgentCommandDependencies, resolved: ResolvedCreateAgent, snapshot: ManagedAgent, -): Promise<{ started: boolean; liveSnapshot: ManagedAgent }> { +): Promise<{ started: boolean; liveSnapshot: ManagedAgent; error?: unknown }> { try { const prompt = resolved.prompt; if (prompt === undefined) { @@ -334,6 +448,9 @@ async function sendInitialPrompt( if (resolved.promptFailure === "throw") { throw error; } + if (resolved.promptFailure === "return-error") { + return { started: false, liveSnapshot: snapshot, error }; + } dependencies.logger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); return { started: false, liveSnapshot: snapshot }; } diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 3981413d4..209befc21 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -358,12 +358,16 @@ describe("Suite A: Core Fixes", () => { expect(listenTarget?.type).toBe("tcp"); const cwd = await makeCwd("manager-direct-agent-cwd"); - const snapshot = await daemonHandle.daemon.agentManager.createAgent({ - provider: "claude", - cwd, - title: "Manager direct parity agent", - modeId: "bypassPermissions", - }); + const snapshot = await daemonHandle.daemon.agentManager.createAgent( + { + provider: "claude", + cwd, + title: "Manager direct parity agent", + modeId: "bypassPermissions", + }, + undefined, + { workspaceId: undefined }, + ); agentId = snapshot.id; const expectedUrl = buildExpectedAgentMcpUrl({ diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index bd308ac4e..7a322fbdf 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1600,7 +1600,10 @@ describe("create_agent MCP tool", () => { thinkingOptionId: "think-hard", }), undefined, - { labels: { source: "mcp" }, workspaceId: "workspace-created" }, + { + labels: { source: "mcp" }, + workspaceId: "workspace-created", + }, ); }); @@ -3091,7 +3094,9 @@ describe("create_agent MCP tool", () => { }); expect(configArg.mcpServers).toBeUndefined(); expect(agentIdArg).toBeUndefined(); - expect(optionsArg).toEqual({ workspaceId: "workspace-created" }); + expect(optionsArg).toEqual({ + workspaceId: "workspace-created", + }); }); it("rejects an explicit mode that is not valid for the target provider", async () => { diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 93444d2f9..51763d336 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -250,6 +250,7 @@ describe("MockLoadTestAgentClient", () => { model: "ten-second-stream", }, "00000000-0000-4000-8000-000000000001", + { workspaceId: undefined }, ); const resultPromise = manager.runAgent( diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index 6c664d312..15efcb1ad 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -56,10 +56,14 @@ async function createRewindHarness(options: { historyGate?: RewindHistoryGate } logger: createTestLogger(), idFactory: () => "00000000-0000-4000-8000-000000000901", }); - const agent = await manager.createAgent({ - provider: "claude", - cwd: process.cwd(), - }); + const agent = await manager.createAgent( + { + provider: "claude", + cwd: process.cwd(), + }, + undefined, + { workspaceId: undefined }, + ); return { manager, session, agentId: agent.id }; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index de7dbfe3b..b12315fb2 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -166,6 +166,10 @@ import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { createGitMutationService } from "./session/git-mutation/git-mutation-service.js"; import { workspaceIdsOnCheckout } from "./workspace-directory.js"; import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; +import { + createAgentCommand, + type CreateAgentCommandDependencies, +} from "./agent/create-agent/create.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; @@ -802,41 +806,6 @@ export async function createPaseoDaemon( paseoHome: config.paseoHome, workspaceGitService, }); - const loopService = new LoopService({ - paseoHome: config.paseoHome, - logger, - agentManager, - providerSnapshotManager, - }); - await loopService.initialize(); - logger.info({ elapsed: elapsed() }, "Loop service initialized"); - const scheduleService = new ScheduleService({ - paseoHome: config.paseoHome, - logger, - agentManager, - agentStorage, - providerSnapshotManager, - }); - await scheduleService.start(); - agentManager.setAgentArchivedCallback(async (agentId) => { - try { - await scheduleService.completeForAgent(agentId); - } catch (error) { - logger.warn({ err: error, agentId }, "Failed to complete schedules for archived agent"); - } - }); - logger.info({ elapsed: elapsed() }, "Schedule service initialized"); - logger.info({ elapsed: elapsed() }, "Loading persisted agent registry"); - const persistedRecords = await agentStorage.list(); - logger.info( - { elapsed: elapsed() }, - `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`, - ); - logger.info( - "Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)", - ); - logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); - const archiveWorkspaceRecordExternal = async (workspaceId: string) => { const sessions = wsServer?.listActiveSessions() ?? []; if (sessions.length > 0) { @@ -903,6 +872,14 @@ export async function createPaseoDaemon( ), ); }; + const ensureWorkspaceForCreateAndBroadcastExternal = async ( + cwd: string, + firstAgentContext?: FirstAgentContext, + ): Promise => { + const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext); + await emitWorkspaceUpdatesExternal([workspaceId]); + return workspaceId; + }; const emitWorkspaceUpdateForCwdExternal = async (cwd: string) => { const workspaceIds = workspaceIdsOnCheckout(await workspaceRegistry.list(), cwd); await emitWorkspaceUpdatesExternal(workspaceIds); @@ -996,6 +973,58 @@ export async function createPaseoDaemon( ); }; + const createAgentCommandDependencies: CreateAgentCommandDependencies = { + agentManager, + agentStorage, + logger, + paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, + terminalManager, + providerSnapshotManager, + createPaseoWorktree: createPaseoWorktreeForTools, + ensureWorkspaceForCreate: ensureWorkspaceForCreateAndBroadcastExternal, + }; + const createAgent = (input: Parameters[1]) => + createAgentCommand(createAgentCommandDependencies, input); + + const loopService = new LoopService({ + paseoHome: config.paseoHome, + logger, + agentManager, + createAgent, + ensureWorkspaceForCreate: ensureWorkspaceForCreateAndBroadcastExternal, + }); + await loopService.initialize(); + logger.info({ elapsed: elapsed() }, "Loop service initialized"); + const scheduleService = new ScheduleService({ + paseoHome: config.paseoHome, + logger, + agentManager, + agentStorage, + createAgent, + ensureWorkspaceForCreate: ensureWorkspaceForCreateAndBroadcastExternal, + workspaceRegistry, + }); + await scheduleService.start(); + agentManager.setAgentArchivedCallback(async (agentId) => { + try { + await scheduleService.completeForAgent(agentId); + } catch (error) { + logger.warn({ err: error, agentId }, "Failed to complete schedules for archived agent"); + } + }); + logger.info({ elapsed: elapsed() }, "Schedule service initialized"); + logger.info({ elapsed: elapsed() }, "Loading persisted agent registry"); + const persistedRecords = await agentStorage.list(); + logger.info( + { elapsed: elapsed() }, + `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`, + ); + logger.info( + "Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)", + ); + logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime"); + const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ @@ -1014,8 +1043,8 @@ export async function createPaseoDaemon( workspaceRegistry, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, - ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal, - createPaseoWorktree: createPaseoWorktreeForTools, + ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate, + createPaseoWorktree: createAgentCommandDependencies.createPaseoWorktree, browserToolsEnabled: browserToolsPolicy.isEnabled(), browserToolsBroker, paseoHome: config.paseoHome, diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 57d87b67d..67edfd173 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -30,7 +30,11 @@ import type { } from "./agent/agent-sdk-types.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { AgentManager } from "./agent/agent-manager.js"; +import { createAgentCommand } from "./agent/create-agent/create.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { createLocalCheckoutWorkspace } from "./paseo-worktree-service.js"; +import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; +import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { LoopService } from "./loop-service.js"; import { isPlatform } from "../test-utils/platform.js"; import { createTestLogger } from "../test-utils/test-logger.js"; @@ -46,11 +50,76 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { const NO_UNATTENDED_LOOP_POLICY: Pick = { async resolveCreateConfig(input) { - expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); - return { modeId: undefined, featureValues: input.featureValues }; + expect(input).toMatchObject({ parent: null, unattended: true }); + return { + modeId: input.unattended ? input.requestedMode : "interactive", + featureValues: input.featureValues, + }; }, }; +interface TestLoopServiceOptions { + paseoHome: string; + agentManager: AgentManager; + agentStorage: AgentStorage; + logger: ReturnType; + providerSnapshotManager?: Pick; + ensureWorkspaceForCreate?: ( + cwd: string, + firstAgentContext?: { prompt: string }, + ) => Promise; +} + +function createLoopService(options: TestLoopServiceOptions): LoopService { + const providerSnapshotManager = options.providerSnapshotManager ?? NO_UNATTENDED_LOOP_POLICY; + const ensureWorkspaceForCreate = + options.ensureWorkspaceForCreate ?? (async () => "workspace-created-for-loop"); + return new LoopService({ + paseoHome: options.paseoHome, + agentManager: options.agentManager, + logger: options.logger, + ensureWorkspaceForCreate, + createAgent: (input) => + createAgentCommand( + { + agentManager: options.agentManager, + agentStorage: options.agentStorage, + logger: options.logger, + providerSnapshotManager: providerSnapshotManager as ProviderSnapshotManager, + ensureWorkspaceForCreate, + }, + input, + ), + }); +} + +async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{ + workspaceRegistry: FileBackedWorkspaceRegistry; + ensureWorkspaceForCreate: TestLoopServiceOptions["ensureWorkspaceForCreate"]; +}> { + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(rootDir, "projects", "workspaces.json"), + createTestLogger(), + ); + const projectRegistry = new FileBackedProjectRegistry( + path.join(rootDir, "projects", "projects.json"), + createTestLogger(), + ); + await workspaceRegistry.initialize(); + await projectRegistry.initialize(); + const workspaceGitService = createNoopWorkspaceGitService(); + return { + workspaceRegistry, + ensureWorkspaceForCreate: async (cwd, firstAgentContext) => { + const workspace = await createLocalCheckoutWorkspace( + { cwd, title: firstAgentContext?.prompt ?? null }, + { projectRegistry, workspaceRegistry, workspaceGitService }, + ); + return workspace.workspaceId; + }, + }; +} + interface ScriptedAgentBehavior { onRun(input: { config: AgentSessionConfig; prompt: string; turnId: string }): Promise; } @@ -273,17 +342,18 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ + const service = createLoopService({ paseoHome, agentManager: manager, + agentStorage: storage, logger, - providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, }); await service.initialize(); const loop = await service.runLoop({ prompt: "Create done.txt when the task is actually fixed.", cwd: workspaceDir, + model: "test-model", verifyChecks: [ `${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`, ], @@ -329,11 +399,11 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ + const service = createLoopService({ paseoHome, agentManager: manager, + agentStorage: storage, logger, - providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, }); await service.initialize(); @@ -363,16 +433,150 @@ describe("LoopService", () => { expect(workerConfigs[0]).toMatchObject({ provider: "codex", model: "gpt-5.4", - internal: true, }); expect(verifierConfigs).toHaveLength(1); expect(verifierConfigs[0]).toMatchObject({ provider: "claude", model: "sonnet", - internal: true, }); }); + test("loop worker and verifier agents share one registry workspace across iterations", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tmpDir); + let verifierCount = 0; + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + return "worker finished"; + } + verifierCount += 1; + return verifierCount >= 2 + ? '{"passed":true,"reason":"second verifier passed"}' + : '{"passed":false,"reason":"try again"}'; + }, + }), + }, + registry: storage, + logger, + }); + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + ensureWorkspaceForCreate, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Keep trying until the verifier passes.", + cwd: workspaceDir, + model: "test-model", + verifyPrompt: "Report whether the loop has passed.", + archive: true, + sleepMs: 1, + maxIterations: 2, + }); + + await waitForLoopCompletion(service, loop.id); + + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("succeeded"); + expect(finalLoop.iterations).toHaveLength(2); + const firstWorker = await storage.get(finalLoop.iterations[0]!.workerAgentId!); + const firstVerifier = await storage.get(finalLoop.iterations[0]!.verifierAgentId!); + const secondWorker = await storage.get(finalLoop.iterations[1]!.workerAgentId!); + const secondVerifier = await storage.get(finalLoop.iterations[1]!.verifierAgentId!); + const workspaceId = firstWorker?.workspaceId; + expect(workspaceId).toMatch(/^wks_/); + expect(firstVerifier?.workspaceId).toBe(workspaceId); + expect(secondWorker?.workspaceId).toBe(workspaceId); + expect(secondVerifier?.workspaceId).toBe(workspaceId); + expect(await workspaceRegistry.get(workspaceId!)).toMatchObject({ + workspaceId, + cwd: workspaceDir, + }); + expect(await workspaceRegistry.list()).toHaveLength(1); + }); + + test("rejects non-directory cwd before minting a loop workspace", async () => { + const filePath = path.join(tmpDir, "not-a-directory.txt"); + writeFileSync(filePath, "not a directory"); + let ensureCalls = 0; + const manager = new AgentManager({ registry: storage, logger }); + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + ensureWorkspaceForCreate: async () => { + ensureCalls += 1; + return "workspace-created-for-file-cwd"; + }, + }); + await service.initialize(); + + await expect( + service.runLoop({ + prompt: "Use a file as cwd", + cwd: filePath, + verifyChecks: ["true"], + }), + ).rejects.toThrow("is not a directory"); + expect(ensureCalls).toBe(0); + }); + + test("model-less loop workers use provider defaults and keep fast worker logs", async () => { + const workerConfigs: AgentSessionConfig[] = []; + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + workerConfigs.push(config); + return "worker default model output"; + } + return '{"passed":true,"reason":"ok"}'; + }, + }), + }, + registry: storage, + logger, + }); + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Use provider default model", + cwd: workspaceDir, + verifyChecks: ["true"], + maxIterations: 1, + }); + + await waitForLoopCompletion(service, loop.id); + + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("succeeded"); + expect(workerConfigs).toHaveLength(1); + expect(workerConfigs[0]).toMatchObject({ + provider: "claude", + model: undefined, + }); + expect( + finalLoop.logs.some( + (entry) => entry.source === "worker" && entry.text.includes("worker default model output"), + ), + ).toBe(true); + }); + test("archives worker and verifier agents after each iteration when requested", async () => { const archivedAgentIds: string[] = []; const manager = new AgentManager({ @@ -395,17 +599,18 @@ describe("LoopService", () => { archivedAgentIds.push(agentId); await archiveAgent(agentId); }; - const service = new LoopService({ + const service = createLoopService({ paseoHome, agentManager: manager, + agentStorage: storage, logger, - providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, }); await service.initialize(); const loop = await service.runLoop({ prompt: "Create done.txt", cwd: workspaceDir, + model: "test-model", verifyPrompt: "Confirm that done.txt exists in the workspace.", archive: true, maxIterations: 1, @@ -432,6 +637,118 @@ describe("LoopService", () => { }); }); + test("worker prompt-start failures fail the loop and archive the worker", async () => { + class StartFailureLoopSession implements AgentSession { + readonly provider = "claude"; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + + async run(): Promise { + return { + sessionId: this.id, + finalText: "", + timeline: [], + }; + } + + async startTurn(): Promise<{ turnId: string }> { + throw new Error("worker failed before starting"); + } + + subscribe(): () => void { + return () => {}; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo(): Promise { + return { + provider: this.provider, + sessionId: this.id, + model: null, + modeId: null, + }; + } + + async getAvailableModes(): Promise { + return []; + } + + async getCurrentMode(): Promise { + return null; + } + + async setMode(): Promise {} + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence(): AgentPersistenceHandle { + return { + provider: this.provider, + sessionId: this.id, + }; + } + + async interrupt(): Promise {} + + async close(): Promise {} + + async listCommands(): Promise { + return []; + } + } + + const manager = new AgentManager({ + clients: { + claude: { + provider: "claude", + capabilities: TEST_CAPABILITIES, + createSession: async () => new StartFailureLoopSession(), + resumeSession: async () => new StartFailureLoopSession(), + fetchCatalog: async () => ({ models: [], modes: [] }), + isAvailable: async () => true, + }, + }, + registry: storage, + logger, + }); + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Fail before starting", + cwd: workspaceDir, + model: "test-model", + verifyChecks: ["true"], + archive: true, + maxIterations: 1, + }); + + await waitForLoopCompletion(service, loop.id); + + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("failed"); + expect(finalLoop.iterations[0]).toMatchObject({ + status: "failed", + workerOutcome: "failed", + failureReason: expect.stringContaining("worker failed before starting"), + }); + const workerAgentId = finalLoop.iterations[0]?.workerAgentId; + expect(workerAgentId).toMatch(/^[0-9a-f-]{36}$/); + expect(await storage.get(workerAgentId!)).toMatchObject({ + archivedAt: expect.any(String), + }); + }); + test("uses verifier prompt when provided", async () => { const manager = new AgentManager({ clients: { @@ -452,17 +769,18 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ + const service = createLoopService({ paseoHome, agentManager: manager, + agentStorage: storage, logger, - providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, }); await service.initialize(); const loop = await service.runLoop({ prompt: "Create done.txt", cwd: workspaceDir, + model: "test-model", verifyPrompt: "Confirm that done.txt exists in the workspace.", maxIterations: 1, }); @@ -499,14 +817,22 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ + const service = createLoopService({ paseoHome, agentManager: manager, + agentStorage: storage, logger, providerSnapshotManager: { async resolveCreateConfig(input) { - expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); - return { modeId: "bypassPermissions", featureValues: input.featureValues }; + expect(input).toMatchObject({ + parent: null, + unattended: true, + requestedMode: undefined, + }); + return { + modeId: input.unattended ? "bypassPermissions" : "interactive", + featureValues: input.featureValues, + }; }, }, }); @@ -515,6 +841,7 @@ describe("LoopService", () => { const loop = await service.runLoop({ prompt: "Create done.txt", cwd: workspaceDir, + model: "test-model", verifyPrompt: "Confirm that done.txt exists in the workspace.", maxIterations: 1, }); @@ -554,16 +881,23 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ + const service = createLoopService({ paseoHome, agentManager: manager, + agentStorage: storage, logger, providerSnapshotManager: { async resolveCreateConfig(input) { - expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); + expect(input).toMatchObject({ + parent: null, + unattended: true, + requestedMode: undefined, + }); return { - modeId: "build", - featureValues: { ...input.featureValues, auto_accept: true }, + modeId: input.unattended ? "build" : "interactive", + featureValues: input.unattended + ? { ...input.featureValues, auto_accept: true } + : input.featureValues, }; }, }, @@ -574,6 +908,7 @@ describe("LoopService", () => { prompt: "Create done.txt", cwd: workspaceDir, provider: "opencode", + model: "test-model", verifyPrompt: "Confirm that done.txt exists in the workspace.", maxIterations: 1, }); @@ -610,17 +945,18 @@ describe("LoopService", () => { registry: storage, logger, }); - const service = new LoopService({ + const service = createLoopService({ paseoHome, agentManager: manager, + agentStorage: storage, logger, - providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, }); await service.initialize(); const loop = await service.runLoop({ prompt: "Create done.txt", cwd: workspaceDir, + model: "test-model", modeId: "acceptEdits", verifierModeId: "plan", verifyPrompt: "Confirm that done.txt exists in the workspace.", @@ -659,17 +995,18 @@ describe("LoopService", () => { cancelledAgentIds.push(agentId); return cancelAgentRun(agentId); }; - const service = new LoopService({ + const service = createLoopService({ paseoHome, agentManager: manager, + agentStorage: storage, logger, - providerSnapshotManager: NO_UNATTENDED_LOOP_POLICY, }); await service.initialize(); const loop = await service.runLoop({ prompt: "Wait forever", cwd: workspaceDir, + model: "test-model", verifyChecks: ["test -f never.txt"], }); @@ -695,6 +1032,116 @@ describe("LoopService", () => { expect(cancelledAgentIds).toEqual([workerAgentId]); expect(finalLoop.logs.some((entry) => entry.text.includes("Stop requested"))).toBe(true); }); + + test("stops while waiting for loop workspace provisioning without starting a worker", async () => { + let resolveWorkspace: ((workspaceId: string) => void) | null = null; + const workspaceProvisioned = new Promise((resolve) => { + resolveWorkspace = resolve; + }); + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun() { + return "worker should not start"; + }, + }), + }, + registry: storage, + logger, + }); + const createAgent = manager.createAgent.bind(manager); + let createAgentCalls = 0; + manager.createAgent = async (...args) => { + createAgentCalls += 1; + return createAgent(...args); + }; + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + ensureWorkspaceForCreate: async () => workspaceProvisioned, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Stop before workspace is ready", + cwd: workspaceDir, + model: "test-model", + verifyPrompt: "Should not run.", + maxIterations: 1, + }); + await waitForLoopIteration(service, loop.id); + + const stopPromise = service.stopLoop(loop.id); + await waitForStopRequested(service, loop.id); + resolveWorkspace?.("workspace-created-after-stop"); + const stopped = await stopPromise; + + expect(stopped.status).toBe("stopped"); + expect(createAgentCalls).toBe(0); + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("stopped"); + expect(finalLoop.activeWorkerAgentId).toBeNull(); + expect(finalLoop.iterations[0]).toMatchObject({ + workerAgentId: null, + status: "stopped", + failureReason: "Loop stopped", + }); + }); + + test("treats externally canceled worker turns as failures", async () => { + let release: (() => void) | null = null; + const blocker = new Promise((resolve) => { + release = resolve; + }); + const manager = new AgentManager({ + clients: { + claude: new ScriptedAgentClient("claude", { + async onRun({ config }) { + if (config.title?.includes("worker")) { + await blocker; + return "finished"; + } + return '{"passed":true,"reason":"should not verify canceled worker"}'; + }, + }), + }, + registry: storage, + logger, + }); + const service = createLoopService({ + paseoHome, + agentManager: manager, + agentStorage: storage, + logger, + }); + await service.initialize(); + + const loop = await service.runLoop({ + prompt: "Wait until canceled", + cwd: workspaceDir, + model: "test-model", + verifyPrompt: "Should not run.", + maxIterations: 1, + }); + + const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); + await manager.cancelAgentRun(workerAgentId); + release?.(); + await waitForLoopCompletion(service, loop.id); + + const finalLoop = await service.inspectLoop(loop.id); + expect(finalLoop.status).toBe("failed"); + expect(finalLoop.iterations).toHaveLength(1); + expect(finalLoop.iterations[0]).toMatchObject({ + workerAgentId, + workerOutcome: "failed", + status: "failed", + }); + expect(finalLoop.iterations[0]?.failureReason).toContain("was canceled"); + expect(finalLoop.iterations[0]?.verifierAgentId).toBeNull(); + }); }); async function fsMkdir(target: string): Promise { @@ -728,6 +1175,30 @@ async function waitForActiveWorkerRun( throw new Error("Timed out waiting for loop worker run to start"); } +async function waitForLoopIteration(service: LoopService, loopId: string): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + const loop = await service.inspectLoop(loopId); + if (loop.iterations.length > 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for loop iteration to start"); +} + +async function waitForStopRequested(service: LoopService, loopId: string): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + const loop = await service.inspectLoop(loopId); + if (loop.stopRequestedAt) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for loop stop request"); +} + async function waitForCancelledAgent( cancelledAgentIds: readonly string[], agentId: string, diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 32b568cd3..e8e24b93f 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -5,11 +5,18 @@ import { z } from "zod"; import type { Logger } from "pino"; import { writeJsonFileAtomic } from "./atomic-file.js"; import { curateAgentActivity } from "./agent/activity-curator.js"; +import { + type BoundCreateAgentCommand, + type EnsureWorkspaceForCreate, + formatProviderModel, +} from "./agent/create-agent/create.js"; import type { AgentManager } from "./agent/agent-manager.js"; -import { getStructuredAgentResponse } from "./agent/agent-response-loop.js"; +import { + buildStructuredAgentResponsePrompt, + getStructuredAgentResponse, +} from "./agent/agent-response-loop.js"; import type { AgentPromptInput, - AgentSessionConfig, AgentStreamEvent, AgentTimelineItem, AgentProvider, @@ -19,11 +26,6 @@ import { createStringCommandShellEnvOverlay, } from "../utils/string-command-shell.js"; import { execCommand } from "../utils/spawn.js"; -import type { - ProviderSnapshotManager, - ResolvedProviderCreateConfig, - ResolveProviderCreateConfigOptions, -} from "./agent/provider-snapshot-manager.js"; const LOOP_ID_LENGTH = 8; const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude"; @@ -205,6 +207,21 @@ function ensureNonNegativeInteger(value: number | undefined, field: string): num return value; } +async function assertLoopCwdDirectory(cwd: string): Promise { + let stats; + try { + stats = await fs.stat(cwd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Working directory ${cwd} no longer exists`, { cause: error }); + } + throw error; + } + if (!stats.isDirectory()) { + throw new Error(`Working directory ${cwd} is not a directory`); + } +} + function buildWorkerTitle(loop: LoopRecord, iterationIndex: number): string { const prefix = loop.name ?? loop.id; return `${prefix} [loop ${iterationIndex} worker]`; @@ -215,7 +232,14 @@ function buildVerifierTitle(loop: LoopRecord, iterationIndex: number): string { return `${prefix} [loop ${iterationIndex} verifier]`; } -type CreateConfigResolver = Pick; +type LoopAgentManager = Pick< + AgentManager, + "archiveAgent" | "cancelAgentRun" | "closeAgent" | "runAgent" | "subscribe" | "waitForAgentEvent" +>; + +interface LoopExecutionContext { + workspaceId: Promise; +} function formatStreamLog(event: AgentStreamEvent): string | null { switch (event.type) { @@ -318,9 +342,10 @@ export class LoopService { constructor( private readonly options: { paseoHome: string; - agentManager: AgentManager; + agentManager: LoopAgentManager; logger: Logger; - providerSnapshotManager: CreateConfigResolver; + createAgent: BoundCreateAgentCommand; + ensureWorkspaceForCreate: EnsureWorkspaceForCreate; }, ) { this.storePath = path.join(options.paseoHome, "loops", "loops.json"); @@ -389,13 +414,15 @@ export class LoopService { if (!verifyPrompt && verifyChecks.length === 0) { throw new Error("Loop requires --verify or at least one --verify-check"); } + const cwd = path.resolve(input.cwd); + await assertLoopCwdDirectory(cwd); const createdAt = nowIso(); const record = LoopRecordSchema.parse({ id: createLoopId(), name: normalizeName(input.name), prompt, - cwd: path.resolve(input.cwd), + cwd, provider: input.provider ?? DEFAULT_LOOP_PROVIDER, model: normalizePrompt(input.model, "model"), modeId: normalizePrompt(input.modeId, "modeId"), @@ -489,10 +516,12 @@ export class LoopService { level: "info", text: "Stop requested.", }); + if (running) { + running.abortController.abort(new Error("Loop aborted")); + } await this.persist(); if (running) { - running.abortController.abort(new Error("Loop aborted")); if (loop.activeWorkerAgentId) { await this.options.agentManager.cancelAgentRun(loop.activeWorkerAgentId).catch(() => {}); } @@ -513,6 +542,11 @@ export class LoopService { private async executeLoop(loopId: string, signal: AbortSignal): Promise { const loop = this.requireLoop(loopId); const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null; + const workspaceId = this.options.ensureWorkspaceForCreate(loop.cwd, { prompt: loop.prompt }); + workspaceId.catch(() => {}); + const context: LoopExecutionContext = { + workspaceId, + }; try { for (let index = 1; ; index += 1) { @@ -551,14 +585,14 @@ export class LoopService { }); await this.persist(); - const workerPassed = await this.runWorkerIteration(loop, iteration, signal); + const workerPassed = await this.runWorkerIteration(loop, iteration, signal, context); if (signal.aborted) { throw new Error("Loop aborted"); } if (!workerPassed) { iteration.status = iteration.status === "stopped" ? "stopped" : "failed"; } else { - const verificationPassed = await this.runVerification(loop, iteration, signal); + const verificationPassed = await this.runVerification(loop, iteration, signal, context); if (verificationPassed) { iteration.status = "succeeded"; this.finishLoop(loop, "succeeded", `Iteration ${index} passed verification.`); @@ -596,11 +630,11 @@ export class LoopService { loopId: string, error: unknown, ): Promise { + const iteration = loop.activeIteration + ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) + : null; if (isAbortError(error)) { this.finishLoop(loop, "stopped", "Loop stopped."); - const iteration = loop.activeIteration - ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) - : null; if (iteration && iteration.status === "running") { iteration.status = "stopped"; iteration.failureReason = "Loop stopped"; @@ -613,9 +647,6 @@ export class LoopService { const message = error instanceof Error ? error.message : String(error); this.logger.error({ err: error, loopId }, "Loop execution failed"); this.finishLoop(loop, "failed", message); - const iteration = loop.activeIteration - ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) - : null; if (iteration && iteration.status === "running") { iteration.status = "failed"; iteration.failureReason = message; @@ -628,20 +659,40 @@ export class LoopService { loop: LoopRecord, iteration: LoopIterationRecord, signal: AbortSignal, + context: LoopExecutionContext, ): Promise { - const agent = await this.options.agentManager.createAgent( - await this.buildWorkerConfig(loop, iteration), - ); + const workspaceId = await context.workspaceId; + if (signal.aborted) { + throw new Error("Loop aborted"); + } + const created = await this.options.createAgent({ + kind: "mcp", + provider: this.formatWorkerProviderModel(loop), + cwd: loop.cwd, + workspaceId, + title: buildWorkerTitle(loop, iteration.index), + mode: loop.modeId ?? undefined, + unattended: true, + promptFailure: "return-error", + background: true, + notifyOnFinish: false, + internal: true, + }); + const agent = created.snapshot; iteration.workerAgentId = agent.id; loop.activeWorkerAgentId = agent.id; loop.updatedAt = nowIso(); await this.persist(); + let workerCanceledReason: string | null = null; const unsubscribe = this.options.agentManager.subscribe( (event) => { if (event.type !== "agent_stream") { return; } + if (event.event.type === "turn_canceled") { + workerCanceledReason = event.event.reason; + } const text = formatStreamLog(event.event); if (!text) { return; @@ -658,14 +709,11 @@ export class LoopService { ); try { - const prompt = this.toPrompt(loop.prompt); - const result = await this.options.agentManager.runAgent(agent.id, prompt); + const result = await this.options.agentManager.runAgent(agent.id, this.toPrompt(loop.prompt)); iteration.workerCompletedAt = nowIso(); - iteration.workerOutcome = result.canceled ? "canceled" : "completed"; - if (result.canceled) { - iteration.failureReason = "Worker run was canceled."; - iteration.status = "stopped"; - return false; + iteration.workerOutcome = "completed"; + if (result.canceled || workerCanceledReason) { + throw new Error(`Loop worker ${agent.id} was canceled: ${workerCanceledReason}`); } return true; } catch (error) { @@ -703,6 +751,7 @@ export class LoopService { loop: LoopRecord, iteration: LoopIterationRecord, signal: AbortSignal, + context: LoopExecutionContext, ): Promise { for (const command of loop.verifyChecks) { if (signal.aborted) { @@ -736,9 +785,25 @@ export class LoopService { } const startedAt = nowIso(); - const verifierAgent = await this.options.agentManager.createAgent( - await this.buildVerifierConfig(loop, iteration), - ); + const initialVerifierPrompt = buildStructuredAgentResponsePrompt({ + prompt: loop.verifyPrompt, + schema: LoopVerifyPromptSchema, + schemaName: "LoopVerifierResult", + }); + const created = await this.options.createAgent({ + kind: "mcp", + provider: this.formatVerifierProviderModel(loop), + cwd: loop.cwd, + workspaceId: await context.workspaceId, + title: buildVerifierTitle(loop, iteration.index), + mode: loop.verifierModeId ?? loop.modeId ?? undefined, + unattended: true, + promptFailure: "return-error", + background: true, + notifyOnFinish: false, + internal: true, + }); + const verifierAgent = created.snapshot; iteration.verifierAgentId = verifierAgent.id; loop.activeVerifierAgentId = verifierAgent.id; loop.updatedAt = nowIso(); @@ -765,8 +830,17 @@ export class LoopService { ); try { + let waitingForInitialResponse = true; const result = await getStructuredAgentResponse({ caller: async (nextPrompt) => { + if (waitingForInitialResponse) { + waitingForInitialResponse = false; + const run = await this.options.agentManager.runAgent( + verifierAgent.id, + initialVerifierPrompt, + ); + return this.resolveFinalText(run.timeline, run.finalText); + } const run = await this.options.agentManager.runAgent( verifierAgent.id, this.toPrompt(nextPrompt), @@ -812,56 +886,18 @@ export class LoopService { } } - private async buildWorkerConfig( - loop: LoopRecord, - iteration: LoopIterationRecord, - ): Promise { - const provider = loop.workerProvider ?? loop.provider; - const resolvedUnattendedConfig = loop.modeId - ? { modeId: loop.modeId, featureValues: undefined } - : await this.resolveProviderCreateConfig({ provider, cwd: loop.cwd }); - return { - provider, - cwd: loop.cwd, - model: loop.workerModel ?? loop.model ?? undefined, - modeId: resolvedUnattendedConfig.modeId, - featureValues: resolvedUnattendedConfig.featureValues, - title: buildWorkerTitle(loop, iteration.index), - internal: true, - }; + private formatWorkerProviderModel(loop: LoopRecord): string { + return formatProviderModel( + loop.workerProvider ?? loop.provider, + loop.workerModel ?? loop.model, + ); } - private async buildVerifierConfig( - loop: LoopRecord, - iteration: LoopIterationRecord, - ): Promise { - const provider = loop.verifierProvider ?? loop.provider; - const explicitModeId = loop.verifierModeId ?? loop.modeId; - const resolvedUnattendedConfig = explicitModeId - ? { modeId: explicitModeId, featureValues: undefined } - : await this.resolveProviderCreateConfig({ provider, cwd: loop.cwd }); - return { - provider, - cwd: loop.cwd, - model: loop.verifierModel ?? loop.model ?? undefined, - modeId: resolvedUnattendedConfig.modeId, - featureValues: resolvedUnattendedConfig.featureValues, - title: buildVerifierTitle(loop, iteration.index), - internal: true, - }; - } - - private resolveProviderCreateConfig( - input: Pick, - ): Promise { - return this.options.providerSnapshotManager.resolveCreateConfig({ - provider: input.provider, - cwd: input.cwd, - requestedMode: undefined, - featureValues: undefined, - parent: null, - unattended: true, - }); + private formatVerifierProviderModel(loop: LoopRecord): string { + return formatProviderModel( + loop.verifierProvider ?? loop.provider, + loop.verifierModel ?? loop.model, + ); } private resolveFinalText(timeline: AgentTimelineItem[], finalText: string): string { diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index ec2db926b..4abc112be 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -1,9 +1,10 @@ -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join, resolve as resolvePath } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { AgentManager } from "../agent/agent-manager.js"; import { AgentStorage } from "../agent/agent-storage.js"; +import { createAgentCommand } from "../agent/create-agent/create.js"; import type { AgentCapabilityFlags, AgentClient, @@ -22,11 +23,19 @@ import type { import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; -import { ScheduleService, ScheduleTargetGoneError } from "./service.js"; +import { createLocalCheckoutWorkspace } from "../paseo-worktree-service.js"; +import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js"; +import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "../workspace-registry.js"; +import { + ScheduleService, + ScheduleTargetGoneError, + type ScheduleServiceOptions, +} from "./service.js"; +import { ScheduleStore } from "./store.js"; import type { ScheduleExecutionResult, StoredSchedule } from "@getpaseo/protocol/schedule/types"; interface ScheduleServiceInternals { - executeSchedule(schedule: StoredSchedule): Promise; + executeSchedule(schedule: StoredSchedule, runId: string): Promise; } const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = { @@ -40,11 +49,89 @@ const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = { const NO_UNATTENDED_SCHEDULE_POLICY: Pick = { async resolveCreateConfig(input) { - expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); - return { modeId: undefined, featureValues: input.featureValues }; + return { + modeId: input.unattended ? input.requestedMode : "interactive", + featureValues: input.featureValues, + }; }, }; +type TestScheduleServiceOptions = Omit< + ScheduleServiceOptions, + "createAgent" | "ensureWorkspaceForCreate" | "workspaceRegistry" +> & { + agentManager: AgentManager; + providerSnapshotManager: Pick; + createAgent?: ScheduleServiceOptions["createAgent"]; + ensureWorkspaceForCreate?: ScheduleServiceOptions["ensureWorkspaceForCreate"]; + workspaceRegistry?: ScheduleServiceOptions["workspaceRegistry"]; +}; + +function createScheduleService(options: TestScheduleServiceOptions): ScheduleService { + return new ScheduleService({ + ...options, + createAgent: + options.createAgent ?? + ((input) => + createAgentCommand( + { + agentManager: options.agentManager, + agentStorage: options.agentStorage, + logger: options.logger, + providerSnapshotManager: options.providerSnapshotManager as ProviderSnapshotManager, + ensureWorkspaceForCreate: options.ensureWorkspaceForCreate, + }, + input, + )), + ensureWorkspaceForCreate: + options.ensureWorkspaceForCreate ?? (async () => "workspace-created-for-schedule"), + workspaceRegistry: options.workspaceRegistry ?? { + async get(workspaceId) { + return { + workspaceId, + projectId: "test-project", + cwd: options.paseoHome, + kind: "directory", + displayName: "test-project", + title: null, + branch: null, + baseBranch: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + }; + }, + }, + }); +} + +async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{ + workspaceRegistry: FileBackedWorkspaceRegistry; + ensureWorkspaceForCreate: ScheduleServiceOptions["ensureWorkspaceForCreate"]; +}> { + const workspaceRegistry = new FileBackedWorkspaceRegistry( + join(rootDir, "projects", "workspaces.json"), + createTestLogger(), + ); + const projectRegistry = new FileBackedProjectRegistry( + join(rootDir, "projects", "projects.json"), + createTestLogger(), + ); + await workspaceRegistry.initialize(); + await projectRegistry.initialize(); + const workspaceGitService = createNoopWorkspaceGitService(); + return { + workspaceRegistry, + ensureWorkspaceForCreate: async (cwd, firstAgentContext) => { + const workspace = await createLocalCheckoutWorkspace( + { cwd, title: firstAgentContext?.prompt ?? null }, + { projectRegistry, workspaceRegistry, workspaceGitService }, + ); + return workspace.workspaceId; + }, + }; +} + function buildAgentRecord(params: { id: string; cwd: string; @@ -96,7 +183,7 @@ describe("ScheduleService", () => { }); test("ticks due schedules and records run history on disk", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -135,7 +222,7 @@ describe("ScheduleService", () => { }); test("pause and resume update persisted schedule state", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -171,7 +258,7 @@ describe("ScheduleService", () => { }); test("completes schedules when max runs is reached", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -211,7 +298,7 @@ describe("ScheduleService", () => { clients: createTestAgentClients(), registry: agentStorage, }); - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: manager, @@ -227,6 +314,7 @@ describe("ScheduleService", () => { type: "new-agent", config: { provider: "claude", + model: "test-model", cwd: tempDir, approvalPolicy: "never", }, @@ -251,7 +339,7 @@ describe("ScheduleService", () => { clients: createTestAgentClients(), registry: agentStorage, }); - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: manager, @@ -267,6 +355,7 @@ describe("ScheduleService", () => { type: "new-agent", config: { provider: "claude", + model: "test-model", cwd: tempDir, approvalPolicy: "never", }, @@ -284,6 +373,1179 @@ describe("ScheduleService", () => { expect(storedAgent?.title).toBe("Audit flaky checkout flow"); }); + test("fired new-agent schedules attach agents to a real workspace registry row", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + const created = await service.create({ + prompt: "attach me", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + maxRuns: 1, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + const agentId = inspected.runs[0]?.agentId; + expect(agentId).toMatch(/^[0-9a-f-]{36}$/); + const storedAgent = await agentStorage.get(agentId!); + expect(storedAgent?.workspaceId).toBe(inspected.target.config.workspaceId); + expect(await workspaceRegistry.get(storedAgent!.workspaceId!)).toMatchObject({ + workspaceId: storedAgent?.workspaceId, + cwd: tempDir, + }); + }); + + test("two fires of the same new-agent schedule share one stamped workspace", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + const created = await service.create({ + prompt: "repeat in one workspace", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + maxRuns: 2, + }); + + await service.tick(); + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs).toHaveLength(2); + const firstAgent = await agentStorage.get(inspected.runs[0]!.agentId!); + const secondAgent = await agentStorage.get(inspected.runs[1]!.agentId!); + expect(firstAgent?.workspaceId).toBe(inspected.target.config.workspaceId); + expect(secondAgent?.workspaceId).toBe(inspected.target.config.workspaceId); + expect(firstAgent?.workspaceId).toBe(secondAgent?.workspaceId); + expect(await workspaceRegistry.list()).toHaveLength(1); + }); + + test("new-agent schedules with non-normalized cwd reuse one stamped workspace", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const nonNormalizedCwd = join(tempDir, "child", ".."); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + const created = await service.create({ + prompt: "repeat from relative cwd", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: nonNormalizedCwd, + }, + }, + maxRuns: 2, + }); + + await service.tick(); + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + const firstAgent = await agentStorage.get(inspected.runs[0]!.agentId!); + const secondAgent = await agentStorage.get(inspected.runs[1]!.agentId!); + expect(firstAgent?.workspaceId).toBe(secondAgent?.workspaceId); + expect(firstAgent?.workspaceId).toBe(inspected.target.config.workspaceId); + expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ + workspaceId: inspected.target.config.workspaceId, + cwd: resolvePath(nonNormalizedCwd), + }); + expect(await workspaceRegistry.list()).toHaveLength(1); + }); + + test("distinct new-agent schedules with the same target each get their own workspace", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + const target = { + type: "new-agent" as const, + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + title: "same target", + }, + }; + + const first = await service.create({ + prompt: "first schedule", + cadence: { type: "every", everyMs: 60_000 }, + target, + runOnCreate: false, + }); + const second = await service.create({ + prompt: "second schedule", + cadence: { type: "every", everyMs: 60_000 }, + target, + runOnCreate: false, + }); + + expect(first.target.config.workspaceId).toMatch(/^wks_/); + expect(second.target.config.workspaceId).toMatch(/^wks_/); + expect(first.target.config.workspaceId).not.toBe(second.target.config.workspaceId); + expect(await workspaceRegistry.list()).toHaveLength(2); + }); + + test("new-agent schedule creates ignore client-provided workspace stamps", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const existingWorkspaceId = await ensureWorkspaceForCreate(tempDir, { + prompt: "client workspace", + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + prompt: "server-owned stamp", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + workspaceId: existingWorkspaceId, + }, + }, + runOnCreate: false, + }); + + expect(created.target.config.workspaceId).toMatch(/^wks_/); + expect(created.target.config.workspaceId).not.toBe(existingWorkspaceId); + expect(await workspaceRegistry.list()).toHaveLength(2); + }); + + test("legacy new-agent schedules without workspaceId stamp and persist on first fire", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const store = new ScheduleStore(join(tempDir, "schedules")); + const legacy = await store.create({ + name: null, + prompt: "legacy stamp", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: 1, + runs: [], + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + await service.tick(); + + const inspected = await service.inspect(legacy.id); + expect(inspected.target.config.workspaceId).toMatch(/^wks_/); + expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ + workspaceId: inspected.target.config.workspaceId, + cwd: tempDir, + }); + const reloaded = await store.get(legacy.id); + expect(reloaded?.target.config.workspaceId).toBe(inspected.target.config.workspaceId); + }); + + test("new-agent schedules restamp a missing workspaceId when the cwd still exists", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const store = new ScheduleStore(join(tempDir, "schedules")); + const legacy = await store.create({ + name: null, + prompt: "missing workspace stamp", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + workspaceId: "wks_missing", + }, + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: 1, + runs: [], + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + await service.tick(); + + const inspected = await service.inspect(legacy.id); + expect(inspected.target.config.workspaceId).toMatch(/^wks_/); + expect(inspected.target.config.workspaceId).not.toBe("wks_missing"); + expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ + workspaceId: inspected.target.config.workspaceId, + cwd: tempDir, + archivedAt: null, + }); + expect(inspected.runs[0]?.status).toBe("succeeded"); + }); + + test("new-agent schedules restamp an archived workspaceId when the cwd still exists", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const archivedWorkspaceId = await ensureWorkspaceForCreate(tempDir, { + prompt: "archived workspace stamp", + }); + await workspaceRegistry.archive(archivedWorkspaceId, "2026-01-01T00:00:30.000Z"); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const store = new ScheduleStore(join(tempDir, "schedules")); + const legacy = await store.create({ + name: null, + prompt: "archived workspace stamp", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + workspaceId: archivedWorkspaceId, + }, + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: 1, + runs: [], + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + await service.tick(); + + const inspected = await service.inspect(legacy.id); + expect(inspected.target.config.workspaceId).toMatch(/^wks_/); + expect(inspected.target.config.workspaceId).not.toBe(archivedWorkspaceId); + expect(await workspaceRegistry.get(archivedWorkspaceId)).toMatchObject({ + archivedAt: "2026-01-01T00:00:30.000Z", + }); + expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ + workspaceId: inspected.target.config.workspaceId, + cwd: tempDir, + archivedAt: null, + }); + expect(inspected.runs[0]?.status).toBe("succeeded"); + }); + + test("new-agent schedules do not reuse a cached stamp after its workspace is archived", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const store = new ScheduleStore(join(tempDir, "schedules")); + const legacy = await store.create({ + name: null, + prompt: "cached archived workspace stamp", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: 2, + runs: [], + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + await service.tick(); + const firstFire = await service.inspect(legacy.id); + const archivedWorkspaceId = firstFire.target.config.workspaceId!; + await workspaceRegistry.archive(archivedWorkspaceId, "2026-01-01T00:00:30.000Z"); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(legacy.id); + expect(inspected.target.config.workspaceId).toMatch(/^wks_/); + expect(inspected.target.config.workspaceId).not.toBe(archivedWorkspaceId); + expect(await workspaceRegistry.get(archivedWorkspaceId)).toMatchObject({ + archivedAt: "2026-01-01T00:00:30.000Z", + }); + expect(await workspaceRegistry.get(inspected.target.config.workspaceId!)).toMatchObject({ + workspaceId: inspected.target.config.workspaceId, + cwd: tempDir, + archivedAt: null, + }); + expect(inspected.runs).toHaveLength(2); + expect(inspected.runs[1]?.status).toBe("succeeded"); + const secondAgent = await agentStorage.get(inspected.runs[1]!.agentId!); + expect(secondAgent?.workspaceId).toBe(inspected.target.config.workspaceId); + }); + + test("new-agent schedule cwd updates restamp the workspace for the new cwd", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const nextCwd = join(tempDir, "next-cwd"); + await mkdir(nextCwd, { recursive: true }); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + const created = await service.create({ + prompt: "change cwd", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + maxRuns: 1, + }); + const originalWorkspaceId = created.target.config.workspaceId; + + const updated = await service.update({ + id: created.id, + newAgentConfig: { cwd: nextCwd }, + }); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(updated.target.config.workspaceId).toMatch(/^wks_/); + expect(updated.target.config.workspaceId).not.toBe(originalWorkspaceId); + expect(inspected.target.config.workspaceId).toBe(updated.target.config.workspaceId); + expect(await workspaceRegistry.get(updated.target.config.workspaceId!)).toMatchObject({ + workspaceId: updated.target.config.workspaceId, + cwd: nextCwd, + archivedAt: null, + }); + const storedAgent = await agentStorage.get(inspected.runs[0]!.agentId!); + expect(storedAgent?.cwd).toBe(nextCwd); + expect(storedAgent?.workspaceId).toBe(updated.target.config.workspaceId); + }); + + test("new-agent schedule equivalent cwd updates reuse the stamped workspace", async () => { + const { workspaceRegistry, ensureWorkspaceForCreate } = + await createRegistryBackedWorkspaceEnsure(tempDir); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate, + workspaceRegistry, + now: () => now, + }); + + const created = await service.create({ + prompt: "same cwd", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + maxRuns: 1, + }); + const originalWorkspaceId = created.target.config.workspaceId; + + const updated = await service.update({ + id: created.id, + newAgentConfig: { cwd: join(tempDir, ".") }, + }); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(updated.target.config.workspaceId).toBe(originalWorkspaceId); + expect(inspected.target.config.workspaceId).toBe(originalWorkspaceId); + expect(await workspaceRegistry.list()).toHaveLength(1); + expect(await workspaceRegistry.get(originalWorkspaceId!)).toMatchObject({ + workspaceId: originalWorkspaceId, + cwd: tempDir, + archivedAt: null, + }); + }); + + test("new-agent schedule creation validates cwd before minting a workspace", async () => { + let ensureCalls = 0; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate: async () => { + ensureCalls += 1; + return "workspace-created-for-missing-cwd"; + }, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + await expect( + service.create({ + prompt: "missing cwd", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: join(tempDir, "does-not-exist") }, + }, + }), + ).rejects.toThrow(ScheduleTargetGoneError); + expect(ensureCalls).toBe(0); + }); + + test("new-agent schedule creation rejects non-directory cwd before minting a workspace", async () => { + const filePath = join(tempDir, "not-a-directory.txt"); + await writeFile(filePath, "not a directory"); + let ensureCalls = 0; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate: async () => { + ensureCalls += 1; + return "workspace-created-for-file-cwd"; + }, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + await expect( + service.create({ + prompt: "file cwd", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: filePath }, + }, + }), + ).rejects.toThrow("is not a directory"); + expect(ensureCalls).toBe(0); + }); + + test("new-agent schedule cwd updates validate cwd before minting a workspace", async () => { + let ensureCalls = 0; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate: async () => { + ensureCalls += 1; + return `workspace-${ensureCalls}`; + }, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + prompt: "valid cwd", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir }, + }, + }); + + await expect( + service.update({ + id: created.id, + newAgentConfig: { cwd: join(tempDir, "missing-update-cwd") }, + }), + ).rejects.toThrow(ScheduleTargetGoneError); + expect(ensureCalls).toBe(1); + }); + + test("concurrent lazy stamping and update mint one workspace and keep the stored record consistent", async () => { + let releaseFirstStamp: (() => void) | null = null; + const firstStampStarted = new Promise((resolve) => { + releaseFirstStamp = resolve; + }); + let unblockStamp: (() => void) | null = null; + const stampBlocked = new Promise((resolve) => { + unblockStamp = resolve; + }); + let ensureCalls = 0; + const store = new ScheduleStore(join(tempDir, "schedules")); + const legacy = await store.create({ + name: null, + prompt: "race stamp", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: 1, + runs: [], + }); + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate: async () => { + ensureCalls += 1; + releaseFirstStamp?.(); + await stampBlocked; + return `workspace-${ensureCalls}`; + }, + now: () => now, + }); + + const tickPromise = service.tick(); + await firstStampStarted; + const updatePromise = service.update({ id: legacy.id, prompt: "updated race stamp" }); + unblockStamp?.(); + await Promise.all([tickPromise, updatePromise]); + + const inspected = await service.inspect(legacy.id); + expect(ensureCalls).toBe(1); + expect(inspected.prompt).toBe("updated race stamp"); + expect(inspected.target.config.workspaceId).toBe("workspace-1"); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]?.status).toBe("succeeded"); + }); + + test("concurrent run finish and update preserve the config stamp and run outcome", async () => { + let finishRun: (() => void) | null = null; + const runBlocked = new Promise((resolve) => { + finishRun = resolve; + }); + let releaseRun: (() => void) | null = null; + const runStarted = new Promise((resolve) => { + releaseRun = resolve; + }); + let releaseStamp: (() => void) | null = null; + const stampBlocked = new Promise((resolve) => { + releaseStamp = resolve; + }); + let stampStarted: (() => void) | null = null; + const stampStartedSignal = new Promise((resolve) => { + stampStarted = resolve; + }); + const store = new ScheduleStore(join(tempDir, "schedules")); + const legacy = await store.create({ + name: null, + prompt: "finish/update race", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate: async () => { + stampStarted?.(); + await stampBlocked; + return "workspace-from-update"; + }, + now: () => now, + runner: async () => { + releaseRun?.(); + await runBlocked; + return { + agentId: null, + output: "finished while updating", + }; + }, + }); + + const tickPromise = service.tick(); + await runStarted; + const updatePromise = service.update({ + id: legacy.id, + newAgentConfig: { modeId: "full-access" }, + }); + await stampStartedSignal; + finishRun?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseStamp?.(); + await Promise.all([tickPromise, updatePromise]); + + const inspected = await service.inspect(legacy.id); + expect(inspected.target).toMatchObject({ + type: "new-agent", + config: { + modeId: "full-access", + workspaceId: "workspace-from-update", + }, + }); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]).toMatchObject({ + status: "succeeded", + output: "finished while updating", + error: null, + }); + }); + + test("fired new-agent runs use the schedule snapshot captured before concurrent edits", async () => { + let releaseStamp: (() => void) | null = null; + const stampBlocked = new Promise((resolve) => { + releaseStamp = resolve; + }); + let firstStampStarted: (() => void) | null = null; + const firstStampSignal = new Promise((resolve) => { + firstStampStarted = resolve; + }); + let ensureCalls = 0; + const nextCwd = join(tempDir, "next-cwd"); + await mkdir(nextCwd, { recursive: true }); + const createdInputs: Parameters[0][] = []; + const runPrompts: AgentPromptInput[] = []; + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + manager.runAgent = async (_agentId, prompt) => { + runPrompts.push(prompt); + return { + sessionId: "scheduled-snapshot-run", + finalText: "old snapshot result", + timeline: [{ type: "assistant_message", text: "old snapshot result" }], + }; + }; + manager.waitForAgentEvent = async () => ({ + status: "idle", + permission: null, + lastMessage: "old snapshot result", + }); + manager.archiveAgent = async () => {}; + const store = new ScheduleStore(join(tempDir, "schedules")); + const legacy = await store.create({ + name: null, + prompt: "old prompt", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "old-model", + cwd: tempDir, + }, + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: 1, + runs: [], + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate: async () => { + ensureCalls += 1; + if (ensureCalls === 1) { + firstStampStarted?.(); + await stampBlocked; + return "workspace-old-snapshot"; + } + return "workspace-new-config"; + }, + createAgent: async (input) => { + createdInputs.push(input); + const snapshot = { + id: "00000000-0000-0000-0000-000000000321", + provider: "claude", + cwd: input.cwd ?? tempDir, + workspaceId: input.workspaceId, + status: "idle", + lifecycle: "idle", + }; + return { + snapshot: snapshot as Awaited< + ReturnType + >["snapshot"], + liveSnapshot: snapshot as Awaited< + ReturnType + >["liveSnapshot"], + background: true, + initialPromptStarted: true, + initialPromptError: null, + }; + }, + now: () => now, + }); + + const tickPromise = service.tick(); + await firstStampSignal; + const updatePromise = service.update({ + id: legacy.id, + prompt: "new prompt", + newAgentConfig: { + cwd: nextCwd, + model: "new-model", + }, + }); + await updatePromise; + releaseStamp?.(); + await tickPromise; + + expect(createdInputs).toHaveLength(1); + expect(createdInputs[0]).toMatchObject({ + cwd: tempDir, + workspaceId: "workspace-old-snapshot", + config: { + model: "old-model", + cwd: tempDir, + }, + }); + expect(createdInputs[0].initialPrompt).toBeUndefined(); + expect(runPrompts).toEqual(["old prompt"]); + const inspected = await service.inspect(legacy.id); + expect(inspected.prompt).toBe("new prompt"); + expect(inspected.target.config).toMatchObject({ + cwd: nextCwd, + model: "new-model", + workspaceId: "workspace-new-config", + }); + expect(inspected.runs[0]).toMatchObject({ + status: "succeeded", + output: "old snapshot result", + }); + }); + + test("scheduled new-agent slash prompts run as normal foreground prompts", async () => { + const createdInputs: Parameters[0][] = []; + const runPrompts: AgentPromptInput[] = []; + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + manager.runAgent = async (_agentId, prompt) => { + runPrompts.push(prompt); + return { + sessionId: "scheduled-slash-run", + finalText: "compacted", + timeline: [{ type: "assistant_message", text: "compacted" }], + }; + }; + manager.waitForAgentEvent = async () => ({ + status: "idle", + permission: null, + lastMessage: "compacted", + }); + manager.archiveAgent = async () => {}; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + createAgent: async (input) => { + createdInputs.push(input); + const snapshot = { + id: "00000000-0000-0000-0000-000000000322", + provider: "claude", + cwd: input.cwd ?? tempDir, + workspaceId: input.workspaceId, + status: "idle", + lifecycle: "idle", + }; + return { + snapshot: snapshot as Awaited< + ReturnType + >["snapshot"], + liveSnapshot: snapshot as Awaited< + ReturnType + >["liveSnapshot"], + background: true, + initialPromptStarted: false, + initialPromptError: null, + }; + }, + now: () => now, + }); + + const created = await service.create({ + prompt: "/compact", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + await service.tick(); + + expect(createdInputs).toHaveLength(1); + expect(createdInputs[0].initialPrompt).toBeUndefined(); + expect(runPrompts).toEqual(["/compact"]); + const inspected = await service.inspect(created.id); + expect(inspected.runs[0]).toMatchObject({ + status: "succeeded", + output: "compacted", + }); + }); + + test("scheduled new-agent run output falls back to final text and curated timeline", async () => { + let runCount = 0; + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + manager.runAgent = async () => { + runCount += 1; + return runCount === 1 + ? { + sessionId: "scheduled-final-text-run", + finalText: "final text output", + timeline: [], + } + : { + sessionId: "scheduled-timeline-run", + finalText: "", + timeline: [{ type: "assistant_message", text: "timeline output" }], + }; + }; + manager.waitForAgentEvent = async () => ({ + status: "idle", + permission: null, + lastMessage: null, + }); + manager.archiveAgent = async () => {}; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + createAgent: async (input) => { + const snapshot = { + id: + runCount === 0 + ? "00000000-0000-0000-0000-000000000323" + : "00000000-0000-0000-0000-000000000324", + provider: "claude", + cwd: input.cwd ?? tempDir, + workspaceId: input.workspaceId, + status: "idle", + lifecycle: "idle", + }; + return { + snapshot: snapshot as Awaited< + ReturnType + >["snapshot"], + liveSnapshot: snapshot as Awaited< + ReturnType + >["liveSnapshot"], + background: true, + initialPromptStarted: false, + initialPromptError: null, + }; + }, + now: () => now, + }); + + const finalTextSchedule = await service.create({ + prompt: "final text", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + const timelineSchedule = await service.create({ + prompt: "timeline", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + + await service.runOnce(finalTextSchedule.id); + await service.runOnce(timelineSchedule.id); + + expect((await service.inspect(finalTextSchedule.id)).runs[0]?.output).toBe("final text output"); + expect((await service.inspect(timelineSchedule.id)).runs[0]?.output).toContain( + "timeline output", + ); + }); + + test("scheduled new-agent cancellations fail the run", async () => { + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + manager.runAgent = async () => ({ + sessionId: "scheduled-canceled-run", + finalText: "", + timeline: [], + canceled: true, + }); + manager.waitForAgentEvent = async () => ({ + status: "idle", + permission: null, + lastMessage: null, + }); + manager.archiveAgent = async () => {}; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + createAgent: async (input) => { + const snapshot = { + id: "00000000-0000-0000-0000-000000000325", + provider: "claude", + cwd: input.cwd ?? tempDir, + workspaceId: input.workspaceId, + status: "idle", + lifecycle: "idle", + }; + return { + snapshot: snapshot as Awaited< + ReturnType + >["snapshot"], + liveSnapshot: snapshot as Awaited< + ReturnType + >["liveSnapshot"], + background: true, + initialPromptStarted: false, + initialPromptError: null, + }; + }, + now: () => now, + }); + + const created = await service.create({ + prompt: "cancel me", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs[0]).toMatchObject({ + status: "failed", + error: expect.stringContaining("was canceled"), + }); + }); + test("shows scheduled new-agent prompts as normal user turns", async () => { class PromptEchoScheduleSession implements AgentSession { readonly provider = "claude"; @@ -292,7 +1554,28 @@ describe("ScheduleService", () => { private turnCount = 0; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); - async run(_prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { + async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { + const turnId = `run-${++this.turnCount}`; + const textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt); + this.emit({ type: "turn_started", provider: this.provider, turnId }); + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "user_message", text: textPrompt }, + }); + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "assistant_message", text: "done" }, + }); + this.emit({ + type: "turn_completed", + provider: this.provider, + turnId, + usage: { inputTokens: 1, outputTokens: 1 }, + }); return { sessionId: this.id, finalText: "done", @@ -408,7 +1691,7 @@ describe("ScheduleService", () => { clients: { claude: new PromptEchoScheduleClient() }, registry: agentStorage, }); - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: manager, @@ -433,6 +1716,7 @@ describe("ScheduleService", () => { type: "new-agent", config: { provider: "claude", + model: "test-model", cwd: tempDir, approvalPolicy: "never", }, @@ -592,7 +1876,7 @@ describe("ScheduleService", () => { clients: { claude: client }, registry: agentStorage, }); - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: manager, @@ -608,6 +1892,7 @@ describe("ScheduleService", () => { type: "new-agent", config: { provider: "claude", + model: "test-model", cwd: tempDir, approvalPolicy: "never", }, @@ -628,21 +1913,142 @@ describe("ScheduleService", () => { expect(storedAgent?.archivedAt).toBeTruthy(); }); + test("records prompt-start failures as failed and archives the scheduled agent", async () => { + class StartFailureScheduleSession implements AgentSession { + readonly provider = "claude"; + readonly capabilities = SCHEDULE_TEST_CAPABILITIES; + readonly id = "scheduled-start-failure-session"; + + async run(): Promise { + return { + sessionId: this.id, + finalText: "", + timeline: [], + }; + } + + async startTurn(): Promise<{ turnId: string }> { + throw new Error("start turn exploded"); + } + + subscribe(): () => void { + return () => {}; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: null, + modeId: null, + }; + } + + async getAvailableModes(): Promise { + return []; + } + + async getCurrentMode(): Promise { + return null; + } + + async setMode(): Promise {} + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence(): AgentPersistenceHandle { + return { + provider: this.provider, + sessionId: this.id, + }; + } + + async interrupt(): Promise {} + + async close(): Promise {} + } + + const manager = new AgentManager({ + logger: createTestLogger(), + clients: { + claude: { + provider: "claude", + capabilities: SCHEDULE_TEST_CAPABILITIES, + createSession: async () => new StartFailureScheduleSession(), + resumeSession: async () => new StartFailureScheduleSession(), + fetchCatalog: async () => ({ models: [], modes: [] }), + isAvailable: async () => true, + }, + }, + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + }); + + const created = await service.create({ + prompt: "this run fails before starting", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + }, + }, + maxRuns: 1, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs[0]).toMatchObject({ + status: "failed", + agentId: null, + error: expect.stringContaining("start turn exploded"), + }); + const storedAgents = await agentStorage.list(); + expect(storedAgents).toHaveLength(1); + expect(storedAgents[0]).toMatchObject({ + archivedAt: expect.any(String), + }); + }); + test("defaults new-agent modeId to provider's unattended mode", async () => { const manager = new AgentManager({ logger: createTestLogger(), clients: createTestAgentClients(), registry: agentStorage, }); - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: manager, agentStorage, providerSnapshotManager: { async resolveCreateConfig(input) { - expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); - return { modeId: "bypassPermissions", featureValues: input.featureValues }; + expect(input).toMatchObject({ + parent: null, + unattended: true, + requestedMode: undefined, + }); + return { + modeId: input.unattended ? "bypassPermissions" : "interactive", + featureValues: input.featureValues, + }; }, }, now: () => now, @@ -655,6 +2061,7 @@ describe("ScheduleService", () => { type: "new-agent", config: { provider: "claude", + model: "test-model", cwd: tempDir, approvalPolicy: "never", }, @@ -696,17 +2103,23 @@ describe("ScheduleService", () => { clients, registry: agentStorage, }); - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: manager, agentStorage, providerSnapshotManager: { async resolveCreateConfig(input) { - expect(input).toMatchObject({ parent: null, unattended: true, requestedMode: undefined }); + expect(input).toMatchObject({ + parent: null, + unattended: true, + requestedMode: undefined, + }); return { - modeId: "build", - featureValues: { ...input.featureValues, auto_accept: true }, + modeId: input.unattended ? "build" : "interactive", + featureValues: input.unattended + ? { ...input.featureValues, auto_accept: true } + : input.featureValues, }; }, }, @@ -720,6 +2133,7 @@ describe("ScheduleService", () => { type: "new-agent", config: { provider: "opencode", + model: "test-model", cwd: tempDir, }, }, @@ -737,8 +2151,109 @@ describe("ScheduleService", () => { }); }); + test("forwards stored new-agent session config to the launched scheduled agent", async () => { + const createdConfigs: AgentSessionConfig[] = []; + const clients = createTestAgentClients(); + const claudeClient = clients.claude; + if (!claudeClient) { + throw new Error("Expected Claude test client"); + } + clients.claude = { + provider: claudeClient.provider, + capabilities: claudeClient.capabilities, + createSession: async (...args) => { + createdConfigs.push(args[0]); + return claudeClient.createSession(...args); + }, + resumeSession: (...args) => claudeClient.resumeSession(...args), + fetchCatalog: (...args) => claudeClient.fetchCatalog(...args), + isAvailable: () => claudeClient.isAvailable(), + } satisfies AgentClient; + const manager = new AgentManager({ + logger: createTestLogger(), + clients, + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: { + async resolveCreateConfig(input) { + expect(input).toMatchObject({ + parent: null, + unattended: true, + requestedMode: "stored-mode", + }); + return { + modeId: input.requestedMode, + featureValues: { ...input.featureValues, resolved: true }, + }; + }, + }, + now: () => now, + }); + + await service.create({ + prompt: "Use the stored launch config", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { + provider: "claude", + model: "test-model", + cwd: tempDir, + title: "Stored launch title", + modeId: "stored-mode", + thinkingOptionId: "think-hard", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + networkAccess: true, + webSearch: true, + featureValues: { auto_accept: true }, + extra: { codex: { profile: "full-access" } }, + systemPrompt: "Stay concise.", + mcpServers: { + docs: { + command: "node", + args: ["docs-server.js"], + }, + }, + }, + }, + maxRuns: 1, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + expect(createdConfigs).toHaveLength(1); + expect(createdConfigs[0]).toMatchObject({ + provider: "claude", + cwd: tempDir, + title: "Stored launch title", + model: "test-model", + modeId: "stored-mode", + thinkingOptionId: "think-hard", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + networkAccess: true, + webSearch: true, + featureValues: { auto_accept: true, resolved: true }, + extra: { codex: { profile: "full-access" } }, + systemPrompt: "Stay concise.", + mcpServers: { + docs: { + command: "node", + args: ["docs-server.js"], + }, + }, + }); + }); + test("advances stale nextRunAt on daemon restart", async () => { - const service1 = new ScheduleService({ + const service1 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -763,7 +2278,7 @@ describe("ScheduleService", () => { // Simulate daemon restart 10 minutes later now = new Date("2026-01-01T00:10:00.000Z"); - const service2 = new ScheduleService({ + const service2 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -789,7 +2304,7 @@ describe("ScheduleService", () => { finishRun = resolve; }); - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -838,7 +2353,7 @@ describe("ScheduleService", () => { test("rejects archived target agents before loading them", async () => { const manager = new AgentManager({ logger: createTestLogger() }); - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: manager, @@ -873,30 +2388,33 @@ describe("ScheduleService", () => { }); await expect( - (service as unknown as ScheduleServiceInternals).executeSchedule({ - id: "schedule-1", - name: null, - prompt: "Check archived agent", - cadence: { type: "every", everyMs: 60_000 }, - target: { - type: "agent", - agentId: "archived-agent", + (service as unknown as ScheduleServiceInternals).executeSchedule( + { + id: "schedule-1", + name: null, + prompt: "Check archived agent", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "agent", + agentId: "archived-agent", + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], }, - status: "active", - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - nextRunAt: now.toISOString(), - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: null, - runs: [], - }), + "run-1", + ), ).rejects.toThrow("Agent archived-agent is archived"); }); test("defaults --every schedules to fire immediately on creation", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -919,7 +2437,7 @@ describe("ScheduleService", () => { }); test("--every with runOnCreate=false waits the full interval", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -943,7 +2461,7 @@ describe("ScheduleService", () => { }); test("--cron defaults to the next cron slot", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -966,7 +2484,7 @@ describe("ScheduleService", () => { }); test("--cron with runOnCreate=true fires immediately on creation", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -990,7 +2508,7 @@ describe("ScheduleService", () => { }); test("runOnce records a run without changing nextRunAt or completing the schedule", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1026,7 +2544,7 @@ describe("ScheduleService", () => { }); test("update mutates cadence, prompt, name, and target fields in place", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1048,6 +2566,8 @@ describe("ScheduleService", () => { expect(created.runs).toEqual([]); now = new Date("2026-01-01T00:00:30.000Z"); + const nextCwd = join(tempDir, "new-path"); + await mkdir(nextCwd, { recursive: true }); const updated = await service.update({ id: created.id, prompt: "second prompt", @@ -1057,7 +2577,7 @@ describe("ScheduleService", () => { provider: "codex", model: "gpt-5", modeId: "full-access", - cwd: "/new/path", + cwd: nextCwd, }, }); @@ -1068,7 +2588,8 @@ describe("ScheduleService", () => { type: "new-agent", config: { provider: "codex", - cwd: "/new/path", + cwd: nextCwd, + workspaceId: "workspace-created-for-schedule", model: "gpt-5", modeId: "full-access", }, @@ -1078,8 +2599,46 @@ describe("ScheduleService", () => { expect(updated.createdAt).toBe(created.createdAt); }); + test("update stamps legacy new-agent schedules even when target fields are unchanged", async () => { + const store = new ScheduleStore(join(tempDir, "schedules")); + const legacy = await store.create({ + name: null, + prompt: "legacy update", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir }, + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const updated = await service.update({ id: legacy.id, prompt: "legacy updated" }); + + expect(updated.target.config.workspaceId).toBe("workspace-created-for-schedule"); + expect((await store.get(legacy.id))?.target.config.workspaceId).toBe( + "workspace-created-for-schedule", + ); + }); + test("update switches between every and cron cadences and recomputes nextRunAt", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1111,8 +2670,41 @@ describe("ScheduleService", () => { expect(back.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); }); + test("update preserves a cron cadence timezone when the new cadence omits it", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const created = await service.create({ + prompt: "p", + cadence: { + type: "cron", + expression: "0 9 * * *", + timezone: "America/New_York", + }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + + const updated = await service.update({ + id: created.id, + cadence: { type: "cron", expression: "30 9 * * *" }, + }); + + expect(updated.cadence).toEqual({ + type: "cron", + expression: "30 9 * * *", + timezone: "America/New_York", + }); + }); + test("update preserves nextRunAt and run history when cadence is unchanged", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1144,7 +2736,7 @@ describe("ScheduleService", () => { }); test("update clears the schedule name when given an empty string", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1170,7 +2762,7 @@ describe("ScheduleService", () => { }); test("update rejects new-agent fields on agent-target schedules", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1195,7 +2787,7 @@ describe("ScheduleService", () => { }); test("update changes individual new-agent fields independently", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1240,7 +2832,7 @@ describe("ScheduleService", () => { }); test("update returns a schedule that round-trips through the store", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1266,12 +2858,17 @@ describe("ScheduleService", () => { expect(reloaded.cadence).toEqual({ type: "cron", expression: "0 9 * * *" }); expect(reloaded.target).toEqual({ type: "new-agent", - config: { provider: "codex", cwd: tempDir, modeId: "full-access" }, + config: { + provider: "codex", + cwd: tempDir, + workspaceId: "workspace-created-for-schedule", + modeId: "full-access", + }, }); }); test("runOnce rejects completed schedules", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1297,7 +2894,7 @@ describe("ScheduleService", () => { }); test("completeForAgent completes only schedules targeting that agent", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1364,7 +2961,7 @@ describe("ScheduleService", () => { buildAgentRecord({ id: liveAgentId, cwd: tempDir, iso: now.toISOString() }), ); - const service1 = new ScheduleService({ + const service1 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1408,7 +3005,7 @@ describe("ScheduleService", () => { await service1.pause(pausedLive.id); now = new Date("2026-01-01T00:10:00.000Z"); - const service2 = new ScheduleService({ + const service2 = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1431,7 +3028,7 @@ describe("ScheduleService", () => { }); test("completes the schedule when a scheduled run reports the target is gone", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1463,7 +3060,7 @@ describe("ScheduleService", () => { test("does not resurrect nextRunAt when the schedule completes during an in-flight run", async () => { const agentId = "ffffffff-ffff-4fff-8fff-ffffffffffff"; let service!: ScheduleService; - service = new ScheduleService({ + service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1494,7 +3091,7 @@ describe("ScheduleService", () => { }); test("keeps the schedule active when a run fails for a transient reason", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1523,7 +3120,7 @@ describe("ScheduleService", () => { }); test("completes the schedule when a scheduled run targets an archived agent", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1559,7 +3156,7 @@ describe("ScheduleService", () => { }); test("completes the schedule when a scheduled run targets a missing agent", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1584,7 +3181,7 @@ describe("ScheduleService", () => { }); test("completes the schedule when a new-agent run's cwd no longer exists", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1593,6 +3190,8 @@ describe("ScheduleService", () => { now: () => now, }); + const deletedWorktree = join(tempDir, "deleted-worktree"); + await mkdir(deletedWorktree, { recursive: true }); const created = await service.create({ prompt: "spawn in a deleted dir", cadence: { type: "every", everyMs: 60_000 }, @@ -1600,11 +3199,12 @@ describe("ScheduleService", () => { type: "new-agent", config: { provider: "claude", - cwd: join(tempDir, "deleted-worktree"), + cwd: deletedWorktree, approvalPolicy: "never", }, }, }); + await rm(deletedWorktree, { recursive: true, force: true }); now = new Date("2026-01-01T00:01:00.000Z"); await service.tick(); @@ -1619,7 +3219,7 @@ describe("ScheduleService", () => { test("keeps the schedule active when a real run fails for a non-gone reason", async () => { // No providers registered: the agent exists and is live, but loading it fails // with a plain error (not ScheduleTargetGoneError), so the schedule must retry. - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1650,7 +3250,7 @@ describe("ScheduleService", () => { }); test("runOnce completes the schedule when the target is gone", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1673,7 +3273,7 @@ describe("ScheduleService", () => { }); test("createOrReplace updates the matching schedule in place instead of duplicating", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1714,8 +3314,43 @@ describe("ScheduleService", () => { expect(await service.list()).toHaveLength(1); }); + test("createOrReplace preserves an existing cron timezone when replacement omits timezone", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const agentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const first = await service.createOrReplace({ + name: "timezone-aware cron", + prompt: "watch the build", + cadence: { type: "cron", expression: "0 9 * * *", timezone: "America/New_York" }, + target: { type: "agent", agentId }, + }); + + const second = await service.createOrReplace({ + name: "timezone-aware cron", + prompt: "watch the build v2", + cadence: { type: "cron", expression: "30 9 * * *" }, + target: { type: "agent", agentId }, + }); + + expect(second.id).toBe(first.id); + expect(second.cadence).toEqual({ + type: "cron", + expression: "30 9 * * *", + timezone: "America/New_York", + }); + expect(await service.list()).toHaveLength(1); + }); + test("createOrReplace creates a sibling when name, target, or completion differ", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1770,7 +3405,7 @@ describe("ScheduleService", () => { }); test("createOrReplace never dedups anonymous schedules", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1796,7 +3431,7 @@ describe("ScheduleService", () => { }); test("createOrReplace matches new-agent targets by config", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1821,18 +3456,72 @@ describe("ScheduleService", () => { expect(second.id).toBe(first.id); expect(await service.list()).toHaveLength(1); + const subCwd = join(tempDir, "sub"); + await mkdir(subCwd, { recursive: true }); const third = await service.createOrReplace({ name: "nightly", prompt: "audit elsewhere", cadence: { type: "every", everyMs: 60_000 }, - target: { type: "new-agent", config: { provider: "claude", cwd: join(tempDir, "sub") } }, + target: { type: "new-agent", config: { provider: "claude", cwd: subCwd } }, }); expect(third.id).not.toBe(first.id); expect(await service.list()).toHaveLength(2); }); + test("concurrent createOrReplace first creates share one schedule and workspace", async () => { + let releaseFirstStamp: (() => void) | null = null; + const firstStampStarted = new Promise((resolve) => { + releaseFirstStamp = resolve; + }); + let unblockStamp: (() => void) | null = null; + const stampBlocked = new Promise((resolve) => { + unblockStamp = resolve; + }); + let ensureCalls = 0; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + ensureWorkspaceForCreate: async () => { + ensureCalls += 1; + releaseFirstStamp?.(); + await stampBlocked; + return `workspace-${ensureCalls}`; + }, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + + const firstPromise = service.createOrReplace({ + name: "nightly race", + prompt: "audit", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + await firstStampStarted; + const secondPromise = service.createOrReplace({ + name: "nightly race", + prompt: "audit", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + unblockStamp?.(); + + const [first, second] = await Promise.all([firstPromise, secondPromise]); + + expect(second.id).toBe(first.id); + expect(ensureCalls).toBe(1); + expect(await service.list()).toHaveLength(1); + expect(second.target).toMatchObject({ + type: "new-agent", + config: { workspaceId: "workspace-1" }, + }); + }); + test("createOrReplace dedups new-agent targets regardless of config key order", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), @@ -1880,7 +3569,7 @@ describe("ScheduleService", () => { }); test("completeForAgent skips schedules that are already completed", async () => { - const service = new ScheduleService({ + const service = createScheduleService({ paseoHome: tempDir, logger: createTestLogger(), agentManager: new AgentManager({ logger: createTestLogger() }), diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 6dd196e1b..bec9b0c5b 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -1,21 +1,22 @@ import { randomUUID } from "node:crypto"; import { stat } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { Logger } from "pino"; -import { AgentManager } from "../agent/agent-manager.js"; -import type { AgentStorage } from "../agent/agent-storage.js"; +import type { AgentManager } from "../agent/agent-manager.js"; import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; +import type { AgentStorage } from "../agent/agent-storage.js"; import { curateAgentActivity } from "../agent/activity-curator.js"; import { ensureAgentLoaded } from "../agent/agent-loading.js"; import { formatSystemNotificationPrompt } from "../agent/agent-prompt.js"; import { resolveCreateAgentTitles } from "../agent/create-agent-title.js"; +import { + type BoundCreateAgentCommand, + type EnsureWorkspaceForCreate, + formatProviderModel, +} from "../agent/create-agent/create.js"; +import type { WorkspaceRegistry } from "../workspace-registry.js"; import { ScheduleStore } from "./store.js"; import { computeNextRunAt, validateScheduleCadence } from "./cron.js"; -import type { - ProviderSnapshotManager, - ResolvedProviderCreateConfig, - ResolveProviderCreateConfigOptions, -} from "../agent/provider-snapshot-manager.js"; import type { CreateScheduleInput, ScheduleExecutionResult, @@ -27,6 +28,7 @@ import type { } from "@getpaseo/protocol/schedule/types"; const SCHEDULE_TICK_INTERVAL_MS = 1000; +const WORKSPACE_STAMP_CACHE_TTL_MS = 5_000; // A run failed because its target no longer exists: the agent was deleted or // archived, or a new-agent cwd was removed. These are permanent, so the schedule @@ -78,6 +80,9 @@ function applyNewAgentConfig( if (!trimmed) { throw new Error("cwd cannot be empty"); } + if (resolve(trimmed) !== resolve(config.cwd)) { + delete config.workspaceId; + } config.cwd = trimmed; } if (patch.model !== undefined) { @@ -152,6 +157,34 @@ function scheduleTargetsEqual(a: ScheduleTarget, b: ScheduleTarget): boolean { return false; } +function carryExistingWorkspaceStamp( + existing: ScheduleTarget, + incoming: ScheduleTarget, +): ScheduleTarget { + if ( + existing.type !== "new-agent" || + incoming.type !== "new-agent" || + incoming.config.workspaceId || + !existing.config.workspaceId + ) { + return incoming; + } + return { + ...incoming, + config: { + ...incoming.config, + workspaceId: existing.config.workspaceId, + }, + }; +} + +function requireSchedule(schedule: StoredSchedule | null, id: string): StoredSchedule { + if (!schedule) { + throw new Error(`Schedule not found: ${id}`); + } + return schedule; +} + function completeSchedule(schedule: StoredSchedule, now: Date): StoredSchedule { return { ...schedule, @@ -162,6 +195,24 @@ function completeSchedule(schedule: StoredSchedule, now: Date): StoredSchedule { }; } +function mergeScheduleCadenceTimezone( + current: StoredSchedule["cadence"], + next: StoredSchedule["cadence"], +): StoredSchedule["cadence"] { + if ( + current.type === "cron" && + next.type === "cron" && + next.timezone === undefined && + current.timezone !== undefined + ) { + return { + ...next, + timezone: current.timezone, + }; + } + return next; +} + function buildRunOutput(params: { output: string | null; timelineText: string; @@ -179,14 +230,29 @@ function buildRunOutput(params: { return null; } -type CreateConfigResolver = Pick; +type ScheduleAgentManager = Pick< + AgentManager, + | "archiveAgent" + | "createAgent" + | "getAgent" + | "getRegisteredProviderIds" + | "hasInFlightRun" + | "hydrateTimelineFromProvider" + | "resumeAgentFromPersistence" + | "runAgent" + | "waitForAgentEvent" +>; + +type ScheduleWorkspaceRegistry = Pick; export interface ScheduleServiceOptions { paseoHome: string; logger: Logger; - agentManager: AgentManager; + agentManager: ScheduleAgentManager; agentStorage: AgentStorage; - providerSnapshotManager: CreateConfigResolver; + createAgent: BoundCreateAgentCommand; + ensureWorkspaceForCreate: EnsureWorkspaceForCreate; + workspaceRegistry: ScheduleWorkspaceRegistry; now?: () => Date; runner?: (schedule: StoredSchedule, runId: string) => Promise; } @@ -194,15 +260,21 @@ export interface ScheduleServiceOptions { export class ScheduleService { private readonly store: ScheduleStore; private readonly logger: Logger; - private readonly agentManager: AgentManager; + private readonly agentManager: ScheduleAgentManager; private readonly agentStorage: AgentStorage; - private readonly createConfigResolver: CreateConfigResolver; + private readonly createAgent: BoundCreateAgentCommand; + private readonly ensureWorkspaceForCreate: EnsureWorkspaceForCreate; + private readonly workspaceRegistry: ScheduleWorkspaceRegistry; private readonly now: () => Date; private readonly runner: ( schedule: StoredSchedule, runId: string, ) => Promise; private readonly runningScheduleIds = new Set(); + private readonly workspaceStampPromises = new Map< + string, + Promise> + >(); private tickTimer: ReturnType | null = null; constructor(options: ScheduleServiceOptions) { @@ -210,7 +282,9 @@ export class ScheduleService { this.logger = options.logger.child({ module: "schedule-service" }); this.agentManager = options.agentManager; this.agentStorage = options.agentStorage; - this.createConfigResolver = options.providerSnapshotManager; + this.createAgent = options.createAgent; + this.ensureWorkspaceForCreate = options.ensureWorkspaceForCreate; + this.workspaceRegistry = options.workspaceRegistry; this.now = options.now ?? (() => new Date()); this.runner = options.runner ?? ((schedule, runId) => this.executeSchedule(schedule, runId)); } @@ -238,16 +312,38 @@ export class ScheduleService { } async create(input: CreateScheduleInput): Promise { - const now = this.now(); const prompt = normalizePrompt(input.prompt); validateScheduleCadence(input.cadence); - const runOnCreate = input.runOnCreate ?? input.cadence.type === "every"; - const nextRunAt = runOnCreate ? now : computeNextRunAt(input.cadence, now); - const schedule = await this.store.create({ + const target = await this.stampNewAgentWorkspace( + stripNewAgentWorkspaceStamp(input.target), + input.prompt, + ); + return this.createScheduleRecord(input, { name: trimOptionalName(input.name), prompt, + target, + }); + } + + private async createScheduleRecord( + input: CreateScheduleInput, + fields: { name: string | null; prompt: string; target: ScheduleTarget }, + ): Promise { + return this.store.create(this.buildScheduleRecord(input, fields)); + } + + private buildScheduleRecord( + input: CreateScheduleInput, + fields: { name: string | null; prompt: string; target: ScheduleTarget }, + ): Omit { + const now = this.now(); + const runOnCreate = input.runOnCreate ?? input.cadence.type === "every"; + const nextRunAt = runOnCreate ? now : computeNextRunAt(input.cadence, now); + return { + name: fields.name, + prompt: fields.prompt, cadence: input.cadence, - target: input.target, + target: fields.target, status: "active", createdAt: now.toISOString(), updatedAt: now.toISOString(), @@ -257,8 +353,7 @@ export class ScheduleService { expiresAt: input.expiresAt ?? null, maxRuns: normalizeMaxRuns(input.maxRuns), runs: [], - }); - return schedule; + }; } // Idempotent create for the MCP write path: repeating a create with the same @@ -266,25 +361,38 @@ export class ScheduleService { // existing non-completed schedule in place instead of minting a duplicate. async createOrReplace(input: CreateScheduleInput): Promise { const name = trimOptionalName(input.name); - if (name !== null) { - const existing = (await this.store.list()).find( - (schedule) => - schedule.status !== "completed" && - trimOptionalName(schedule.name) === name && - scheduleTargetsEqual(schedule.target, input.target), + const prompt = normalizePrompt(input.prompt); + validateScheduleCadence(input.cadence); + if (name === null) { + const target = await this.stampNewAgentWorkspace( + stripNewAgentWorkspaceStamp(input.target), + input.prompt, ); - if (existing) { + return this.createScheduleRecord(input, { name, prompt, target }); + } + + const inputTarget = stripNewAgentWorkspaceStamp(input.target); + return this.store.upsertByNameAndTarget(name, inputTarget, { + create: async () => { + const target = await this.stampNewAgentWorkspace(inputTarget, input.prompt); + return this.buildScheduleRecord(input, { name, prompt, target }); + }, + update: async (current) => { const now = this.now(); - const prompt = normalizePrompt(input.prompt); - validateScheduleCadence(input.cadence); - const runOnCreate = input.runOnCreate ?? input.cadence.type === "every"; - const nextRunAt = runOnCreate ? now : computeNextRunAt(input.cadence, now); - const replaced: StoredSchedule = { - ...existing, + const cadence = mergeScheduleCadenceTimezone(current.cadence, input.cadence); + const runOnCreate = input.runOnCreate ?? cadence.type === "every"; + const nextRunAt = runOnCreate ? now : computeNextRunAt(cadence, now); + const target = await this.stampNewAgentWorkspace( + carryExistingWorkspaceStamp(current.target, inputTarget), + input.prompt, + current.id, + ); + return { + ...current, name, prompt, - cadence: input.cadence, - target: input.target, + cadence, + target, status: "active", pausedAt: null, nextRunAt: nextRunAt.toISOString(), @@ -292,11 +400,8 @@ export class ScheduleService { maxRuns: normalizeMaxRuns(input.maxRuns), updatedAt: now.toISOString(), }; - await this.store.put(replaced); - return replaced; - } - } - return this.create(input); + }, + }); } async list(): Promise { @@ -317,83 +422,94 @@ export class ScheduleService { } async pause(id: string): Promise { - const schedule = await this.inspect(id); - if (schedule.status === "completed") { - throw new Error(`Schedule ${id} is already completed`); - } - if (schedule.status === "paused") { - return schedule; - } - const now = this.now(); - const paused = { - ...schedule, - status: "paused" as const, - nextRunAt: null, - pausedAt: now.toISOString(), - updatedAt: now.toISOString(), - }; - await this.store.put(paused); - return paused; + const paused = await this.store.update(id, (schedule) => { + if (schedule.status === "completed") { + throw new Error(`Schedule ${id} is already completed`); + } + if (schedule.status === "paused") { + return schedule; + } + const now = this.now(); + return { + ...schedule, + status: "paused" as const, + nextRunAt: null, + pausedAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + }); + return requireSchedule(paused, id); } async resume(id: string): Promise { - const schedule = await this.inspect(id); - if (schedule.status === "completed") { - throw new Error(`Schedule ${id} is already completed`); - } - if (schedule.status === "active") { - return schedule; - } - const now = this.now(); - const resumed = { - ...schedule, - status: "active" as const, - pausedAt: null, - nextRunAt: computeNextRunAt(schedule.cadence, now).toISOString(), - updatedAt: now.toISOString(), - }; - await this.store.put(resumed); - return resumed; + const resumed = await this.store.update(id, (schedule) => { + if (schedule.status === "completed") { + throw new Error(`Schedule ${id} is already completed`); + } + if (schedule.status === "active") { + return schedule; + } + const now = this.now(); + return { + ...schedule, + status: "active" as const, + pausedAt: null, + nextRunAt: computeNextRunAt(schedule.cadence, now).toISOString(), + updatedAt: now.toISOString(), + }; + }); + return requireSchedule(resumed, id); } async update(input: UpdateScheduleInput): Promise { - const schedule = await this.inspect(input.id); - const now = this.now(); - let updated: StoredSchedule = schedule; + const next = await this.store.update(input.id, async (schedule) => { + const now = this.now(); + let updated: StoredSchedule = schedule; - if (input.prompt !== undefined) { - updated = { ...updated, prompt: normalizePrompt(input.prompt) }; - } - - if (input.name !== undefined) { - updated = { ...updated, name: trimOptionalName(input.name) }; - } - - if (input.cadence !== undefined) { - validateScheduleCadence(input.cadence); - const nextRunAt = - updated.status === "active" ? computeNextRunAt(input.cadence, now).toISOString() : null; - updated = { ...updated, cadence: input.cadence, nextRunAt }; - } - - if (input.newAgentConfig !== undefined) { - if (updated.target.type !== "new-agent") { - throw new Error("new-agent config updates are only valid for new-agent target schedules"); + if (input.prompt !== undefined) { + updated = { ...updated, prompt: normalizePrompt(input.prompt) }; } - updated = { ...updated, target: applyNewAgentConfig(updated.target, input.newAgentConfig) }; - } - if (input.maxRuns !== undefined) { - updated = { ...updated, maxRuns: normalizeMaxRuns(input.maxRuns) }; - } + if (input.name !== undefined) { + updated = { ...updated, name: trimOptionalName(input.name) }; + } - if (input.expiresAt !== undefined) { - updated = { ...updated, expiresAt: input.expiresAt }; - } + if (input.cadence !== undefined) { + const cadence = mergeScheduleCadenceTimezone(updated.cadence, input.cadence); + validateScheduleCadence(cadence); + const nextRunAt = + updated.status === "active" ? computeNextRunAt(cadence, now).toISOString() : null; + updated = { ...updated, cadence, nextRunAt }; + } - updated = { ...updated, updatedAt: now.toISOString() }; - await this.store.put(updated); - return updated; + if (input.newAgentConfig !== undefined) { + if (updated.target.type !== "new-agent") { + throw new Error("new-agent config updates are only valid for new-agent target schedules"); + } + const patchedTarget = applyNewAgentConfig(updated.target, input.newAgentConfig); + updated = { + ...updated, + target: await this.stampNewAgentWorkspace(patchedTarget, updated.prompt, updated.id), + }; + } + + if (input.maxRuns !== undefined) { + updated = { ...updated, maxRuns: normalizeMaxRuns(input.maxRuns) }; + } + + if (input.expiresAt !== undefined) { + updated = { ...updated, expiresAt: input.expiresAt }; + } + + if (input.newAgentConfig === undefined) { + updated = { + ...updated, + target: await this.stampNewAgentWorkspace(updated.target, updated.prompt, updated.id), + }; + } + return { ...updated, updatedAt: now.toISOString() }; + }); + return requireSchedule(next, input.id); } async delete(id: string): Promise { @@ -410,15 +526,19 @@ export class ScheduleService { schedule.status !== "completed", ); const results = await Promise.allSettled( - matches.map((schedule) => this.store.put(completeSchedule(schedule, now))), + matches.map((schedule) => this.completeScheduleForAgent(schedule.id, agentId, now)), ); let completed = 0; for (const [index, result] of results.entries()) { - if (result.status === "fulfilled") { + if (result.status === "fulfilled" && result.value) { completed += 1; - } else { + } else if (result.status === "rejected") { this.logger.warn( - { err: result.reason, scheduleId: matches[index].id, agentId }, + { + err: result.reason, + scheduleId: matches[index].id, + agentId, + }, "Failed to complete schedule for archived agent; continuing", ); } @@ -426,6 +546,27 @@ export class ScheduleService { return completed; } + private async completeScheduleForAgent( + scheduleId: string, + agentId: string, + now: Date, + ): Promise { + let completed = false; + const updated = await this.store.update(scheduleId, (schedule) => { + if ( + schedule.target.type !== "agent" || + schedule.target.agentId !== agentId || + schedule.status === "completed" + ) { + return schedule; + } + completed = true; + return completeSchedule(schedule, now); + }); + requireSchedule(updated, scheduleId); + return completed; + } + async runOnce(id: string): Promise { const schedule = await this.inspect(id); if (schedule.status === "completed") { @@ -449,7 +590,7 @@ export class ScheduleService { continue; } if (shouldCompleteSchedule(schedule, now)) { - await this.store.put(completeSchedule(schedule, now)); + await this.completeScheduleIfDue(schedule.id, now); continue; } if (new Date(schedule.nextRunAt).getTime() > now.getTime()) { @@ -459,68 +600,86 @@ export class ScheduleService { } } + private async completeScheduleIfDue(scheduleId: string, now: Date): Promise { + const updated = await this.store.update(scheduleId, (schedule) => { + if ( + schedule.status !== "active" || + !schedule.nextRunAt || + !shouldCompleteSchedule(schedule, now) + ) { + return schedule; + } + return completeSchedule(schedule, now); + }); + requireSchedule(updated, scheduleId); + } + private async recoverInterruptedRuns(): Promise { const schedules = await this.store.list(); const now = this.now(); await Promise.all( - schedules.map(async (schedule) => { - let updated = { ...schedule }; - let dirty = false; - - // Mark any in-flight runs as failed - const runningIndex = updated.runs.findIndex((run) => run.status === "running"); - if (runningIndex !== -1) { - const runs = [...updated.runs]; - runs[runningIndex] = { - ...runs[runningIndex], - status: "failed", - endedAt: now.toISOString(), - error: "Daemon restarted before the scheduled run completed", - }; - updated = { ...updated, runs }; - dirty = true; - } - - // Advance stale nextRunAt for active schedules - if ( - updated.status === "active" && - updated.nextRunAt && - new Date(updated.nextRunAt).getTime() <= now.getTime() - ) { - let nextRunAt = computeNextRunAt(updated.cadence, new Date(updated.nextRunAt)); - while (nextRunAt.getTime() <= now.getTime()) { - nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); - } - updated = { ...updated, nextRunAt: nextRunAt.toISOString() }; - dirty = true; - } - - if (dirty) { - updated = { ...updated, updatedAt: now.toISOString() }; - await this.store.put(updated); - } - }), + schedules.map((schedule) => this.recoverInterruptedSchedule(schedule.id, now)), ); } + private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise { + await this.store.update(scheduleId, (current) => { + let updated = { ...current }; + let dirty = false; + + const runningIndex = updated.runs.findIndex((run) => run.status === "running"); + if (runningIndex !== -1) { + const runs = [...updated.runs]; + runs[runningIndex] = { + ...runs[runningIndex], + status: "failed", + endedAt: now.toISOString(), + error: "Daemon restarted before the scheduled run completed", + }; + updated = { ...updated, runs }; + dirty = true; + } + + if ( + updated.status === "active" && + updated.nextRunAt && + new Date(updated.nextRunAt).getTime() <= now.getTime() + ) { + let nextRunAt = computeNextRunAt(updated.cadence, new Date(updated.nextRunAt)); + while (nextRunAt.getTime() <= now.getTime()) { + nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); + } + updated = { ...updated, nextRunAt: nextRunAt.toISOString() }; + dirty = true; + } + + if (dirty) { + return { ...updated, updatedAt: now.toISOString() }; + } + return current; + }); + } + // Orphaned agent-target schedules (agent deleted while the daemon was down, or // archived before completeForAgent existed) can never fire successfully. Complete // them on startup so they stop ticking and surface as ended in the UI. private async sweepOrphanedSchedules(): Promise { const now = this.now(); const schedules = await this.store.list(); - await Promise.all( - schedules.map(async (schedule) => { - if (schedule.target.type !== "agent" || schedule.status === "completed") { - return; - } - const record = await this.agentStorage.get(schedule.target.agentId); - if (record && !record.archivedAt) { - return; - } - await this.store.put(completeSchedule(schedule, now)); - }), - ); + await Promise.all(schedules.map((schedule) => this.sweepOrphanedSchedule(schedule.id, now))); + } + + private async sweepOrphanedSchedule(scheduleId: string, now: Date): Promise { + await this.store.update(scheduleId, async (schedule) => { + if (schedule.target.type !== "agent" || schedule.status === "completed") { + return schedule; + } + const record = await this.agentStorage.get(schedule.target.agentId); + if (record && !record.archivedAt) { + return schedule; + } + return completeSchedule(schedule, now); + }); } private async runSchedule( @@ -541,12 +700,7 @@ export class ScheduleService { output: null, error: null, }; - const scheduleWithRun = { - ...schedule, - updatedAt: now.toISOString(), - runs: [...schedule.runs, runningRun], - }; - await this.store.put(scheduleWithRun); + const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); try { const result = await this.runner(scheduleWithRun, runId); @@ -576,6 +730,18 @@ export class ScheduleService { } } + private async appendRunningRun( + scheduleId: string, + runningRun: ScheduleRun, + ): Promise { + const updated = await this.store.update(scheduleId, (schedule) => ({ + ...schedule, + updatedAt: runningRun.startedAt, + runs: [...schedule.runs, runningRun], + })); + return requireSchedule(updated, scheduleId); + } + private async finishRun(params: { scheduleId: string; runId: string; @@ -586,56 +752,58 @@ export class ScheduleService { targetGone: boolean; manual: boolean; }): Promise { - const schedule = await this.inspect(params.scheduleId); - const now = this.now(); - const completedRuns = schedule.runs.map((run) => - run.id === params.runId - ? { - ...run, - status: params.status, - endedAt: now.toISOString(), - agentId: params.agentId, - output: params.output, - error: params.error, - } - : run, - ); - let updated: StoredSchedule = { - ...schedule, - runs: completedRuns, - lastRunAt: now.toISOString(), - updatedAt: now.toISOString(), - }; - - if (params.targetGone) { - // The target is permanently gone; retrying only burns the schedule down to - // its expiry, so complete it now regardless of manual/scheduled origin. - updated = completeSchedule(updated, now); - } else if (updated.status === "completed") { - // Completed concurrently (e.g. the target agent was archived mid-run); - // record the run outcome but leave the schedule terminal — don't advance. - } else if (params.manual) { - // Manual one-shot runs do not advance the cadence or recompute completion. - } else if (shouldCompleteSchedule(updated, now)) { - updated = completeSchedule(updated, now); - } else if (updated.status === "paused") { - updated = { - ...updated, - nextRunAt: null, + const updatedSchedule = await this.store.update(params.scheduleId, (schedule) => { + const now = this.now(); + const completedRuns = schedule.runs.map((run) => + run.id === params.runId + ? { + ...run, + status: params.status, + endedAt: now.toISOString(), + agentId: params.agentId, + output: params.output, + error: params.error, + } + : run, + ); + let updated: StoredSchedule = { + ...schedule, + runs: completedRuns, + lastRunAt: now.toISOString(), + updatedAt: now.toISOString(), }; - } else { - const after = new Date(schedule.nextRunAt ?? now.toISOString()); - let nextRunAt = computeNextRunAt(updated.cadence, after); - while (nextRunAt.getTime() <= now.getTime()) { - nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); + + if (params.targetGone) { + // The target is permanently gone; retrying only burns the schedule down to + // its expiry, so complete it now regardless of manual/scheduled origin. + updated = completeSchedule(updated, now); + } else if (updated.status === "completed") { + // Completed concurrently (e.g. the target agent was archived mid-run); + // record the run outcome but leave the schedule terminal — don't advance. + } else if (params.manual) { + // Manual one-shot runs do not advance the cadence or recompute completion. + } else if (shouldCompleteSchedule(updated, now)) { + updated = completeSchedule(updated, now); + } else if (updated.status === "paused") { + updated = { + ...updated, + nextRunAt: null, + }; + } else { + const after = new Date(schedule.nextRunAt ?? now.toISOString()); + let nextRunAt = computeNextRunAt(updated.cadence, after); + while (nextRunAt.getTime() <= now.getTime()) { + nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); + } + updated = { + ...updated, + nextRunAt: nextRunAt.toISOString(), + }; } - updated = { - ...updated, - nextRunAt: nextRunAt.toISOString(), - }; - } - await this.store.put(updated); + return updated; + }); + requireSchedule(updatedSchedule, params.scheduleId); } private async executeSchedule( @@ -672,57 +840,60 @@ export class ScheduleService { }; } - const targetConfig = schedule.target.config; - try { - await stat(targetConfig.cwd); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new ScheduleTargetGoneError(`Working directory ${targetConfig.cwd} no longer exists`); - } - throw error; + const executionSchedule = await this.ensureScheduleWorkspaceStamped(schedule); + const stampedConfig = + executionSchedule.target.type === "new-agent" ? executionSchedule.target.config : null; + if (!stampedConfig) { + throw new Error(`Schedule ${schedule.id} target changed during execution`); } - const resolvedUnattendedConfig = targetConfig.modeId - ? { modeId: targetConfig.modeId, featureValues: targetConfig.featureValues } - : await this.resolveProviderCreateConfig({ - provider: targetConfig.provider, - cwd: targetConfig.cwd, - requestedMode: undefined, - featureValues: targetConfig.featureValues, - parent: null, - unattended: true, - }); - const config: AgentSessionConfig = { - provider: targetConfig.provider, - cwd: targetConfig.cwd, - modeId: resolvedUnattendedConfig.modeId, - model: targetConfig.model, - thinkingOptionId: targetConfig.thinkingOptionId, - title: targetConfig.title, - approvalPolicy: targetConfig.approvalPolicy, - sandboxMode: targetConfig.sandboxMode, - networkAccess: targetConfig.networkAccess, - webSearch: targetConfig.webSearch, - featureValues: resolvedUnattendedConfig.featureValues, - extra: targetConfig.extra, - systemPrompt: targetConfig.systemPrompt, - mcpServers: targetConfig.mcpServers as AgentSessionConfig["mcpServers"], - }; - const { provisionalTitle } = resolveCreateAgentTitles({ - configTitle: config.title, - initialPrompt: schedule.prompt, + await this.assertNewAgentCwdDirectory(stampedConfig.cwd); + const created = await this.createAgent({ + kind: "mcp", + provider: formatScheduleProviderModel(stampedConfig), + config: buildScheduleAgentConfig(stampedConfig), + cwd: stampedConfig.cwd, + workspaceId: stampedConfig.workspaceId, + title: resolveScheduleAgentTitle(stampedConfig, executionSchedule.prompt), + labels: { + "paseo.schedule-id": executionSchedule.id, + "paseo.schedule-run": runId, + }, + mode: stampedConfig.modeId, + thinking: stampedConfig.thinkingOptionId, + features: stampedConfig.featureValues, + unattended: true, + promptFailure: "return-error", + background: true, + notifyOnFinish: false, }); - const labels = { - "paseo.schedule-id": schedule.id, - "paseo.schedule-run": runId, - }; - const agent = await this.agentManager.createAgent(config, undefined, { - labels, - initialPrompt: schedule.prompt, - initialTitle: provisionalTitle, - }); - let result; + const agent = created.snapshot; try { - result = await this.agentManager.runAgent(agent.id, schedule.prompt); + if (created.initialPromptError) { + throw created.initialPromptError; + } + const result = await this.agentManager.runAgent(agent.id, executionSchedule.prompt); + const waitResult = await this.agentManager.waitForAgentEvent(agent.id, { + waitForActive: true, + }); + if (result.canceled) { + throw new Error(`Scheduled agent ${agent.id} was canceled`); + } + if (waitResult.permission) { + throw new Error(`Scheduled agent ${agent.id} is waiting for permission`); + } + if (waitResult.status === "error") { + throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`); + } + const timelineText = curateAgentActivity(result.timeline); + await this.agentManager.archiveAgent(agent.id); + return { + agentId: agent.id, + output: buildRunOutput({ + output: waitResult.lastMessage ?? null, + timelineText, + finalText: result.finalText, + }), + }; } catch (error) { try { await this.agentManager.archiveAgent(agent.id); @@ -734,22 +905,198 @@ export class ScheduleService { } throw error; } - - await this.agentManager.archiveAgent(agent.id); - const timelineText = curateAgentActivity(result.timeline); - return { - agentId: agent.id, - output: buildRunOutput({ - output: null, - timelineText, - finalText: result.finalText, - }), - }; } - private async resolveProviderCreateConfig( - input: ResolveProviderCreateConfigOptions, - ): Promise { - return this.createConfigResolver.resolveCreateConfig(input); + private async assertNewAgentCwdDirectory(cwd: string): Promise { + try { + const stats = await stat(cwd); + if (!stats.isDirectory()) { + throw new ScheduleTargetGoneError(`Working directory ${cwd} is not a directory`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new ScheduleTargetGoneError(`Working directory ${cwd} no longer exists`); + } + throw error; + } + } + + private async createWorkspaceStampedTarget( + target: Extract, + prompt: string, + scheduleId?: string, + ): Promise { + await this.assertNewAgentCwdDirectory(target.config.cwd); + const key = scheduleId ? `${scheduleId}:${newAgentWorkspaceStampKey(target)}` : null; + if (key) { + const existing = this.workspaceStampPromises.get(key); + if (existing) { + const stampedTarget = await existing; + const workspaceId = stampedTarget.config.workspaceId; + if ( + workspaceId && + (await this.hasActiveWorkspaceStamp(workspaceId, stampedTarget.config.cwd)) + ) { + return stampedTarget; + } + if (this.workspaceStampPromises.get(key) === existing) { + this.workspaceStampPromises.delete(key); + } + } + } + + const promise = (async () => { + const workspaceId = await this.ensureWorkspaceForCreate(resolve(target.config.cwd), { + prompt, + }); + return { + ...target, + config: { + ...target.config, + workspaceId, + }, + }; + })(); + if (!key) { + return promise; + } + this.workspaceStampPromises.set(key, promise); + try { + const stampedTarget = await promise; + const timer = setTimeout(() => { + if (this.workspaceStampPromises.get(key) === promise) { + this.workspaceStampPromises.delete(key); + } + }, WORKSPACE_STAMP_CACHE_TTL_MS); + timer.unref?.(); + return stampedTarget; + } catch (error) { + if (this.workspaceStampPromises.get(key) === promise) { + this.workspaceStampPromises.delete(key); + } + throw error; + } + } + + private async hasActiveWorkspaceStamp(workspaceId: string, cwd: string): Promise { + const workspace = await this.workspaceRegistry.get(workspaceId); + return Boolean(workspace && !workspace.archivedAt && workspace.cwd === resolve(cwd)); + } + + private async stampNewAgentWorkspace( + target: ScheduleTarget, + prompt: string, + scheduleId?: string, + ): Promise { + if (target.type !== "new-agent" || target.config.workspaceId) { + return target; + } + return this.createWorkspaceStampedTarget(target, prompt, scheduleId); + } + + private async ensureScheduleWorkspaceStamped(schedule: StoredSchedule): Promise { + if (schedule.target.type !== "new-agent") { + return schedule; + } + const stampedWorkspaceId = schedule.target.config.workspaceId; + if ( + stampedWorkspaceId && + (await this.hasActiveWorkspaceStamp(stampedWorkspaceId, schedule.target.config.cwd)) + ) { + return schedule; + } + + await this.assertNewAgentCwdDirectory(schedule.target.config.cwd); + const target = await this.createWorkspaceStampedTarget( + schedule.target, + schedule.prompt, + schedule.id, + ); + const stamped = { + ...schedule, + target, + updatedAt: this.now().toISOString(), + }; + + await this.store.update(schedule.id, (latest) => { + if (latest.target.type !== "new-agent") { + return latest; + } + if (!scheduleTargetsEqual(latest.target, schedule.target)) { + return latest; + } + return { + ...latest, + target, + updatedAt: stamped.updatedAt, + }; + }); + + return stamped; } } + +function buildScheduleAgentConfig( + config: Extract["config"], +): AgentSessionConfig { + return { + provider: config.provider, + cwd: config.cwd, + modeId: config.modeId, + model: config.model, + thinkingOptionId: config.thinkingOptionId, + title: config.title, + approvalPolicy: config.approvalPolicy, + sandboxMode: config.sandboxMode, + networkAccess: config.networkAccess, + webSearch: config.webSearch, + featureValues: config.featureValues, + extra: config.extra, + systemPrompt: config.systemPrompt, + mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"], + }; +} + +function resolveScheduleAgentTitle( + config: Extract["config"], + prompt: string, +): string { + return ( + resolveCreateAgentTitles({ + configTitle: config.title, + initialPrompt: prompt, + }).provisionalTitle ?? "" + ); +} + +function stripNewAgentWorkspaceStamp(target: ScheduleTarget): ScheduleTarget { + if (target.type !== "new-agent") { + return target; + } + const config = { ...target.config }; + delete config.workspaceId; + return { + ...target, + config, + }; +} + +function newAgentWorkspaceStampKey(target: Extract): string { + const config = target.config; + return JSON.stringify({ + type: target.type, + provider: config.provider, + model: config.model ?? null, + cwd: resolve(config.cwd), + modeId: config.modeId ?? null, + thinkingOptionId: config.thinkingOptionId ?? null, + approvalPolicy: config.approvalPolicy ?? null, + title: config.title ?? null, + }); +} + +function formatScheduleProviderModel( + config: Extract["config"], +): string { + return formatProviderModel(config.provider, config.model); +} diff --git a/packages/server/src/server/schedule/store.test.ts b/packages/server/src/server/schedule/store.test.ts index e170aa441..7e19bbfd9 100644 --- a/packages/server/src/server/schedule/store.test.ts +++ b/packages/server/src/server/schedule/store.test.ts @@ -47,7 +47,7 @@ describe("ScheduleStore", () => { expect(listed).toEqual([created]); }); - test("put round-trips an updated schedule to disk", async () => { + test("update round-trips an updated schedule to disk", async () => { const created = await store.create({ name: "before", prompt: "before", @@ -79,7 +79,7 @@ describe("ScheduleStore", () => { nextRunAt: "2026-01-01T09:00:00.000Z", updatedAt: "2026-01-01T00:00:30.000Z", }; - await store.put(updated); + await store.update(created.id, () => updated); const reloaded = await new ScheduleStore(tempDir).get(created.id); expect(reloaded).toEqual(updated); @@ -113,4 +113,195 @@ describe("ScheduleStore", () => { expect(await store.get(created.id)).toBeNull(); expect(await store.list()).toEqual([]); }); + + test("serializes concurrent updates on one schedule without losing writes", async () => { + const created = await store.create({ + name: "before", + prompt: "before", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir }, + }, + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: "2026-01-01T00:01:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }); + + let releaseFirstUpdate: (() => void) | null = null; + const firstUpdateBlocked = new Promise((resolve) => { + releaseFirstUpdate = resolve; + }); + let firstUpdaterEntered: (() => void) | null = null; + const firstUpdaterStarted = new Promise((resolve) => { + firstUpdaterEntered = resolve; + }); + let secondSawRunCount = -1; + + const firstUpdate = store.update(created.id, async (schedule) => { + firstUpdaterEntered?.(); + await firstUpdateBlocked; + return { + ...schedule, + runs: [ + ...schedule.runs, + { + id: "run-1", + scheduledFor: "2026-01-01T00:01:00.000Z", + startedAt: "2026-01-01T00:01:00.000Z", + endedAt: null, + status: "running" as const, + agentId: null, + output: null, + error: null, + }, + ], + }; + }); + await firstUpdaterStarted; + + const secondUpdate = store.update(created.id, (schedule) => { + secondSawRunCount = schedule.runs.length; + return { + ...schedule, + prompt: "after", + }; + }); + + releaseFirstUpdate?.(); + const [, second] = await Promise.all([firstUpdate, secondUpdate]); + + expect(secondSawRunCount).toBe(1); + expect(second).toMatchObject({ + prompt: "after", + runs: [{ id: "run-1" }], + }); + await expect(new ScheduleStore(tempDir).get(created.id)).resolves.toMatchObject({ + prompt: "after", + runs: [{ id: "run-1" }], + }); + }); + + test("revalidates a named target match after waiting for the schedule update queue", async () => { + class GatedListScheduleStore extends ScheduleStore { + private listGate: { + entered: () => void; + release: Promise; + } | null = null; + + gateNextList(gate: { entered: () => void; release: Promise }): void { + this.listGate = gate; + } + + override async list() { + const schedules = await super.list(); + const gate = this.listGate; + if (gate) { + this.listGate = null; + gate.entered(); + await gate.release; + } + return schedules; + } + } + + const gatedStore = new GatedListScheduleStore(tempDir); + const target = { + type: "new-agent" as const, + config: { provider: "claude" as const, cwd: tempDir }, + }; + const created = await gatedStore.create({ + name: "race", + prompt: "before", + cadence: { type: "every", everyMs: 60_000 }, + target, + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: "2026-01-01T00:01:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }); + + let releaseCompletion: (() => void) | null = null; + const completionBlocked = new Promise((resolve) => { + releaseCompletion = resolve; + }); + let completionEntered: (() => void) | null = null; + const completionStarted = new Promise((resolve) => { + completionEntered = resolve; + }); + const completeOriginal = gatedStore.update(created.id, async (schedule) => { + completionEntered?.(); + await completionBlocked; + return { + ...schedule, + status: "completed" as const, + nextRunAt: null, + updatedAt: "2026-01-01T00:00:30.000Z", + }; + }); + await completionStarted; + + let releaseUpsertList: (() => void) | null = null; + const upsertListBlocked = new Promise((resolve) => { + releaseUpsertList = resolve; + }); + let upsertListEntered: (() => void) | null = null; + const upsertListed = new Promise((resolve) => { + upsertListEntered = resolve; + }); + gatedStore.gateNextList({ + entered: () => upsertListEntered?.(), + release: upsertListBlocked, + }); + + const upsert = gatedStore.upsertByNameAndTarget("race", target, { + create: () => ({ + name: "race", + prompt: "after", + cadence: { type: "every", everyMs: 60_000 }, + target, + status: "active", + createdAt: "2026-01-01T00:01:00.000Z", + updatedAt: "2026-01-01T00:01:00.000Z", + nextRunAt: "2026-01-01T00:02:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }), + update: () => { + throw new Error("stale identity match should not update"); + }, + }); + + await upsertListed; + releaseCompletion?.(); + await completeOriginal; + releaseUpsertList?.(); + + const upserted = await upsert; + expect(upserted.id).not.toBe(created.id); + expect(upserted).toMatchObject({ + name: "race", + prompt: "after", + status: "active", + }); + await expect(gatedStore.get(created.id)).resolves.toMatchObject({ + status: "completed", + prompt: "before", + }); + expect(await gatedStore.list()).toHaveLength(2); + }); }); diff --git a/packages/server/src/server/schedule/store.ts b/packages/server/src/server/schedule/store.ts index 37e9a402e..51d6c774f 100644 --- a/packages/server/src/server/schedule/store.ts +++ b/packages/server/src/server/schedule/store.ts @@ -1,14 +1,97 @@ import { randomBytes } from "node:crypto"; import { mkdir, readFile, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; -import { StoredScheduleSchema, type StoredSchedule } from "@getpaseo/protocol/schedule/types"; +import { + StoredScheduleSchema, + type ScheduleTarget, + type StoredSchedule, +} from "@getpaseo/protocol/schedule/types"; import { writeJsonFileAtomic } from "../atomic-file.js"; function generateScheduleId(): string { return randomBytes(4).toString("hex"); } +type ScheduleUpdater = (schedule: StoredSchedule) => StoredSchedule | Promise; + +interface ScheduleNameTargetUpsert { + create: () => Omit | Promise>; + update: ScheduleUpdater; +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value && typeof value === "object") { + const source = value as Record; + return Object.fromEntries( + Object.keys(source) + .sort() + .map((key) => [key, canonicalize(source[key])]), + ); + } + return value; +} + +function normalizeScheduleName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) { + throw new Error("Schedule name is required"); + } + return trimmed; +} + +function normalizeOptionalScheduleName(name: string | null): string | null { + if (name === null) { + return null; + } + const trimmed = name.trim(); + return trimmed ? trimmed : null; +} + +function targetIdentity(target: ScheduleTarget): unknown { + if (target.type === "agent") { + return { + type: target.type, + agentId: target.agentId, + }; + } + + const { workspaceId: _workspaceId, ...config } = target.config; + return { + type: target.type, + config, + }; +} + +function nameTargetIdentityKey(name: string, target: ScheduleTarget): string { + return JSON.stringify( + canonicalize({ + name: normalizeScheduleName(name), + target: targetIdentity(target), + }), + ); +} + +function matchesNameAndTarget( + schedule: StoredSchedule, + name: string, + target: ScheduleTarget, +): boolean { + const scheduleName = normalizeOptionalScheduleName(schedule.name); + return ( + schedule.status !== "completed" && + scheduleName !== null && + scheduleName === normalizeScheduleName(name) && + nameTargetIdentityKey(scheduleName, schedule.target) === nameTargetIdentityKey(name, target) + ); +} + export class ScheduleStore { + private readonly scheduleMutations = new Map>(); + private readonly identityMutations = new Map>(); + constructor(private readonly dir: string) {} private filePath(id: string): string { @@ -47,18 +130,125 @@ export class ScheduleStore { } async create(schedule: Omit): Promise { - const created = { ...schedule, id: generateScheduleId() }; - await this.put(created); + const created = StoredScheduleSchema.parse({ ...schedule, id: generateScheduleId() }); + await this.write(created); return created; } - async put(schedule: StoredSchedule): Promise { + async update(id: string, updater: ScheduleUpdater): Promise { + return this.serializeScheduleMutation(id, async () => { + const current = await this.get(id); + if (!current) { + return null; + } + const next = await updater(current); + if (next === current) { + return current; + } + if (next.id !== id) { + throw new Error(`Schedule update cannot change id: ${id}`); + } + const updated = StoredScheduleSchema.parse(next); + await this.write(updated); + return updated; + }); + } + + async upsertByNameAndTarget( + name: string, + target: ScheduleTarget, + options: ScheduleNameTargetUpsert, + ): Promise { + const identity = nameTargetIdentityKey(name, target); + return this.serializeIdentityMutation(identity, async () => { + while (true) { + const existing = (await this.list()).find((schedule) => + matchesNameAndTarget(schedule, name, target), + ); + if (!existing) { + const created = StoredScheduleSchema.parse({ + ...(await options.create()), + id: generateScheduleId(), + }); + if (!matchesNameAndTarget(created, name, target)) { + throw new Error("Created schedule does not match requested identity"); + } + await this.write(created); + return created; + } + + const updated = await this.updateMatchedSchedule(existing.id, name, target, options.update); + if (updated) { + return updated; + } + } + }); + } + + private async write(schedule: StoredSchedule): Promise { await this.ensureDir(); await writeJsonFileAtomic(this.filePath(schedule.id), schedule); } async delete(id: string): Promise { - await this.ensureDir(); - await rm(this.filePath(id), { force: true }); + await this.serializeScheduleMutation(id, async () => { + await this.ensureDir(); + await rm(this.filePath(id), { force: true }); + }); + } + + private async serializeScheduleMutation( + scheduleId: string, + mutation: () => Promise, + ): Promise { + return this.serializeMutation(this.scheduleMutations, scheduleId, mutation); + } + + private async serializeIdentityMutation( + identity: string, + mutation: () => Promise, + ): Promise { + return this.serializeMutation(this.identityMutations, identity, mutation); + } + + private async serializeMutation( + promises: Map>, + key: string, + mutation: () => Promise, + ): Promise { + const previous = promises.get(key) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(mutation); + promises.set(key, next); + try { + return await next; + } finally { + if (promises.get(key) === next) { + promises.delete(key); + } + } + } + + private async updateMatchedSchedule( + id: string, + name: string, + target: ScheduleTarget, + updater: ScheduleUpdater, + ): Promise { + return this.serializeScheduleMutation(id, async () => { + const current = await this.get(id); + if (!current || !matchesNameAndTarget(current, name, target)) { + return null; + } + const next = await updater(current); + if (next.id !== id) { + throw new Error(`Schedule update cannot change id: ${id}`); + } + const updated = StoredScheduleSchema.parse(next); + if (!matchesNameAndTarget(updated, name, target)) { + throw new Error("Updated schedule does not match requested identity"); + } + await this.write(updated); + return updated; + }); } } diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index ebf89f4ec..0f23c2a40 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -29,11 +29,15 @@ describe("snapshot mutation ownership boundary", () => { const cwd = mkdtempSync(path.join(os.tmpdir(), "snapshot-owner-live-")); try { - const snapshot = await daemonHandle.daemon.agentManager.createAgent({ - provider: "codex", - cwd, - model: "gpt-5.2-codex", - }); + const snapshot = await daemonHandle.daemon.agentManager.createAgent( + { + provider: "codex", + cwd, + model: "gpt-5.2-codex", + }, + undefined, + { workspaceId: undefined }, + ); await daemonHandle.daemon.agentManager.flush(); const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");