mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user