Keep idle agents and their background work alive (#2590)

* fix(server): keep idle agents resident

Remove time-based runtime collection so background work and subsequent prompts are not destroyed during idle periods. Runtimes now close only through explicit lifecycle actions.

* fix(server): preserve explicit close coordination

* fix(server): support partial agent manager adapters
This commit is contained in:
Mohamed Boudra
2026-07-29 12:36:44 +02:00
committed by GitHub
parent 504b687f89
commit fab975a059
12 changed files with 103 additions and 500 deletions

View File

@@ -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 same Paseo agent ID. Provider history is not appended again when the canonical timeline is already
primed. primed.
The daemon collects an eligible idle runtime after 30 minutes and sweeps every minute. Only Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
unarchived, non-internal agents that are exactly `idle`, have no active or pending run, replacement, action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
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.
### Cancellation ### 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 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 sets `archivedAt`, invokes the provider's native archive hook, and cascades to managed
archive hook, and cascades to managed children. Runtime collection does none of those things; it only children.
releases the live runtime and writes `lastStatus: closed` on the still-active record.
`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. `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.

View File

@@ -26,7 +26,7 @@ export type AgentLoaderManager = Pick<
| "hydrateTimelineFromProvider" | "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence" | "resumeAgentFromPersistence"
> & > &
Partial<Pick<AgentManager, "touchAgentActivity" | "waitForAgentClose">>; Partial<Pick<AgentManager, "waitForAgentClose">>;
export interface EnsureAgentLoadedDeps { export interface EnsureAgentLoadedDeps {
agentManager: AgentLoaderManager; agentManager: AgentLoaderManager;
@@ -71,8 +71,7 @@ export async function ensureAgentLoaded(
return inflight.promise; return inflight.promise;
} }
const existing = const existing = deps.agentManager.getAgent(agentId);
deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId);
if (existing) { if (existing) {
return existing; return existing;
} }

View File

@@ -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 () => { test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-collection-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
const storage = new AgentStorage(join(workdir, "agents"), logger); let closeCount = 0;
let activeSession: TestAgentSession | null = null; let resumeCount = 0;
const client = new (class extends NativeArchiveRecordingClient { const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> { override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
activeSession = new TestAgentSession(config); const recordClose = () => {
return activeSession; closeCount += 1;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
recordClose();
}
})(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
resumeCount += 1;
return super.resumeSession(handle, config, launchContext);
} }
})(); })();
const manager = new AgentManager({ const manager = new AgentManager({ clients: { codex: client }, logger });
clients: { codex: client },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000210",
});
try { try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "workspace-idle-collection", workspaceId: undefined,
});
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(),
}); });
expect(collection).toEqual({ await new Promise((resolve) => setTimeout(resolve, 25));
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();
const resumed = await ensureAgentLoaded(created.id, { expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
agentManager: manager, expect(closeCount).toBe(0);
agentStorage: storage,
logger,
});
expect(resumed.id).toBe(created.id); await manager.runAgent(agent.id, "Continue on the resident runtime");
expect(resumed.persistence).toEqual(created.persistence);
expect(manager.getTimeline(created.id)).toEqual(timelineBeforeCollection); expect(manager.getAgent(agent.id)?.lifecycle).toBe("idle");
expect(manager.listProviderSubagents(created.id)).toEqual([ expect(resumeCount).toBe(0);
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);
} finally { } finally {
await manager.flush().catch(() => undefined); await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
await storage.flush().catch(() => undefined); () => undefined,
);
rmSync(workdir, { recursive: true, force: true }); rmSync(workdir, { recursive: true, force: true });
} }
}); });
test("archiving an idle-collected parent still cascades to its managed children", async () => { test("archiving a closed parent still cascades to its managed children", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-collected-parent-archive-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-parent-archive-"));
const storage = new AgentStorage(join(workdir, "agents"), logger); const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { codex: new TestAgentClient() }, clients: { codex: new TestAgentClient() },
@@ -7368,7 +7317,7 @@ test("archiving an idle-collected parent still cascades to its managed children"
try { try {
const parent = await manager.createAgent( const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Collected parent" }, { provider: "codex", cwd: workdir, title: "Closed parent" },
undefined, undefined,
{ workspaceId: undefined }, { workspaceId: undefined },
); );
@@ -7381,10 +7330,7 @@ test("archiving an idle-collected parent still cascades to its managed children"
}, },
); );
await manager.collectIdleAgents({ await manager.closeAgent(parent.id);
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set([child.id]),
});
await manager.archiveSnapshot(parent.id, new Date().toISOString()); await manager.archiveSnapshot(parent.id, new Date().toISOString());
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String)); 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, { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined, workspaceId: undefined,
}); });
await manager.collectIdleAgents({ await manager.closeAgent(agent.id);
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.archiveSnapshot(agent.id, new Date().toISOString()); await manager.archiveSnapshot(agent.id, new Date().toISOString());
await expect( 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, { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined, workspaceId: undefined,
}); });
await manager.collectIdleAgents({ await manager.closeAgent(agent.id);
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
const load = ensureUnarchivedAgentLoaded(agent.id, { const load = ensureUnarchivedAgentLoaded(agent.id, {
agentManager: manager, 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, { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined, workspaceId: undefined,
}); });
await manager.collectIdleAgents({ await manager.closeAgent(agent.id);
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
const sharedLoad = ensureAgentLoaded(agent.id, { const sharedLoad = ensureAgentLoaded(agent.id, {
agentManager: manager, 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, { const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined, workspaceId: undefined,
}); });
await manager.collectIdleAgents({ await manager.closeAgent(agent.id);
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.deleteAgentState(agent.id); await manager.deleteAgentState(agent.id);
const events: AgentManagerEvent[] = []; const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false }); 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 () => { test("explicit close cancels running provider subagents before resume", 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 () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-closed-provider-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger); const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new SessionRecordingAgentClient(); 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( expect(manager.getProviderSubagent(parent.id, "provider-child-finishing")?.status).toBe(
"completed", "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 { } finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch( await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))).catch(
() => undefined, () => 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 () => { test("load waits for an in-flight explicit close and creates one resumed runtime", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-explicit-close-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger); const storage = new AgentStorage(join(workdir, "agents"), logger);
const closeStarted = deferred<void>(); const closeStarted = deferred<void>();
const closeAllowed = deferred<void>(); const closeAllowed = deferred<void>();
@@ -7858,10 +7628,7 @@ test("load waits for an in-flight collection close and creates only one resumed
"00000000-0000-4000-8000-000000000216", "00000000-0000-4000-8000-000000000216",
{ workspaceId: undefined }, { workspaceId: undefined },
); );
const collection = manager.collectIdleAgents({ const close = manager.closeAgent(created.id);
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await closeStarted.promise; await closeStarted.promise;
const loads = Promise.all([ const loads = Promise.all([
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }), 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); expect(client.resumeCount).toBe(0);
closeAllowed.resolve(); closeAllowed.resolve();
const [first, second] = await loads; const [first, second] = await loads;
await collection; await close;
expect(first.id).toBe(created.id); expect(first.id).toBe(created.id);
expect(second.id).toBe(created.id); expect(second.id).toBe(created.id);
expect(client.resumeCount).toBe(1); expect(client.resumeCount).toBe(1);
} finally { } finally {
closeAllowed.resolve();
await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined); await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined);
await storage.flush().catch(() => undefined); await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true }); 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<void>();
const closeAllowed = deferred<void>();
let closeCount = 0;
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const recordClose = () => {
closeCount += 1;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
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 () => { test("provider close failure still persists and emits a resumable closed agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
const storage = new AgentStorage(join(workdir, "agents"), logger); 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 closed = waitForAgentLifecycle(manager, created.id, "closed");
const collection = await manager.collectIdleAgents({ await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed");
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await closed; 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); const stored = await storage.get(created.id);
expect(stored).toMatchObject({ lastStatus: "closed" }); expect(stored).toMatchObject({ lastStatus: "closed" });
expect(stored?.archivedAt).toBeFalsy(); expect(stored?.archivedAt).toBeFalsy();

View File

@@ -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 = type ActiveManagedAgent =
| ManagedAgentInitializing | ManagedAgentInitializing
| ManagedAgentIdle | ManagedAgentIdle
@@ -969,15 +954,6 @@ export class AgentManager {
return agent ? { ...agent } : null; 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<void> { async waitForAgentClose(agentId: string): Promise<void> {
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined); await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
} }
@@ -1436,66 +1412,6 @@ export class AgentManager {
} }
} }
async collectIdleAgents(options: {
cutoff: Date;
protectedAgentIds: ReadonlySet<string>;
}): Promise<IdleAgentCollectionResult> {
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<string> },
): 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 }> { async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
if (!this.registry) { if (!this.registry) {

View File

@@ -37,19 +37,14 @@ describe("opencode agent commands E2E", () => {
} }
}, 60_000); }, 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({ const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"), ...getFullAccessConfig("opencode"),
cwd: "/tmp", cwd: "/tmp",
title: "Collected OpenCode Commands Test Agent", title: "Closed OpenCode Commands Test Agent",
}); });
const collection = await ctx.daemon.daemon.agentManager.collectIdleAgents({ await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection.failures).toEqual([]);
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull(); expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull();
const result = await ctx.client.listCommands({ agentId: agent.id }); const result = await ctx.client.listCommands({ agentId: agent.id });

View File

@@ -215,8 +215,6 @@ import { DaemonExecutions } from "./hub/daemon-executions.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]"; 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 = const DOWNLOAD_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW; process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
@@ -1194,39 +1192,6 @@ export async function createPaseoDaemon(
archiveWorkspace: archiveScheduleWorkspaceExternal, archiveWorkspace: archiveScheduleWorkspaceExternal,
}); });
await scheduleService.start(); await scheduleService.start();
let inFlightIdleAgentCollection: Promise<void> | 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) => { agentManager.setAgentArchivedCallback(async (agentId) => {
try { try {
await scheduleService.completeForAgent(agentId); await scheduleService.completeForAgent(agentId);
@@ -1634,8 +1599,6 @@ export async function createPaseoDaemon(
await hubRelationships.stop(); await hubRelationships.stop();
workspaceReconciliation.dispose(); workspaceReconciliation.dispose();
scriptHealthMonitor.stop(); scriptHealthMonitor.stop();
clearInterval(idleAgentCollectionTimer);
await inFlightIdleAgentCollection;
// Freeze both ingress and registration before taking the agent closure snapshot. // Freeze both ingress and registration before taking the agent closure snapshot.
wsServer?.prepareForShutdown(); wsServer?.prepareForShutdown();
agentManager.prepareForShutdown(); agentManager.prepareForShutdown();

View File

@@ -646,7 +646,7 @@ test(
); );
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 () => { async () => {
const cwd = tmpCwd("pi-resumed-entry-id-"); const cwd = tmpCwd("pi-resumed-entry-id-");
const firstPrompt = "PASEO_PI_ENTRY_ID_FIRST. Reply exactly: first-ok"; 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); const firstFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
expect(firstFinish.status).toBe("idle"); expect(firstFinish.status).toBe("idle");
const collection = await daemon.daemon.agentManager.collectIdleAgents({ await daemon.daemon.agentManager.closeAgent(agent.id);
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 client.sendMessage(agent.id, secondPrompt); await client.sendMessage(agent.id, secondPrompt);
const secondFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); const secondFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);

View File

@@ -376,47 +376,6 @@ describe("ScheduleService", () => {
expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z"); 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 () => { test("completes schedules when max runs is reached", async () => {
const service = createScheduleService({ const service = createScheduleService({
paseoHome: tempDir, paseoHome: tempDir,

View File

@@ -204,7 +204,6 @@ type ScheduleAgentManager = Pick<
| "hydrateTimelineFromProvider" | "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence" | "resumeAgentFromPersistence"
| "runAgent" | "runAgent"
| "touchAgentActivity"
| "waitForAgentEvent" | "waitForAgentEvent"
| "waitForAgentClose" | "waitForAgentClose"
>; >;
@@ -370,17 +369,6 @@ export class ScheduleService {
return this.store.list(); return this.store.list();
} }
async listActiveAgentTargetIds(): Promise<Set<string>> {
const schedules = await this.store.list();
const agentIds = new Set<string>();
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<StoredSchedule> { async inspect(id: string): Promise<StoredSchedule> {
const schedule = await this.store.get(id); const schedule = await this.store.get(id);
if (!schedule) { if (!schedule) {

View File

@@ -4906,7 +4906,7 @@ describe("agent config setters", () => {
} { } {
return { return {
waitForAgentClose: vi.fn().mockResolvedValue(undefined), waitForAgentClose: vi.fn().mockResolvedValue(undefined),
touchAgentActivity: vi.fn(() => ({ id: "agent-1" })), getAgent: vi.fn(() => ({ id: "agent-1" })),
...overrides, ...overrides,
}; };
} }

View File

@@ -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(); const { subsystem, emitted, operations } = makeSubsystem();
operations.loadFailure = new Error("agent is archived"); operations.loadFailure = new Error("agent is archived");

View File

@@ -19,7 +19,7 @@ export interface AgentConfigSessionHost {
/** /**
* The per-agent config mutations this subsystem drives. The shell adapts these * 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 * routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
* thinking yield a provider notice; model and feature do not. * thinking yield a provider notice; model and feature do not.
*/ */