Scheduled runs each get their own workspace in the sidebar (#1934)
* feat(schedules): per-run workspaces, isolation and archive controls Scheduled agents never appeared in the sidebar: each schedule reused one stamped workspace and archived the run's agent on completion. Now every run mints its own workspace — the entity the sidebar shows — with per-schedule controls for isolation (local or worktree) and whether to archive that workspace when the run finishes. The schedule form is rebuilt on a non-React form model (open/commands/close) to end the flicker, edit-into-create contamination, and multi-host hydration bugs that came from effect-choreographed shared state. That lands the reusable foundations it needed: a form kit over a single control-geometry owner, a data-access taxonomy (replica/snapshot/fetch) with real load-state semantics, and lint/boundary guardrails so new screens inherit the paved road. Also fixes desktop combobox popovers truncating options when the trigger is narrower than the content. New schedule protocol fields are optional on both create and update, so old and new clients/daemons stay compatible. * test(app): complete mock themes for control-geometry tokens switch and terminal-profile-edit-modal now style through createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50], and theme.colors.borderAccent. The hand-rolled mock themes in these two suites lacked those tokens, so the eager StyleSheet.create mock threw at import (collection error), not on any assertion. Add the missing tokens. * fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration - Server: archive the run's workspace even when agent creation fails, so a misconfigured provider no longer orphans a sidebar workspace/worktree. - Edit form: keep a stored worktree isolation while worktree eligibility is still resolving, instead of silently downgrading a saved schedule to local. - Schedules screen: show the load error/retry UI when every host fails, rather than spinning forever behind the loading branch. - Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the sheet closes instead of re-opening. - Form model: apply late-loading saved preferences to untouched create-mode fields (never to edited schedules or user-modified fields). * fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety - Push-router: re-send terminal and checkout-diff subscriptions after a reconnect; the old per-hook effect resubscribed on isConnected and the extracted router did not, so daemon restarts silently stopped terminals_changed pushes (root cause of the terminal-activity e2e failures). - Server: pass the run's source repo root when archiving worktree runs so the worktree is unregistered (git worktree remove/prune), not just deleted; also archive the workspace when agent creation fails even with archive-off (an agentless workspace has nothing to inspect). - Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the Android native path too; replace the JSDOM component test with a real Playwright dismiss spec and inline the trivial dismiss decision. - Form model: editing an interval schedule no longer rewrites its cadence to cron unless the cadence UI is touched (90-minute intervals have no cron equivalent); switching projects clears stale provider/model state and gates submit until the new host's snapshot resolves. * chore(lint): drop custom lint wrapper and boundary script, plain oxlint The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports). The receiver-sensitive checks the custom script enforced are not worth a parallel lint pipeline; if they come back it will be as oxlint rules. * fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations - Persist the run's workspace/agent ids on the running-run record so a daemon crash mid-run can be recovered: restart archives the interrupted run's workspace under the same policy as normal cleanup (agentless always archives, otherwise archiveOnFinish is respected). - Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on hosts without workspaceMultiplicity — an old daemon ignores the fields, so offering the switch there would lie. - Optimistic pause/resume/delete no longer throw when the schedules cache holds a still-connecting entry alongside loaded data. - Mode field is gated on provider mode options, not a selected model, so model-less providers can pick their advertised modes.
This commit is contained in:
@@ -124,7 +124,9 @@ import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
|
||||
import {
|
||||
archiveByScope,
|
||||
archivePersistedWorkspaceRecord,
|
||||
killTerminalsForWorkspace,
|
||||
type ActiveWorkspaceRef,
|
||||
} from "./workspace-archive-service.js";
|
||||
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
|
||||
@@ -996,14 +998,75 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
await loopService.initialize();
|
||||
logger.info({ elapsed: elapsed() }, "Loop service initialized");
|
||||
const createScheduleLocalWorkspaceExternal = async (input: {
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => {
|
||||
const workspace = await createLocalCheckoutWorkspace(
|
||||
{ cwd: input.cwd, title: resolveFirstAgentPromptTitle(input.firstAgentContext) },
|
||||
{ projectRegistry, workspaceRegistry, workspaceGitService },
|
||||
);
|
||||
workspaceAutoName.scheduleForDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.cwd,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
});
|
||||
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
|
||||
return workspace;
|
||||
};
|
||||
const createSchedulePaseoWorktreeExternal = async (input: {
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}) => {
|
||||
const result = await createPaseoWorktreeForTools({
|
||||
cwd: input.cwd,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
});
|
||||
await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]);
|
||||
return result;
|
||||
};
|
||||
const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => {
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: config.paseoHome,
|
||||
paseoWorktreesBaseRoot: config.worktreesRoot,
|
||||
github,
|
||||
workspaceGitService,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
killTerminalsForWorkspace: (workspaceIdToKill) =>
|
||||
killTerminalsForWorkspace(
|
||||
{
|
||||
terminalManager,
|
||||
sessionLogger: logger,
|
||||
},
|
||||
workspaceIdToKill,
|
||||
),
|
||||
sessionLogger: logger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
repoRoot,
|
||||
paseoWorktreesBaseRoot: config.worktreesRoot,
|
||||
requestId: "schedule-run-finish",
|
||||
},
|
||||
);
|
||||
};
|
||||
const scheduleService = new ScheduleService({
|
||||
paseoHome: config.paseoHome,
|
||||
logger,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
createAgent,
|
||||
ensureWorkspaceForCreate: ensureWorkspaceForCreateAndBroadcastExternal,
|
||||
workspaceRegistry,
|
||||
createLocalCheckoutWorkspace: createScheduleLocalWorkspaceExternal,
|
||||
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { sep } from "node:path";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonClient,
|
||||
type DaemonTestContext,
|
||||
} from "./test-utils/index.js";
|
||||
import type { SessionOutboundMessage } from "./messages.js";
|
||||
import { getPaseoWorktreesRoot } from "../utils/worktree.js";
|
||||
|
||||
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
|
||||
type WorkspaceUpdateMessage = Extract<SessionOutboundMessage, { type: "workspace_update" }>;
|
||||
type ScheduleCreateOptions = Parameters<DaemonClient["scheduleCreate"]>[0];
|
||||
type ScheduleSummary = NonNullable<Awaited<ReturnType<DaemonClient["scheduleCreate"]>>["schedule"]>;
|
||||
type ScheduleWithRuns = NonNullable<
|
||||
Awaited<ReturnType<DaemonClient["scheduleRunOnce"]>>["schedule"]
|
||||
>;
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
for (const tempRoot of tempRoots.splice(0)) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
tempRoots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createGitRepo(): string {
|
||||
const tempRoot = makeTempDir("schedule-run-worktree-");
|
||||
const repoDir = path.join(tempRoot, "repo");
|
||||
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
async function createNewAgentSchedule(options: ScheduleCreateOptions): Promise<ScheduleSummary> {
|
||||
const response = await ctx.client.scheduleCreate(options);
|
||||
if (response.error || !response.schedule) {
|
||||
throw new Error(response.error ?? "schedule/create returned no schedule");
|
||||
}
|
||||
return response.schedule;
|
||||
}
|
||||
|
||||
async function runScheduleOnce(scheduleId: string): Promise<ScheduleWithRuns> {
|
||||
const response = await ctx.client.scheduleRunOnce({ id: scheduleId });
|
||||
if (response.error || !response.schedule) {
|
||||
throw new Error(response.error ?? "schedule/run-once returned no schedule");
|
||||
}
|
||||
return response.schedule;
|
||||
}
|
||||
|
||||
async function updateSchedule(
|
||||
options: Parameters<DaemonClient["scheduleUpdate"]>[0],
|
||||
): Promise<ScheduleWithRuns> {
|
||||
const response = await ctx.client.scheduleUpdate(options);
|
||||
if (response.error || !response.schedule) {
|
||||
throw new Error(response.error ?? "schedule/update returned no schedule");
|
||||
}
|
||||
return response.schedule;
|
||||
}
|
||||
|
||||
function requireCompletedAgentId(schedule: ScheduleWithRuns): string {
|
||||
const run = schedule.runs[0];
|
||||
if (!run || run.status !== "succeeded" || !run.agentId) {
|
||||
throw new Error(
|
||||
`Expected one succeeded run with an agent id: ${JSON.stringify(schedule.runs)}`,
|
||||
);
|
||||
}
|
||||
return run.agentId;
|
||||
}
|
||||
|
||||
function collectLifecycleUpdates(): {
|
||||
agentUpdates: AgentUpdateMessage[];
|
||||
workspaceUpdates: WorkspaceUpdateMessage[];
|
||||
stop: () => void;
|
||||
} {
|
||||
const agentUpdates: AgentUpdateMessage[] = [];
|
||||
const workspaceUpdates: WorkspaceUpdateMessage[] = [];
|
||||
const stopAgentUpdates = ctx.client.on("agent_update", (message) => {
|
||||
agentUpdates.push(message);
|
||||
});
|
||||
const stopWorkspaceUpdates = ctx.client.on("workspace_update", (message) => {
|
||||
workspaceUpdates.push(message);
|
||||
});
|
||||
return {
|
||||
agentUpdates,
|
||||
workspaceUpdates,
|
||||
stop: () => {
|
||||
stopAgentUpdates();
|
||||
stopWorkspaceUpdates();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForAgentUpsert(events: AgentUpdateMessage[], agentId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
events.some(
|
||||
(message) => message.payload.kind === "upsert" && message.payload.agent.id === agentId,
|
||||
),
|
||||
{ timeout: 10_000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function waitForWorkspaceUpsert(
|
||||
events: WorkspaceUpdateMessage[],
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
events.some(
|
||||
(message) =>
|
||||
message.payload.kind === "upsert" && message.payload.workspace.id === workspaceId,
|
||||
),
|
||||
{ timeout: 10_000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function waitForWorkspaceRemove(
|
||||
events: WorkspaceUpdateMessage[],
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
events.some(
|
||||
(message) => message.payload.kind === "remove" && message.payload.id === workspaceId,
|
||||
),
|
||||
{ timeout: 10_000, interval: 100 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
function workspaceWasRemoved(events: WorkspaceUpdateMessage[], workspaceId: string): boolean {
|
||||
return events.some(
|
||||
(message) => message.payload.kind === "remove" && message.payload.id === workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
function requireWorkspaceUpsert(
|
||||
events: WorkspaceUpdateMessage[],
|
||||
workspaceId: string,
|
||||
): Extract<WorkspaceUpdateMessage["payload"], { kind: "upsert" }>["workspace"] {
|
||||
const message = events.find(
|
||||
(event) => event.payload.kind === "upsert" && event.payload.workspace.id === workspaceId,
|
||||
);
|
||||
if (!message || message.payload.kind !== "upsert") {
|
||||
throw new Error(`Expected workspace upsert for ${workspaceId}`);
|
||||
}
|
||||
return message.payload.workspace;
|
||||
}
|
||||
|
||||
async function activeAgentIds(): Promise<Set<string>> {
|
||||
const agents = await ctx.client.fetchAgents({ scope: "active" });
|
||||
return new Set(agents.entries.map((entry) => entry.agent.id));
|
||||
}
|
||||
|
||||
async function archivedAgent(agentId: string) {
|
||||
const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const entry = agents.entries.find((item) => item.agent.id === agentId);
|
||||
if (!entry) {
|
||||
throw new Error(`Expected archived agent list to contain ${agentId}`);
|
||||
}
|
||||
return entry.agent;
|
||||
}
|
||||
|
||||
async function activeAgent(agentId: string) {
|
||||
const agent = await ctx.client.fetchAgent({ agentId });
|
||||
if (!agent) {
|
||||
throw new Error(`Expected active agent ${agentId}`);
|
||||
}
|
||||
return agent.agent;
|
||||
}
|
||||
|
||||
test("archiveOnFinish=false local scheduled run emits upserts and remains active", async () => {
|
||||
const cwd = makeTempDir("schedule-run-local-");
|
||||
const schedule = await createNewAgentSchedule({
|
||||
prompt: "Say done.",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
archiveOnFinish: false,
|
||||
isolation: "local",
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "schedule-local-workspaces" } });
|
||||
const events = collectLifecycleUpdates();
|
||||
|
||||
const ran = await runScheduleOnce(schedule.id);
|
||||
const agentId = requireCompletedAgentId(ran);
|
||||
const agent = await activeAgent(agentId);
|
||||
const workspaceId = agent.workspaceId;
|
||||
|
||||
expect(workspaceId).toMatch(/^wks_/);
|
||||
await waitForWorkspaceUpsert(events.workspaceUpdates, workspaceId!);
|
||||
await waitForAgentUpsert(events.agentUpdates, agentId);
|
||||
expect(workspaceWasRemoved(events.workspaceUpdates, workspaceId!)).toBe(false);
|
||||
expect(await activeAgentIds()).toContain(agentId);
|
||||
|
||||
events.stop();
|
||||
});
|
||||
|
||||
test("archiveOnFinish=true scheduled run emits a workspace remove", async () => {
|
||||
const cwd = makeTempDir("schedule-run-archive-");
|
||||
const schedule = await createNewAgentSchedule({
|
||||
prompt: "Say done.",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
archiveOnFinish: true,
|
||||
isolation: "local",
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
await ctx.client.fetchWorkspaces({
|
||||
subscribe: { subscriptionId: "schedule-archive-workspaces" },
|
||||
});
|
||||
const events = collectLifecycleUpdates();
|
||||
|
||||
const ran = await runScheduleOnce(schedule.id);
|
||||
const agentId = requireCompletedAgentId(ran);
|
||||
const agent = await archivedAgent(agentId);
|
||||
const workspaceId = agent.workspaceId;
|
||||
|
||||
expect(workspaceId).toMatch(/^wks_/);
|
||||
await waitForWorkspaceRemove(events.workspaceUpdates, workspaceId!);
|
||||
|
||||
events.stop();
|
||||
});
|
||||
|
||||
test("worktree isolation creates a run worktree and archiveOnFinish removes it", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
const expectedRoot = await getPaseoWorktreesRoot(
|
||||
repoDir,
|
||||
realpathSync(ctx.daemon.paseoHome),
|
||||
ctx.daemon.config.worktreesRoot,
|
||||
);
|
||||
const schedule = await createNewAgentSchedule({
|
||||
prompt: "Say done.",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: repoDir,
|
||||
archiveOnFinish: true,
|
||||
isolation: "worktree",
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
await ctx.client.fetchWorkspaces({
|
||||
subscribe: { subscriptionId: "schedule-worktree-workspaces" },
|
||||
});
|
||||
const events = collectLifecycleUpdates();
|
||||
|
||||
const ran = await runScheduleOnce(schedule.id);
|
||||
const agentId = requireCompletedAgentId(ran);
|
||||
const agent = await archivedAgent(agentId);
|
||||
const workspace = requireWorkspaceUpsert(events.workspaceUpdates, agent.workspaceId!);
|
||||
|
||||
expect(workspace.workspaceKind).toBe("worktree");
|
||||
expect(
|
||||
agent.cwd.startsWith(`${expectedRoot}${sep}`),
|
||||
`agent cwd ${agent.cwd}; expected root ${expectedRoot}`,
|
||||
).toBe(true);
|
||||
expect(agent.cwd).not.toBe(repoDir);
|
||||
await waitForWorkspaceRemove(events.workspaceUpdates, agent.workspaceId!);
|
||||
await expect.poll(() => existsSync(agent.cwd), { timeout: 10_000, interval: 100 }).toBe(false);
|
||||
const worktreeList = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(worktreeList).not.toContain(agent.cwd);
|
||||
|
||||
events.stop();
|
||||
});
|
||||
|
||||
test("update_schedule patches thinking, archive behavior, and isolation for the next run", async () => {
|
||||
const repoDir = createGitRepo();
|
||||
const expectedRoot = await getPaseoWorktreesRoot(
|
||||
repoDir,
|
||||
realpathSync(ctx.daemon.paseoHome),
|
||||
ctx.daemon.config.worktreesRoot,
|
||||
);
|
||||
const schedule = await createNewAgentSchedule({
|
||||
prompt: "Say done.",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: repoDir,
|
||||
archiveOnFinish: true,
|
||||
isolation: "local",
|
||||
},
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
|
||||
await updateSchedule({
|
||||
id: schedule.id,
|
||||
newAgentConfig: {
|
||||
thinkingOptionId: "think-hard",
|
||||
archiveOnFinish: false,
|
||||
isolation: "worktree",
|
||||
},
|
||||
});
|
||||
|
||||
const ran = await runScheduleOnce(schedule.id);
|
||||
const agentId = requireCompletedAgentId(ran);
|
||||
const agent = await activeAgent(agentId);
|
||||
|
||||
expect(agent.thinkingOptionId).toBe("think-hard");
|
||||
expect(agent.cwd.startsWith(`${expectedRoot}${sep}`)).toBe(true);
|
||||
expect(agent.cwd).not.toBe(repoDir);
|
||||
expect(await activeAgentIds()).toContain(agentId);
|
||||
expect(existsSync(agent.cwd)).toBe(true);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import type { AgentManager } from "../agent/agent-manager.js";
|
||||
import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
||||
@@ -9,12 +9,9 @@ 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 { type BoundCreateAgentCommand, formatProviderModel } from "../agent/create-agent/create.js";
|
||||
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
|
||||
import { ScheduleStore } from "./store.js";
|
||||
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
||||
import type {
|
||||
@@ -26,9 +23,9 @@ import type {
|
||||
UpdateScheduleInput,
|
||||
UpdateScheduleNewAgentConfig,
|
||||
} from "@getpaseo/protocol/schedule/types";
|
||||
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||
|
||||
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
|
||||
@@ -80,9 +77,6 @@ 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) {
|
||||
@@ -101,6 +95,20 @@ function applyNewAgentConfig(
|
||||
delete config.modeId;
|
||||
}
|
||||
}
|
||||
if (patch.thinkingOptionId !== undefined) {
|
||||
const trimmed = patch.thinkingOptionId?.trim();
|
||||
if (trimmed) {
|
||||
config.thinkingOptionId = trimmed;
|
||||
} else {
|
||||
delete config.thinkingOptionId;
|
||||
}
|
||||
}
|
||||
if (patch.archiveOnFinish !== undefined) {
|
||||
config.archiveOnFinish = patch.archiveOnFinish;
|
||||
}
|
||||
if (patch.isolation !== undefined) {
|
||||
config.isolation = patch.isolation;
|
||||
}
|
||||
return { ...target, config };
|
||||
}
|
||||
|
||||
@@ -118,6 +126,13 @@ function countCompletedRuns(schedule: StoredSchedule): number {
|
||||
return schedule.runs.filter((run) => run.status !== "running").length;
|
||||
}
|
||||
|
||||
function shouldArchiveScheduleRunWorkspace(input: {
|
||||
agentId: string | null;
|
||||
archiveOnFinish?: boolean;
|
||||
}): boolean {
|
||||
return input.agentId === null || (input.archiveOnFinish ?? true);
|
||||
}
|
||||
|
||||
function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean {
|
||||
if (schedule.expiresAt && new Date(schedule.expiresAt).getTime() <= now.getTime()) {
|
||||
return true;
|
||||
@@ -128,56 +143,6 @@ function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean {
|
||||
return countCompletedRuns(schedule) >= schedule.maxRuns;
|
||||
}
|
||||
|
||||
// Sort object keys recursively so two structurally-equal configs serialize
|
||||
// identically regardless of key order. Stored configs come back from disk in Zod
|
||||
// schema order while incoming configs keep their construction order, so a plain
|
||||
// JSON.stringify comparison would wrongly treat identical targets as different.
|
||||
function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const source = value as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(source)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalize(source[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function scheduleTargetsEqual(a: ScheduleTarget, b: ScheduleTarget): boolean {
|
||||
if (a.type === "agent" && b.type === "agent") {
|
||||
return a.agentId === b.agentId;
|
||||
}
|
||||
if (a.type === "new-agent" && b.type === "new-agent") {
|
||||
return JSON.stringify(canonicalize(a.config)) === JSON.stringify(canonicalize(b.config));
|
||||
}
|
||||
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}`);
|
||||
@@ -232,7 +197,6 @@ function buildRunOutput(params: {
|
||||
|
||||
type ScheduleAgentManager = Pick<
|
||||
AgentManager,
|
||||
| "archiveAgent"
|
||||
| "createAgent"
|
||||
| "getAgent"
|
||||
| "getRegisteredProviderIds"
|
||||
@@ -243,7 +207,10 @@ type ScheduleAgentManager = Pick<
|
||||
| "waitForAgentEvent"
|
||||
>;
|
||||
|
||||
type ScheduleWorkspaceRegistry = Pick<WorkspaceRegistry, "get">;
|
||||
interface ScheduleWorkspaceCreateInput {
|
||||
cwd: string;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
}
|
||||
|
||||
export interface ScheduleServiceOptions {
|
||||
paseoHome: string;
|
||||
@@ -251,8 +218,13 @@ export interface ScheduleServiceOptions {
|
||||
agentManager: ScheduleAgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
createAgent: BoundCreateAgentCommand;
|
||||
ensureWorkspaceForCreate: EnsureWorkspaceForCreate;
|
||||
workspaceRegistry: ScheduleWorkspaceRegistry;
|
||||
createLocalCheckoutWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<PersistedWorkspaceRecord>;
|
||||
createPaseoWorktreeWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
|
||||
now?: () => Date;
|
||||
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
|
||||
}
|
||||
@@ -263,18 +235,19 @@ export class ScheduleService {
|
||||
private readonly agentManager: ScheduleAgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly createAgent: BoundCreateAgentCommand;
|
||||
private readonly ensureWorkspaceForCreate: EnsureWorkspaceForCreate;
|
||||
private readonly workspaceRegistry: ScheduleWorkspaceRegistry;
|
||||
private readonly createLocalCheckoutWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<PersistedWorkspaceRecord>;
|
||||
private readonly createPaseoWorktreeWorkspace: (
|
||||
input: ScheduleWorkspaceCreateInput,
|
||||
) => Promise<CreatePaseoWorktreeWorkflowResult>;
|
||||
private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
|
||||
private readonly now: () => Date;
|
||||
private readonly runner: (
|
||||
schedule: StoredSchedule,
|
||||
runId: string,
|
||||
) => Promise<ScheduleExecutionResult>;
|
||||
private readonly runningScheduleIds = new Set<string>();
|
||||
private readonly workspaceStampPromises = new Map<
|
||||
string,
|
||||
Promise<Extract<ScheduleTarget, { type: "new-agent" }>>
|
||||
>();
|
||||
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(options: ScheduleServiceOptions) {
|
||||
@@ -283,8 +256,9 @@ export class ScheduleService {
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.createAgent = options.createAgent;
|
||||
this.ensureWorkspaceForCreate = options.ensureWorkspaceForCreate;
|
||||
this.workspaceRegistry = options.workspaceRegistry;
|
||||
this.createLocalCheckoutWorkspace = options.createLocalCheckoutWorkspace;
|
||||
this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace;
|
||||
this.archiveWorkspace = options.archiveWorkspace;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.runner = options.runner ?? ((schedule, runId) => this.executeSchedule(schedule, runId));
|
||||
}
|
||||
@@ -314,14 +288,10 @@ export class ScheduleService {
|
||||
async create(input: CreateScheduleInput): Promise<StoredSchedule> {
|
||||
const prompt = normalizePrompt(input.prompt);
|
||||
validateScheduleCadence(input.cadence);
|
||||
const target = await this.stampNewAgentWorkspace(
|
||||
stripNewAgentWorkspaceStamp(input.target),
|
||||
input.prompt,
|
||||
);
|
||||
return this.createScheduleRecord(input, {
|
||||
name: trimOptionalName(input.name),
|
||||
prompt,
|
||||
target,
|
||||
target: input.target,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -364,35 +334,25 @@ export class ScheduleService {
|
||||
const prompt = normalizePrompt(input.prompt);
|
||||
validateScheduleCadence(input.cadence);
|
||||
if (name === null) {
|
||||
const target = await this.stampNewAgentWorkspace(
|
||||
stripNewAgentWorkspaceStamp(input.target),
|
||||
input.prompt,
|
||||
);
|
||||
return this.createScheduleRecord(input, { name, prompt, target });
|
||||
return this.createScheduleRecord(input, { name, prompt, target: input.target });
|
||||
}
|
||||
|
||||
const inputTarget = stripNewAgentWorkspaceStamp(input.target);
|
||||
const inputTarget = 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 });
|
||||
return this.buildScheduleRecord(input, { name, prompt, target: inputTarget });
|
||||
},
|
||||
update: async (current) => {
|
||||
const now = this.now();
|
||||
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,
|
||||
target,
|
||||
target: inputTarget,
|
||||
status: "active",
|
||||
pausedAt: null,
|
||||
nextRunAt: nextRunAt.toISOString(),
|
||||
@@ -489,7 +449,7 @@ export class ScheduleService {
|
||||
const patchedTarget = applyNewAgentConfig(updated.target, input.newAgentConfig);
|
||||
updated = {
|
||||
...updated,
|
||||
target: await this.stampNewAgentWorkspace(patchedTarget, updated.prompt, updated.id),
|
||||
target: patchedTarget,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -501,12 +461,6 @@ export class ScheduleService {
|
||||
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);
|
||||
@@ -623,6 +577,12 @@ export class ScheduleService {
|
||||
}
|
||||
|
||||
private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise<void> {
|
||||
const interruptedWorkspaces: Array<{
|
||||
workspaceId: string;
|
||||
repoRoot: string;
|
||||
agentId: string | null;
|
||||
runId: string;
|
||||
}> = [];
|
||||
await this.store.update(scheduleId, (current) => {
|
||||
let updated = { ...current };
|
||||
let dirty = false;
|
||||
@@ -630,8 +590,24 @@ export class ScheduleService {
|
||||
const runningIndex = updated.runs.findIndex((run) => run.status === "running");
|
||||
if (runningIndex !== -1) {
|
||||
const runs = [...updated.runs];
|
||||
const runningRun = runs[runningIndex];
|
||||
if (
|
||||
updated.target.type === "new-agent" &&
|
||||
runningRun.workspaceId &&
|
||||
shouldArchiveScheduleRunWorkspace({
|
||||
agentId: runningRun.agentId,
|
||||
archiveOnFinish: updated.target.config.archiveOnFinish,
|
||||
})
|
||||
) {
|
||||
interruptedWorkspaces.push({
|
||||
workspaceId: runningRun.workspaceId,
|
||||
repoRoot: updated.target.config.cwd,
|
||||
agentId: runningRun.agentId,
|
||||
runId: runningRun.id,
|
||||
});
|
||||
}
|
||||
runs[runningIndex] = {
|
||||
...runs[runningIndex],
|
||||
...runningRun,
|
||||
status: "failed",
|
||||
endedAt: now.toISOString(),
|
||||
error: "Daemon restarted before the scheduled run completed",
|
||||
@@ -658,6 +634,24 @@ export class ScheduleService {
|
||||
}
|
||||
return current;
|
||||
});
|
||||
const interruptedWorkspace = interruptedWorkspaces[0];
|
||||
if (!interruptedWorkspace) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
err: error,
|
||||
agentId: interruptedWorkspace.agentId,
|
||||
workspaceId: interruptedWorkspace.workspaceId,
|
||||
scheduleId,
|
||||
runId: interruptedWorkspace.runId,
|
||||
},
|
||||
"Failed to archive interrupted scheduled workspace after daemon restart",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Orphaned agent-target schedules (agent deleted while the daemon was down, or
|
||||
@@ -760,7 +754,7 @@ export class ScheduleService {
|
||||
...run,
|
||||
status: params.status,
|
||||
endedAt: now.toISOString(),
|
||||
agentId: params.agentId,
|
||||
agentId: params.agentId ?? run.agentId,
|
||||
output: params.output,
|
||||
error: params.error,
|
||||
}
|
||||
@@ -806,6 +800,28 @@ export class ScheduleService {
|
||||
requireSchedule(updatedSchedule, params.scheduleId);
|
||||
}
|
||||
|
||||
private async recordRunWorkspace(params: {
|
||||
scheduleId: string;
|
||||
runId: string;
|
||||
workspaceId: string;
|
||||
agentId: string | null;
|
||||
}): Promise<void> {
|
||||
const updatedSchedule = await this.store.update(params.scheduleId, (schedule) => ({
|
||||
...schedule,
|
||||
updatedAt: this.now().toISOString(),
|
||||
runs: schedule.runs.map((run) =>
|
||||
run.id === params.runId && run.status === "running"
|
||||
? {
|
||||
...run,
|
||||
workspaceId: params.workspaceId,
|
||||
agentId: params.agentId,
|
||||
}
|
||||
: run,
|
||||
),
|
||||
}));
|
||||
requireSchedule(updatedSchedule, params.scheduleId);
|
||||
}
|
||||
|
||||
private async executeSchedule(
|
||||
schedule: StoredSchedule,
|
||||
runId: string,
|
||||
@@ -840,38 +856,53 @@ export class ScheduleService {
|
||||
};
|
||||
}
|
||||
|
||||
const executionSchedule = await this.ensureScheduleWorkspaceStamped(schedule);
|
||||
const stampedConfig =
|
||||
executionSchedule.target.type === "new-agent" ? executionSchedule.target.config : null;
|
||||
if (!stampedConfig) {
|
||||
const config = schedule.target.type === "new-agent" ? schedule.target.config : null;
|
||||
if (!config) {
|
||||
throw new Error(`Schedule ${schedule.id} target changed during execution`);
|
||||
}
|
||||
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 agent = created.snapshot;
|
||||
await this.assertNewAgentCwdDirectory(config.cwd);
|
||||
let workspace: PersistedWorkspaceRecord | null = null;
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
workspace = await this.createScheduleRunWorkspace(config, schedule.prompt);
|
||||
await this.recordRunWorkspace({
|
||||
scheduleId: schedule.id,
|
||||
runId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: null,
|
||||
});
|
||||
const runConfig = { ...config, cwd: workspace.cwd };
|
||||
const created = await this.createAgent({
|
||||
kind: "mcp",
|
||||
provider: formatScheduleProviderModel(runConfig),
|
||||
config: buildScheduleAgentConfig(runConfig),
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: resolveScheduleAgentTitle(config, schedule.prompt),
|
||||
labels: {
|
||||
"paseo.schedule-id": schedule.id,
|
||||
"paseo.schedule-run": runId,
|
||||
},
|
||||
mode: config.modeId,
|
||||
thinking: config.thinkingOptionId,
|
||||
features: config.featureValues,
|
||||
unattended: true,
|
||||
promptFailure: "return-error",
|
||||
background: true,
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
const agent = created.snapshot;
|
||||
agentId = agent.id;
|
||||
await this.recordRunWorkspace({
|
||||
scheduleId: schedule.id,
|
||||
runId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId,
|
||||
});
|
||||
if (created.initialPromptError) {
|
||||
throw created.initialPromptError;
|
||||
}
|
||||
const result = await this.agentManager.runAgent(agent.id, executionSchedule.prompt);
|
||||
const result = await this.agentManager.runAgent(agent.id, schedule.prompt);
|
||||
const waitResult = await this.agentManager.waitForAgentEvent(agent.id, {
|
||||
waitForActive: true,
|
||||
});
|
||||
@@ -885,7 +916,6 @@ export class ScheduleService {
|
||||
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({
|
||||
@@ -894,16 +924,40 @@ export class ScheduleService {
|
||||
finalText: result.finalText,
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.agentManager.archiveAgent(agent.id);
|
||||
} catch (archiveError) {
|
||||
this.logger.warn(
|
||||
{ err: archiveError, agentId: agent.id, scheduleId: schedule.id, runId },
|
||||
"Failed to archive scheduled agent after failed run",
|
||||
);
|
||||
} finally {
|
||||
if (
|
||||
workspace &&
|
||||
shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish })
|
||||
) {
|
||||
try {
|
||||
await this.archiveWorkspace(workspace.workspaceId, config.cwd);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
err: error,
|
||||
agentId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
scheduleId: schedule.id,
|
||||
runId,
|
||||
},
|
||||
"Failed to archive scheduled workspace after run",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createScheduleRunWorkspace(
|
||||
config: Extract<ScheduleTarget, { type: "new-agent" }>["config"],
|
||||
prompt: string,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const firstAgentContext = { prompt };
|
||||
switch (config.isolation ?? "local") {
|
||||
case "local":
|
||||
return this.createLocalCheckoutWorkspace({ cwd: config.cwd, firstAgentContext });
|
||||
case "worktree":
|
||||
return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext }))
|
||||
.workspace;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -920,120 +974,6 @@ export class ScheduleService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createWorkspaceStampedTarget(
|
||||
target: Extract<ScheduleTarget, { type: "new-agent" }>,
|
||||
prompt: string,
|
||||
scheduleId?: string,
|
||||
): Promise<ScheduleTarget> {
|
||||
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<boolean> {
|
||||
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<ScheduleTarget> {
|
||||
if (target.type !== "new-agent" || target.config.workspaceId) {
|
||||
return target;
|
||||
}
|
||||
return this.createWorkspaceStampedTarget(target, prompt, scheduleId);
|
||||
}
|
||||
|
||||
private async ensureScheduleWorkspaceStamped(schedule: StoredSchedule): Promise<StoredSchedule> {
|
||||
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(
|
||||
@@ -1069,32 +1009,6 @@ function resolveScheduleAgentTitle(
|
||||
);
|
||||
}
|
||||
|
||||
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<ScheduleTarget, { type: "new-agent" }>): 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<ScheduleTarget, { type: "new-agent" }>["config"],
|
||||
): string {
|
||||
|
||||
@@ -58,10 +58,9 @@ function targetIdentity(target: ScheduleTarget): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
const { workspaceId: _workspaceId, ...config } = target.config;
|
||||
return {
|
||||
type: target.type,
|
||||
config,
|
||||
config: target.config,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user