diff --git a/packages/cli/src/commands/agent/archive.ts b/packages/cli/src/commands/agent/archive.ts index 0a857c748..b439b6049 100644 --- a/packages/cli/src/commands/agent/archive.ts +++ b/packages/cli/src/commands/agent/archive.ts @@ -83,7 +83,8 @@ export async function runArchiveCommand( const error: CommandError = { code: 'AGENT_RUNNING', message: `Agent ${agentId.slice(0, 7)} is currently running`, - details: 'Use --force to archive a running agent, or stop it first with: paseo agent stop', + details: + 'Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop', } throw error } diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index 4004e2911..c87a0a0db 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -117,7 +117,7 @@ export function createAgentCommand(): Command { .command('archive') .description('Archive an agent (soft-delete)') .argument('', 'Agent ID (or prefix)') - .option('--force', 'Force archive running agent') + .option('--force', 'Force archive running agent (interrupts active run first)') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runArchiveCommand)) diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index fbce2eee4..b0a2ff0e0 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -196,6 +196,64 @@ describe("daemon client E2E", () => { await expect(ctx.client.archiveAgent(randomUUID())).rejects.toThrow(); }, 10000); + test("interrupts a running agent before archiving", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + + await ctx.client.sendMessage( + created.id, + "Use your shell tool to run `sleep 30` and then confirm when done." + ); + await ctx.client.waitForAgentUpsert( + created.id, + (snapshot) => snapshot.status === "running", + 15000 + ); + + const result = await ctx.client.archiveAgent(created.id); + expect(result.archivedAt).toBeTruthy(); + + const archived = await ctx.client.fetchAgent(created.id); + expect(archived).not.toBeNull(); + expect(archived?.archivedAt).toBeTruthy(); + expect(archived?.status).not.toBe("running"); + + const runningAgents = await ctx.client.fetchAgents({ + filter: { includeArchived: true, statuses: ["running"] }, + }); + expect( + runningAgents.entries.some((entry) => entry.agent.id === created.id) + ).toBe(false); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 60000); + + test("rejects send_agent_message for archived agents", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + + await ctx.client.archiveAgent(created.id); + await expect( + ctx.client.sendMessage(created.id, "Say hello and nothing else") + ).rejects.toThrow("archived"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 30000); + test("returns home-scoped directory suggestions", async () => { const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 63e0bd0f5..c87f48090 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1674,6 +1674,10 @@ export class Session { private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise { this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`) + if (this.agentManager.getAgent(agentId)) { + await this.interruptAgentIfRunning(agentId) + } + const archivedAt = new Date().toISOString() const existing = await this.agentStorage.get(agentId) @@ -1717,6 +1721,11 @@ export class Session { }) } + private async getArchivedAt(agentId: string): Promise { + const record = await this.agentStorage.get(agentId) + return record?.archivedAt ?? null + } + private async handleUpdateAgentRequest( agentId: string, name: string | undefined, @@ -2308,6 +2317,16 @@ export class Session { return } + const archivedAt = await this.getArchivedAt(agentId) + if (archivedAt) { + this.handleAgentRunError( + agentId, + new Error(`Agent ${agentId} is archived`), + 'Refusing to send prompt to archived agent' + ) + return + } + try { await this.interruptAgentIfRunning(agentId) } catch (error) { @@ -5250,6 +5269,20 @@ export class Session { try { const agentId = resolved.agentId + const archivedAt = await this.getArchivedAt(agentId) + if (archivedAt) { + this.emit({ + type: 'send_agent_message_response', + payload: { + requestId: msg.requestId, + agentId, + accepted: false, + error: `Agent ${agentId} is archived`, + }, + }) + return + } + await this.ensureAgentLoaded(agentId) await this.interruptAgentIfRunning(agentId)