diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index e58f0d5c1..680122391 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -21,16 +21,8 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider same Paseo agent ID. Provider history is not appended again when the canonical timeline is already primed. -The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only -unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement, -or permission, and have not been activated during the idle window are eligible. `running`, -`initializing`, and `error` agents stay resident. An idle parent also stays resident while current -in-memory state shows a running managed child or provider subagent. Otherwise agents are evaluated -independently; collection does not cascade or change parentage. - -Active schedules targeting an existing agent protect that agent from collection. Paused, completed, -and new-agent schedules do not. A pane may remain open after collection; its next prompt resumes the -runtime. +Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle +action such as archive, replacement, reload, workspace teardown, or daemon shutdown. ### Cancellation @@ -59,9 +51,8 @@ The provider still owns the underlying runtime. Paseo keeps an agent record so t Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. -Archive is distinct from runtime collection. Archive sets `archivedAt`, invokes the provider's native -archive hook, and cascades to managed children. Runtime collection does none of those things; it only -releases the live runtime and writes `lastStatus: closed` on the still-active record. +Archive sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed +children. `create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone. diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index c4a32e5f5..f87f38911 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -26,7 +26,7 @@ export type AgentLoaderManager = Pick< | "hydrateTimelineFromProvider" | "resumeAgentFromPersistence" > & - Partial>; + Partial>; export interface EnsureAgentLoadedDeps { agentManager: AgentLoaderManager; @@ -71,8 +71,7 @@ export async function ensureAgentLoaded( return inflight.promise; } - const existing = - deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId); + const existing = deps.agentManager.getAgent(agentId); if (existing) { return existing; } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 10dcea8f9..1ff1b1933 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7257,108 +7257,57 @@ test("closeAgent persists one final closed snapshot", async () => { } }); -test("collectIdleAgents releases an idle runtime and resumes the same agent and timeline", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-collection-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - let activeSession: TestAgentSession | null = null; - const client = new (class extends NativeArchiveRecordingClient { +test("idle agents remain resident until an explicit lifecycle action closes them", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-")); + let closeCount = 0; + let resumeCount = 0; + const client = new (class extends TestAgentClient { override async createSession(config: AgentSessionConfig): Promise { - activeSession = new TestAgentSession(config); - return activeSession; + const recordClose = () => { + closeCount += 1; + }; + return new (class extends TestAgentSession { + override async close(): Promise { + recordClose(); + } + })(config); + } + + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + resumeCount += 1; + return super.resumeSession(handle, config, launchContext); } })(); - const manager = new AgentManager({ - clients: { codex: client }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000210", - }); + const manager = new AgentManager({ clients: { codex: client }, logger }); try { - const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - workspaceId: "workspace-idle-collection", - }); - await manager.appendTimelineItem(created.id, { - type: "user_message", - text: "Keep this timeline", - }); - activeSession?.pushEvent({ - type: "provider_subagent", - provider: "codex", - event: { - type: "upsert", - id: "retained-provider-child", - title: "Retained provider child", - status: "completed", - }, - }); - await manager.flush(); - const timelineBeforeCollection = manager.getTimeline(created.id); - - const collection = await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, }); - expect(collection).toEqual({ - collected: [ - { - agentId: created.id, - provider: "codex", - sessionId: created.persistence?.sessionId, - }, - ], - failures: [], - }); - expect(manager.getAgent(created.id)).toBeNull(); - expect(client.archivedHandles).toEqual([]); - const stored = await storage.get(created.id); - expect(stored).toMatchObject({ - id: created.id, - lastStatus: "closed", - workspaceId: "workspace-idle-collection", - }); - expect(stored?.archivedAt).toBeFalsy(); + await new Promise((resolve) => setTimeout(resolve, 25)); - const resumed = await ensureAgentLoaded(created.id, { - agentManager: manager, - agentStorage: storage, - logger, - }); + expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle"); + expect(closeCount).toBe(0); - expect(resumed.id).toBe(created.id); - expect(resumed.persistence).toEqual(created.persistence); - expect(manager.getTimeline(created.id)).toEqual(timelineBeforeCollection); - expect(manager.listProviderSubagents(created.id)).toEqual([ - expect.objectContaining({ - id: "retained-provider-child", - title: "Retained provider child", - status: "completed", - }), - ]); - const idleBeforeOpen = resumed.updatedAt; - await ensureAgentLoaded(created.id, { - agentManager: manager, - agentStorage: storage, - logger, - }); - await expect( - manager.collectIdleAgents({ cutoff: idleBeforeOpen, protectedAgentIds: new Set() }), - ).resolves.toMatchObject({ collected: [] }); - await expect(manager.runAgent(created.id, "Continue the same agent")).resolves.toMatchObject({ - finalText: "", - canceled: false, - }); - expect(manager.getAgent(created.id)?.id).toBe(created.id); + await manager.runAgent(agent.id, "Continue on the resident runtime"); + + expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle"); + expect(resumeCount).toBe(0); } finally { - await manager.flush().catch(() => undefined); - await storage.flush().catch(() => undefined); + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( + () => undefined, + ); rmSync(workdir, { recursive: true, force: true }); } }); -test("archiving an idle-collected parent still cascades to its managed children", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-collected-parent-archive-")); +test("archiving a closed parent still cascades to its managed children", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-parent-archive-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const manager = new AgentManager({ clients: { codex: new TestAgentClient() }, @@ -7368,7 +7317,7 @@ test("archiving an idle-collected parent still cascades to its managed children" try { const parent = await manager.createAgent( - { provider: "codex", cwd: workdir, title: "Collected parent" }, + { provider: "codex", cwd: workdir, title: "Closed parent" }, undefined, { workspaceId: undefined }, ); @@ -7381,10 +7330,7 @@ test("archiving an idle-collected parent still cascades to its managed children" }, ); - await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set([child.id]), - }); + await manager.closeAgent(parent.id); await manager.archiveSnapshot(parent.id, new Date().toISOString()); expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String)); @@ -7410,10 +7356,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () = const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); - await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); + await manager.closeAgent(agent.id); await manager.archiveSnapshot(agent.id, new Date().toISOString()); await expect( @@ -7452,10 +7395,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); - await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); + await manager.closeAgent(agent.id); const load = ensureUnarchivedAgentLoaded(agent.id, { agentManager: manager, @@ -7498,10 +7438,7 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); - await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); + await manager.closeAgent(agent.id); const sharedLoad = ensureAgentLoaded(agent.id, { agentManager: manager, @@ -7557,10 +7494,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); - await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); + await manager.closeAgent(agent.id); await manager.deleteAgentState(agent.id); const events: AgentManagerEvent[] = []; manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false }); @@ -7598,166 +7532,7 @@ test("a shared agent load upgrades provider history hydration to broadcast", asy } }); -test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-")); - const client = new SessionRecordingAgentClient(); - const ids = [ - "00000000-0000-4000-8000-000000000211", - "00000000-0000-4000-8000-000000000212", - "00000000-0000-4000-8000-000000000213", - "00000000-0000-4000-8000-000000000214", - "00000000-0000-4000-8000-000000000215", - ]; - const manager = new AgentManager({ - clients: { codex: client }, - logger, - idFactory: () => ids.shift()!, - }); - - try { - const recent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - workspaceId: undefined, - }); - const protectedAgent = await manager.createAgent( - { provider: "codex", cwd: workdir }, - undefined, - { workspaceId: undefined }, - ); - const internal = await manager.createAgent( - { provider: "codex", cwd: workdir, internal: true }, - undefined, - { workspaceId: undefined }, - ); - const running = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - workspaceId: undefined, - }); - const failed = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - workspaceId: undefined, - }); - client.sessions[3]!.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: "autonomous-running", - }); - client.sessions[4]!.pushEvent({ - type: "turn_failed", - provider: "codex", - turnId: "autonomous-failed", - error: "provider failed", - }); - await manager.flush(); - - const recentSweep = await manager.collectIdleAgents({ - cutoff: new Date(recent.updatedAt.getTime() - 1), - protectedAgentIds: new Set(), - }); - const protectedSweep = await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set([protectedAgent.id, recent.id]), - }); - - expect(recentSweep.collected).toEqual([]); - expect(protectedSweep.collected).toEqual([]); - expect(manager.getAgent(recent.id)?.lifecycle).toBe("idle"); - expect(manager.getAgent(protectedAgent.id)?.lifecycle).toBe("idle"); - expect(manager.getAgent(internal.id)?.lifecycle).toBe("idle"); - expect(manager.getAgent(running.id)?.lifecycle).toBe("running"); - expect(manager.getAgent(failed.id)?.lifecycle).toBe("error"); - } finally { - await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( - () => undefined, - ); - rmSync(workdir, { recursive: true, force: true }); - } -}); - -test("collectIdleAgents protects an idle parent with a running managed child", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-child-")); - const client = new SessionRecordingAgentClient(); - const manager = new AgentManager({ clients: { codex: client }, logger }); - - try { - const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - workspaceId: undefined, - }); - const child = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, - workspaceId: undefined, - }); - const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - workspaceId: undefined, - }); - client.sessions[1]!.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: "managed-child-running", - }); - await manager.flush(); - - const collection = await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); - - expect(collection).toEqual({ - collected: [expect.objectContaining({ agentId: independent.id })], - failures: [], - }); - expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle"); - expect(manager.getAgent(child.id)?.lifecycle).toBe("running"); - expect(manager.getAgent(independent.id)).toBeNull(); - } finally { - await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( - () => undefined, - ); - rmSync(workdir, { recursive: true, force: true }); - } -}); - -test("collectIdleAgents protects an idle parent with a running provider subagent", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-running-provider-child-")); - const client = new SessionRecordingAgentClient(); - const manager = new AgentManager({ clients: { codex: client }, logger }); - - try { - const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - workspaceId: undefined, - }); - const independent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { - workspaceId: undefined, - }); - client.sessions[0]!.pushEvent({ - type: "provider_subagent", - provider: "codex", - event: { - type: "upsert", - id: "provider-child-running", - title: "Provider child", - status: "running", - }, - }); - await manager.flush(); - - const collection = await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); - - expect(collection).toEqual({ - collected: [expect.objectContaining({ agentId: independent.id })], - failures: [], - }); - expect(manager.getAgent(parent.id)?.lifecycle).toBe("idle"); - expect(manager.getAgent(independent.id)).toBeNull(); - } finally { - await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( - () => undefined, - ); - rmSync(workdir, { recursive: true, force: true }); - } -}); - -test("closed provider subagents do not block collection after resume", async () => { +test("explicit close cancels running provider subagents before resume", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new SessionRecordingAgentClient(); @@ -7811,11 +7586,6 @@ test("closed provider subagents do not block collection after resume", async () expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe( "completed", ); - const collection = await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); - expect(collection.collected).toEqual([expect.objectContaining({ agentId: parent.id })]); } finally { await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( () => undefined, @@ -7825,8 +7595,8 @@ test("closed provider subagents do not block collection after resume", async () } }); -test("load waits for an in-flight collection close and creates only one resumed runtime", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-")); +test("load waits for an in-flight explicit close and creates one resumed runtime", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-explicit-close-race-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const closeStarted = deferred(); const closeAllowed = deferred(); @@ -7858,10 +7628,7 @@ test("load waits for an in-flight collection close and creates only one resumed "00000000-0000-4000-8000-000000000216", { workspaceId: undefined }, ); - const collection = manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); + const close = manager.closeAgent(created.id); await closeStarted.promise; const loads = Promise.all([ ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }), @@ -7871,18 +7638,58 @@ test("load waits for an in-flight collection close and creates only one resumed expect(client.resumeCount).toBe(0); closeAllowed.resolve(); const [first, second] = await loads; - await collection; + await close; expect(first.id).toBe(created.id); expect(second.id).toBe(created.id); expect(client.resumeCount).toBe(1); } finally { + closeAllowed.resolve(); await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined); await storage.flush().catch(() => undefined); rmSync(workdir, { recursive: true, force: true }); } }); +test("concurrent explicit closes tear down the runtime once", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-concurrent-close-")); + const closeStarted = deferred(); + const closeAllowed = deferred(); + let closeCount = 0; + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + const recordClose = () => { + closeCount += 1; + }; + return new (class extends TestAgentSession { + override async close(): Promise { + recordClose(); + closeStarted.resolve(); + await closeAllowed.promise; + } + })(config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const firstClose = manager.closeAgent(agent.id); + await closeStarted.promise; + const secondClose = manager.closeAgent(agent.id); + + closeAllowed.resolve(); + await Promise.all([firstClose, secondClose]); + + expect(closeCount).toBe(1); + } finally { + closeAllowed.resolve(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("provider close failure still persists and emits a resumable closed agent", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-")); const storage = new AgentStorage(join(workdir, "agents"), logger); @@ -7905,18 +7712,8 @@ test("provider close failure still persists and emits a resumable closed agent", ); const closed = waitForAgentLifecycle(manager, created.id, "closed"); - const collection = await manager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); + await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed"); await closed; - expect(collection.collected).toEqual([]); - expect(collection.failures).toHaveLength(1); - expect(collection.failures[0]).toMatchObject({ - agentId: created.id, - provider: "codex", - error: expect.objectContaining({ message: "provider cleanup failed" }), - }); const stored = await storage.get(created.id); expect(stored).toMatchObject({ lastStatus: "closed" }); expect(stored?.archivedAt).toBeFalsy(); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index dc87d3196..e6c4e68a8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -394,21 +394,6 @@ export interface AgentMetricsSnapshot { }; } -export interface IdleAgentCollectionEntry { - agentId: string; - provider: AgentProvider; - sessionId?: string; -} - -export interface IdleAgentCollectionFailure extends IdleAgentCollectionEntry { - error: unknown; -} - -export interface IdleAgentCollectionResult { - collected: IdleAgentCollectionEntry[]; - failures: IdleAgentCollectionFailure[]; -} - type ActiveManagedAgent = | ManagedAgentInitializing | ManagedAgentIdle @@ -969,15 +954,6 @@ export class AgentManager { return agent ? { ...agent } : null; } - touchAgentActivity(id: string): ManagedAgent | null { - const agent = this.agents?.get(id); - if (!agent) { - return null; - } - this.touchUpdatedAt(agent); - return { ...agent }; - } - async waitForAgentClose(agentId: string): Promise { await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined); } @@ -1436,66 +1412,6 @@ export class AgentManager { } } - async collectIdleAgents(options: { - cutoff: Date; - protectedAgentIds: ReadonlySet; - }): Promise { - const result: IdleAgentCollectionResult = { collected: [], failures: [] }; - - for (const agent of Array.from(this.agents.values())) { - const current = this.agents.get(agent.id); - if (!current || !this.isIdleAgentCollectable(current, options)) { - continue; - } - - const entry: IdleAgentCollectionEntry = { - agentId: current.id, - provider: current.provider, - ...(current.persistence?.sessionId ? { sessionId: current.persistence.sessionId } : {}), - }; - try { - await this.closeAgent(current.id); - result.collected.push(entry); - } catch (error) { - result.failures.push({ ...entry, error }); - } - } - - return result; - } - - private isIdleAgentCollectable( - agent: LiveManagedAgent, - options: { cutoff: Date; protectedAgentIds: ReadonlySet }, - ): agent is ManagedAgentIdle { - return ( - agent.lifecycle === "idle" && - agent.updatedAt.getTime() <= options.cutoff.getTime() && - !agent.internal && - !options.protectedAgentIds.has(agent.id) && - agent.activeForegroundTurnId === null && - !this.runs.hasRun(agent.id) && - !agent.pendingReplacement && - agent.pendingPermissions.size === 0 && - agent.inFlightPermissionResponses.size === 0 && - !this.hasRunningChild(agent.id) - ); - } - - private hasRunningChild(parentAgentId: string): boolean { - for (const agent of this.agents.values()) { - if ( - agent.lifecycle === "running" && - getParentAgentIdFromLabels(agent.labels) === parentAgentId - ) { - return true; - } - } - return this.providerSubagents - .list(parentAgentId) - .some((subagent) => subagent.status === "running"); - } - async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); if (!this.registry) { diff --git a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts index 8dd3832cf..4342ffc00 100644 --- a/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent-commands.e2e.test.ts @@ -37,19 +37,14 @@ describe("opencode agent commands E2E", () => { } }, 60_000); - test("listing commands resumes an idle-collected agent", async () => { + test("listing commands resumes an explicitly closed agent", async () => { const agent = await ctx.client.createAgent({ ...getFullAccessConfig("opencode"), cwd: "/tmp", - title: "Collected OpenCode Commands Test Agent", + title: "Closed OpenCode Commands Test Agent", }); - const collection = await ctx.daemon.daemon.agentManager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); - expect(collection.failures).toEqual([]); - expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id); + await ctx.daemon.daemon.agentManager.closeAgent(agent.id); expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull(); const result = await ctx.client.listCommands({ agentId: agent.id }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 31fcd5400..2a1005cb7 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -215,8 +215,6 @@ import { DaemonExecutions } from "./hub/daemon-executions.js"; const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const REDACTED_LOG_VALUE = "[redacted]"; -const IDLE_AGENT_RUNTIME_TTL_MS = 30 * 60 * 1000; -const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000; const DOWNLOAD_OPEN_FLAGS = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW; @@ -1194,39 +1192,6 @@ export async function createPaseoDaemon( archiveWorkspace: archiveScheduleWorkspaceExternal, }); await scheduleService.start(); - let inFlightIdleAgentCollection: Promise | null = null; - const collectIdleAgentRuntimes = async () => { - const protectedAgentIds = await scheduleService.listActiveAgentTargetIds(); - const cutoff = new Date(Date.now() - IDLE_AGENT_RUNTIME_TTL_MS); - const result = await agentManager.collectIdleAgents({ cutoff, protectedAgentIds }); - for (const collected of result.collected) { - logger.info(collected, "Collected idle agent runtime"); - } - for (const failure of result.failures) { - const { error, ...context } = failure; - logger.warn({ ...context, err: error }, "Failed to collect idle agent runtime"); - } - }; - const runIdleAgentCollection = () => { - if (inFlightIdleAgentCollection) { - return; - } - const collection = collectIdleAgentRuntimes() - .catch((error) => { - logger.warn({ err: error }, "Idle agent runtime sweep failed"); - }) - .finally(() => { - if (inFlightIdleAgentCollection === collection) { - inFlightIdleAgentCollection = null; - } - }); - inFlightIdleAgentCollection = collection; - }; - const idleAgentCollectionTimer = setInterval( - runIdleAgentCollection, - IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS, - ); - idleAgentCollectionTimer.unref(); agentManager.setAgentArchivedCallback(async (agentId) => { try { await scheduleService.completeForAgent(agentId); @@ -1634,8 +1599,6 @@ export async function createPaseoDaemon( await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); - clearInterval(idleAgentCollectionTimer); - await inFlightIdleAgentCollection; // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); agentManager.prepareForShutdown(); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index ef7d43a0f..5a0510c1e 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -646,7 +646,7 @@ test( ); test( - "resumed Pi prompts retain their exact native entry ids after idle collection", + "resumed Pi prompts retain their exact native entry ids after explicit runtime close", async () => { const cwd = tmpCwd("pi-resumed-entry-id-"); const firstPrompt = "PASEO_PI_ENTRY_ID_FIRST. Reply exactly: first-ok"; @@ -665,12 +665,7 @@ test( const firstFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); expect(firstFinish.status).toBe("idle"); - const collection = await daemon.daemon.agentManager.collectIdleAgents({ - cutoff: new Date(Date.now() + 1_000), - protectedAgentIds: new Set(), - }); - expect(collection.failures).toEqual([]); - expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id); + await daemon.daemon.agentManager.closeAgent(agent.id); await client.sendMessage(agent.id, secondPrompt); const secondFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index b713641e0..a7fad87be 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -376,47 +376,6 @@ describe("ScheduleService", () => { expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z"); }); - test("lists only active schedules that target existing agents", 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 activeAgentId = "00000000-0000-4000-8000-000000000201"; - const pausedAgentId = "00000000-0000-4000-8000-000000000202"; - const completedAgentId = "00000000-0000-4000-8000-000000000203"; - const cadence = { type: "every" as const, everyMs: 60_000 }; - - await service.create({ - prompt: "Keep active agent resident", - cadence, - target: { type: "agent", agentId: activeAgentId }, - }); - const paused = await service.create({ - prompt: "Paused heartbeat", - cadence, - target: { type: "agent", agentId: pausedAgentId }, - }); - await service.pause(paused.id); - await service.create({ - prompt: "Completed heartbeat", - cadence, - target: { type: "agent", agentId: completedAgentId }, - }); - await service.completeForAgent(completedAgentId); - await service.create({ - prompt: "Fresh agent each run", - cadence, - target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, - }); - - await expect(service.listActiveAgentTargetIds()).resolves.toEqual(new Set([activeAgentId])); - }); - test("completes schedules when max runs is reached", async () => { const service = createScheduleService({ paseoHome: tempDir, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 6bcaed724..6a650ac92 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -204,7 +204,6 @@ type ScheduleAgentManager = Pick< | "hydrateTimelineFromProvider" | "resumeAgentFromPersistence" | "runAgent" - | "touchAgentActivity" | "waitForAgentEvent" | "waitForAgentClose" >; @@ -370,17 +369,6 @@ export class ScheduleService { return this.store.list(); } - async listActiveAgentTargetIds(): Promise> { - const schedules = await this.store.list(); - const agentIds = new Set(); - for (const schedule of schedules) { - if (schedule.status === "active" && schedule.target.type === "agent") { - agentIds.add(schedule.target.agentId); - } - } - return agentIds; - } - async inspect(id: string): Promise { const schedule = await this.store.get(id); if (!schedule) { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 53ec923a8..9b9701ecb 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -4906,7 +4906,7 @@ describe("agent config setters", () => { } { return { waitForAgentClose: vi.fn().mockResolvedValue(undefined), - touchAgentActivity: vi.fn(() => ({ id: "agent-1" })), + getAgent: vi.fn(() => ({ id: "agent-1" })), ...overrides, }; } diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index d0e14519a..f6d35a1f3 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -117,7 +117,7 @@ describe("AgentConfigSession", () => { }); }); - test("set mode: a failed load rejects without mutating the collected agent", async () => { + test("set mode: a failed load rejects without mutating the closed agent", async () => { const { subsystem, emitted, operations } = makeSubsystem(); operations.loadFailure = new Error("agent is archived"); diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts index 1cd39856a..dab811b98 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -19,7 +19,7 @@ export interface AgentConfigSessionHost { /** * The per-agent config mutations this subsystem drives. The shell adapts these - * onto the AgentManager and loads a collected agent before mutation (mode still + * onto the AgentManager and loads a closed agent before mutation (mode still * routes through setAgentModeCommand); tests wire an in-memory fake. Mode and * thinking yield a provider notice; model and feature do not. */