Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andOuyang Xingyuan 44fdf72c8f test(server): reproduce ACP restart after failed process termination
Refs #3256 and #7472. A real ACP subprocess remains alive when the injected termination dependency returns kill-timeout. The following prompt starts a second live subprocess, violating exclusive runtime ownership. This regression test intentionally fails on the inspected contributor commit.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
2026-10-20 08:18:26 +02:00
Ouyang Xingyuan c17e7c5b64 fix(server): release a provider turn the daemon force-canceled
Cancelling a foreground turn can leave two owners of "is a turn running". The
manager force-cancels after its 2 s settle window: it synthesizes turn_canceled
and reports the agent idle. The provider session never confirmed that turn
ended, so it still holds the slot. Every later prompt then fails with "A
foreground turn is already active" until the agent is reloaded.

ACP is the reproducible case. ACPAgentSession.interrupt() sends session/cancel,
which is a notification: it resolves as soon as it is written. An agent that
answers cancelled but keeps working in the same process, or never answers at
all, leaves activeForegroundTurnId set. This is the failure observed locally on
2026-10-20 with a droid-magpie agent: five "[System Error] A foreground turn is
already active" rows and no way to send anything afterwards.

Add AgentSession.abandonForegroundTurn? as the seam for that state. The manager
calls it after a forced settlement, and only then; a cancel that settles keeps
today's path. A provider that implements it must make the session usable again
rather than clear a local flag, because reusing a runtime that may still be
working is the split-brain shape docs/agent-lifecycle.md forbids.

wrapSessionProvider forwards the hook. Without that line the manager only sees
the wrapper's fixed member list, so an optional method added to the interface is
silently dropped for every wrapped provider.

The ACP adapter kills the process that owned the abandoned turn, drops the slot,
and synthesizes cancelled tool calls for rows that were still running. Its next
startTurn resumes the durable session in a new process and drops the replayed
history rows, since the daemon already has them. A kill-timeout rejects, which
leaves the agent as it was.

Tests: a force-canceled turn releases the session and the next prompt starts, a
settled cancel does not call the hook, a rejected release still settles, the
wrapper forwards the hook or omits it, and the ACP adapter reconnects on the
next turn without replaying history twice.
2026-10-20 13:52:24 +08:00
paseo-bot[bot] ab6cdb7c89 fix(claude): return to Auto when approving a plan (#7453)
Offer Auto the same explicit resume action as Bypass and apply the saved mode before releasing plan approval. Ordinary Implement keeps selecting Accept File Edits.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 03:57:25 +02:00
paseo-bot[bot] 0d61cca50c fix(app): identify Android certificate failures as TLS errors (#7429)
Recognize Android certificate-path validation failures before the generic not-found classification, so Direct connection no longer tells users to debug DNS for an untrusted certificate. Keep the existing TLS copy and technical details.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-20 00:53:37 +02:00
paseo-bot[bot] 0eee4fd9b0 docs(schedules): explain archive-on-finish behavior (#7426)
The schedule and worktree guides omitted the existing retention control and its default. Document the app setting and clarify that CLI and MCP tools do not expose it.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 23:56:49 +02:00
19 changed files with 724 additions and 96 deletions
+9
View File
@@ -50,6 +50,15 @@ older cancellation from settling a newer turn. If interruption is rejected or ti
keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure.
Accepting new work after an ambiguous interruption would create a split-brain session.
A forced settlement leaves two owners of "is a turn running": the manager has settled the run, while
the provider session still holds the turn slot it never confirmed ending. `AgentSession.abandonForegroundTurn`
is the optional seam for that case. The manager calls it after force-canceling, and only then. A
provider that implements it must make the session usable again, not just clear a local flag: the ACP
adapter kills the process that owned the turn and drops the slot, and its next `startTurn` resumes the
durable session in a new process. Clearing the slot while reusing a runtime that may still be working
would be the split-brain shape this section forbids. Providers that omit the hook keep today's
behavior, and a rejected release is logged without undoing the settlement.
## Relationships
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous and always stamps `paseo.parent-agent-id`, pointing back at the caller. Omit `workspaceId` to use the caller's workspace, or pass an existing workspace ID returned by `create_workspace`. Placement never changes parentage.
+1 -90
View File
@@ -11,10 +11,7 @@ import {
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
};
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: DirectConnectionLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: DirectConnectionLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
const labels = {
hostRequired: "host required",
invalidPort: "invalid port",
invalidConnection: "invalid connection",
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
timedOut: "timeout",
refused: "refused",
hostNotFound: "DNS failure",
hostUnreachable: "unreachable",
tlsError: "TLS failure",
unableToConnect: "connection failure",
};
function failure(message: string) {
return buildConnectionFailureCopy({
endpoint: "tcp://example.test:443?ssl=true",
error: new DaemonConnectionTestError(message, {
reason: "transport error",
lastError: message,
}),
labels,
});
}
describe("direct connection failure copy", () => {
it("identifies Android certificate validation errors without calling them DNS failures", () => {
const message =
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
expect(failure(message)).toEqual({
title: "Failed: tcp://example.test:443?ssl=true",
detail: labels.tlsError,
raw: `transport error — ${message}`,
});
});
it.each([
["certificate not found", labels.tlsError],
["SSL handshake failed", labels.tlsError],
["TLS handshake failed", labels.tlsError],
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
["Host not found", labels.hostNotFound],
["connect ECONNREFUSED", labels.refused],
["connection timed out", labels.timedOut],
["EHOSTUNREACH", labels.hostUnreachable],
["unexpected transport failure", labels.unableToConnect],
])("classifies %s", (message, expected) => {
expect(failure(message).detail).toBe(expected);
});
it("preserves authentication failures and their technical details", () => {
const message = "Invalid daemon password";
expect(
buildConnectionFailureCopy({
endpoint: "example.test:6767",
error: new DaemonConnectionTestError(message, {
reason: message,
lastError: message,
authFailureReason: "incorrect_password",
}),
labels,
}),
).toMatchObject({ detail: message, raw: message });
});
});
@@ -0,0 +1,103 @@
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
interface ConnectionFailureLabels {
failedToConnect: (endpoint: string) => string;
noAdditionalDetails: (detail: string) => string;
timedOut: string;
refused: string;
hostNotFound: string;
hostUnreachable: string;
tlsError: string;
unableToConnect: string;
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
if (!message) return null;
const trimmed = message.trim();
if (!trimmed) return null;
return trimmed;
}
function formatTechnicalTransportDetails(
details: (string | null)[],
labels: ConnectionFailureLabels,
): string | null {
const unique = Array.from(
new Set(
details
.map((value) => normalizeTransportMessage(value))
.filter((value): value is string => Boolean(value))
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
);
if (unique.length === 0) return null;
const allGeneric = unique.every((value) => {
const lower = value.toLowerCase();
return lower === "transport error" || lower === "transport closed";
});
if (allGeneric) {
return labels.noAdditionalDetails(unique[0] ?? "");
}
return unique.join(" — ");
}
export function buildConnectionFailureCopy(input: {
endpoint: string;
error: unknown;
labels: ConnectionFailureLabels;
}): { title: string; detail: string | null; raw: string | null } {
const { endpoint, error, labels } = input;
const title = labels.failedToConnect(endpoint);
const raw = (() => {
if (error instanceof DaemonConnectionTestError) {
return (
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
normalizeTransportMessage(error.message)
);
}
if (error instanceof Error) {
return normalizeTransportMessage(error.message);
}
return null;
})();
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
rawLower.includes("econnrefused") ||
rawLower.includes("connection refused") ||
rawLower.includes("err_connection_refused")
) {
detail = labels.refused;
} else if (
rawLower.includes("certificate") ||
rawLower.includes("certpathvalidator") ||
rawLower.includes("trust anchor") ||
rawLower.includes("tls") ||
rawLower.includes("ssl")
) {
detail = labels.tlsError;
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
detail = labels.hostNotFound;
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
detail = labels.hostUnreachable;
} else {
detail = labels.unableToConnect;
}
return { title, detail, raw };
}
@@ -1364,6 +1364,7 @@ class McpCapableTestAgentClient extends TestAgentClient {
class ControlledInterruptSession extends TestAgentSession {
interruptCalled = false;
abandonCalls = 0;
constructor(
config: AgentSessionConfig,
@@ -1384,6 +1385,16 @@ class ControlledInterruptSession extends TestAgentSession {
this.interruptCalled = true;
await this.interruptBehavior(this);
}
override async abandonForegroundTurn(): Promise<void> {
this.abandonCalls += 1;
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
reason: "Interrupted",
turnId: this.turnId,
});
}
}
interface ControlledInterruptFixture {
@@ -2785,6 +2796,91 @@ test("cancelAgentRun succeeds when the provider queues completion before rejecti
}
});
test("a force-canceled foreground turn releases the provider turn it never settled", async () => {
const fixture = await createControlledInterruptFixture({
name: "interrupt-acknowledged-no-settle",
agentId: "00000000-0000-4000-8000-000000000307",
turnId: "stuck-after-cancel-turn",
// The provider answers cancel but never answers session/prompt, so the run stays unsettled
// and the manager has to force-cancel its own side of the turn.
interrupt: async () => {},
});
try {
await fixture.startForegroundRun();
expect(fixture.manager.getAgent(fixture.agentId)?.activeForegroundTurnId).toBe(
"stuck-after-cancel-turn",
);
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
status: "settled",
});
expect(fixture.session.abandonCalls).toBe(1);
expect(fixture.manager.getAgent(fixture.agentId)).toMatchObject({
lifecycle: "idle",
activeForegroundTurnId: null,
});
// The session must accept the next prompt: that is the bug this release exists for.
await fixture.startForegroundRun();
expect(fixture.manager.getAgent(fixture.agentId)?.lifecycle).toBe("running");
} finally {
await fixture.cleanup();
}
});
test("a settled cancel does not release the provider turn", async () => {
let fixture!: ControlledInterruptFixture;
fixture = await createControlledInterruptFixture({
name: "interrupt-settled",
agentId: "00000000-0000-4000-8000-000000000308",
turnId: "settled-turn",
interrupt: async (session) => {
session.pushEvent({
type: "turn_canceled",
provider: session.provider,
reason: "Interrupted",
turnId: "settled-turn",
});
},
});
try {
await fixture.startForegroundRun();
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
status: "settled",
});
expect(fixture.session.abandonCalls).toBe(0);
expect(fixture.manager.getAgent(fixture.agentId)?.lifecycle).toBe("idle");
} finally {
await fixture.cleanup();
}
});
test("a provider that cannot release the turn still settles the cancel", async () => {
const fixture = await createControlledInterruptFixture({
name: "interrupt-release-rejected",
agentId: "00000000-0000-4000-8000-000000000309",
turnId: "release-rejected-turn",
interrupt: async () => {},
});
let releaseAttempts = 0;
fixture.session.abandonForegroundTurn = async () => {
releaseAttempts += 1;
throw new Error("The provider process did not exit");
};
try {
await fixture.startForegroundRun();
await expect(fixture.manager.cancelAgentRun(fixture.agentId)).resolves.toEqual({
status: "settled",
});
expect(releaseAttempts).toBe(1);
} finally {
await fixture.cleanup();
}
});
test("listProviderAvailability uses registered client keys, including custom providers", async () => {
const customClient: AgentClient = {
provider: "zai",
@@ -3156,6 +3156,7 @@ export class AgentManager {
{ agentId, turnId: runTurnId, kind: run.kind },
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
);
await this.releaseAbandonedTurn(agent);
await this.dispatchSessionEvent(agent, {
type: "turn_canceled",
provider: agent.provider,
@@ -3168,6 +3169,7 @@ export class AgentManager {
{ agentId, kind: run.kind },
"cancelAgentRun: acknowledged pending turn still active after timeout, clearing it",
);
await this.releaseAbandonedTurn(agent);
this.runs.settleForegroundRun(agentId, run.token);
if (!agent.pendingReplacement) {
agent.lifecycle = "idle";
@@ -3194,6 +3196,26 @@ export class AgentManager {
return { status: "settled" };
}
/**
* After a forced settlement, stop reusing a runtime whose foreground turn it never confirmed
* ending. Providers that cannot reconcile omit the hook and keep today's behavior.
*
* A rejected release leaves the agent unusable, so log it and continue: the manager already
* settled the run, and refusing here would report a failure whose only cause is the provider.
*/
private async releaseAbandonedTurn(agent: ActiveManagedAgent): Promise<void> {
const abandon = agent.session.abandonForegroundTurn;
if (!abandon) return;
try {
await abandon.call(agent.session);
} catch (error) {
this.logger.warn(
{ err: error, agentId: agent.id, provider: agent.provider },
"cancelAgentRun: provider could not release the abandoned foreground turn",
);
}
}
private async cancelAgentRunBefore(
agentId: string,
action: "reload" | "replace" | "rewind",
@@ -694,6 +694,17 @@ export interface AgentSession {
* still uncertain.
*/
interrupt(): Promise<void>;
/**
* Drop ownership of a foreground turn the provider did not confirm as ended, after `interrupt()`
* was acknowledged but the run never settled. The manager calls this only when it has already
* force-canceled its own side of that run, so the daemon and the provider must not stay split.
*
* A provider that implements this must make the session usable again: release the turn slot and
* stop trusting the runtime that owned it (kill, replace, or resume it). Reject when the runtime
* cannot be released, which keeps the agent in its previous state. Omit it when the provider has
* no safe way to reconcile; the manager keeps today's behavior.
*/
abandonForegroundTurn?(): Promise<void>;
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
@@ -22,6 +22,7 @@ type OptionalAgentSessionMethodName = {
const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
"steerActiveTurn",
"abandonForegroundTurn",
"listCommands",
"setModel",
"setThinkingOption",
@@ -168,6 +169,10 @@ class FakeSession implements AgentSession {
this.recordedCalls.push("revertBoth");
}
async abandonForegroundTurn() {
this.recordedCalls.push("abandonForegroundTurn");
}
tryHandleOutOfBand(_prompt: AgentPromptInput) {
this.recordedCalls.push("tryHandleOutOfBand");
return {
@@ -190,6 +195,7 @@ describe("wrapSessionProvider", () => {
const wrapped = wrapSessionProvider("custom-claude", session);
await wrapped.steerActiveTurn?.("follow-up", { expectedTurnId: "turn-1" });
await wrapped.abandonForegroundTurn?.();
await wrapped.listCommands?.();
await wrapped.setModel?.("sonnet");
await wrapped.setThinkingOption?.("high");
@@ -205,6 +211,7 @@ describe("wrapSessionProvider", () => {
]);
expect(session.recordedCalls).toEqual([
"steerActiveTurn",
"abandonForegroundTurn",
"listCommands",
"setModel",
"setThinkingOption",
@@ -246,4 +253,10 @@ describe("wrapSessionProvider", () => {
session.steerActiveTurn = undefined;
expect(wrapSessionProvider("custom-claude", session).steerActiveTurn).toBeUndefined();
});
test("leaves abandoned-turn release unavailable when the provider has no implementation", () => {
const session: AgentSession = new FakeSession();
session.abandonForegroundTurn = undefined;
expect(wrapSessionProvider("custom-claude", session).abandonForegroundTurn).toBeUndefined();
});
});
@@ -468,6 +468,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
abandonForegroundTurn: inner.abandonForegroundTurn?.bind(inner),
close: () => inner.close(),
listCommands: inner.listCommands?.bind(inner),
setModel: inner.setModel?.bind(inner),
@@ -0,0 +1,84 @@
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import { ACPAgentSession } from "./acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
const source = `
import { appendFileSync } from 'node:fs';
import { Readable, Writable } from 'node:stream';
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
appendFileSync(process.env.PROBE_PIDS, String(process.pid) + '\\n');
new AgentSideConnection(() => ({
async initialize() { return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } }; },
async newSession() { return { sessionId: 'held-session' }; },
async loadSession() { return {}; },
async authenticate() {},
prompt() { return new Promise(() => {}); },
async cancel() {},
}), ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)));
`;
test("a failed abandoned-runtime kill must block a second provider process", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "acp-abandon-timeout-"));
const script = path.join(dir, "agent.mjs");
const pidFile = path.join(dir, "pids");
await writeFile(script, source);
let allowTermination = false;
const session = new ACPAgentSession(
{ provider: "abandon-probe", cwd: dir },
{
provider: "abandon-probe",
logger: createTestLogger(),
defaultCommand: [process.execPath, script],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
launchEnv: {
ACP_SDK_URL: pathToFileURL(
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
).href,
PROBE_PIDS: pidFile,
},
// Inject the documented termination result; the ACP subprocess itself is real.
terminateProcess: (child, options) =>
allowTermination ? terminateWithTreeKill(child, options) : Promise.resolve("kill-timeout"),
},
);
try {
await session.initializeNewSession();
await session.startTurn("first");
await expect(session.abandonForegroundTurn()).rejects.toThrow("provider process did not exit");
const firstPid = Number((await readFile(pidFile, "utf8")).trim());
expect(() => process.kill(firstPid, 0)).not.toThrow();
const next = await session.startTurn("second").then(
() => "started",
() => "refused",
);
const pids = (await readFile(pidFile, "utf8")).trim().split("\n").map(Number);
for (const pid of pids) expect(() => process.kill(pid, 0)).not.toThrow();
console.log({ next, liveProviderProcesses: pids.length });
expect(next).toBe("refused");
expect(pids).toHaveLength(1);
} finally {
allowTermination = true;
await session.close();
const pids = await readFile(pidFile, "utf8").catch(() => "");
for (const pid of pids.trim().split("\n").filter(Boolean).map(Number)) {
try {
process.kill(pid, "SIGKILL");
} catch {}
}
await rm(dir, { recursive: true, force: true });
}
});
@@ -4269,6 +4269,125 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () =
});
});
describe("ACP abandoned foreground turn", () => {
/**
* A session whose spawn is stubbed, so tests can hold a foreground turn that never settles
* while asserting what the release path does to the runtime that owned it.
*/
function makeDownturnSession(options: { terminator?: ProcessTerminator } = {}) {
const loadSession = vi.fn().mockResolvedValue({
sessionId: "session-1",
modes: null,
models: null,
configOptions: [],
});
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
class TestSession extends ACPAgentSession {
spawnedChildren = 0;
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
this.spawnedChildren += 1;
return {
child: createProbeChildStub(),
connection: {
prompt,
loadSession,
unstable_resumeSession: loadSession,
} as unknown as ClientSideConnection,
initialize: { agentCapabilities: { loadSession: true } },
} as SpawnedACPProcess;
}
}
const session = new TestSession(
{ provider: "claude-acp", cwd: "/tmp/paseo-acp-test" },
{
provider: "claude-acp",
logger: createTestLogger(),
defaultCommand: ["claude", "--acp"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
handle: { sessionId: "session-1", provider: "claude-acp" },
...(options.terminator ? { terminateProcess: options.terminator } : {}),
},
);
return { session, prompt, loadSession };
}
test("releases the turn slot and stops the runtime that never answered cancel", async () => {
const terminator = new FakeTerminator();
const { session } = makeDownturnSession({ terminator: terminator.terminate });
await session.initializeResumedSession();
await session.startTurn("first");
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeTruthy();
await session.abandonForegroundTurn();
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
expect(terminator.terminated).toHaveLength(1);
});
test("the next prompt reconnects in a new process instead of failing on the held slot", async () => {
const terminator = new FakeTerminator();
const { session } = makeDownturnSession({ terminator: terminator.terminate });
await session.initializeResumedSession();
await session.startTurn("first");
await session.abandonForegroundTurn();
await expect(session.startTurn("second")).resolves.toEqual({ turnId: expect.any(String) });
expect(terminator.terminated).toHaveLength(1);
// One spawn for the first turn, one for the resume the release forced.
expect((session as unknown as { spawnedChildren: number }).spawnedChildren).toBe(2);
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeTruthy();
});
test("does not replay history a second time when it reconnects", async () => {
const terminator = new FakeTerminator();
const { session } = makeDownturnSession({ terminator: terminator.terminate });
await session.initializeResumedSession();
const historyBefore = [];
for await (const event of session.streamHistory()) {
historyBefore.push(event);
}
await session.startTurn("first");
await session.abandonForegroundTurn();
await session.startTurn("second");
const replay = [];
for await (const event of session.streamHistory()) {
replay.push(event);
}
expect(replay).toEqual([]);
});
test("rejects when the provider process cannot be stopped", async () => {
const session = makeDownturnSession({
terminator: async () => "kill-timeout",
}).session;
await session.initializeResumedSession();
await session.startTurn("first");
await expect(session.abandonForegroundTurn()).rejects.toThrow(
"the provider process did not exit",
);
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
});
test("is a no-op on a session that has no id yet", async () => {
const session = makeDownturnSession().session;
await expect(session.abandonForegroundTurn()).resolves.toBeUndefined();
});
});
const SILENT_CLOSE_ACP_AGENT = `
import { readFileSync, writeFileSync } from "node:fs";
import { Readable, Writable } from "node:stream";
@@ -1734,7 +1734,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly toolCalls = new Map<string, ACPToolSnapshot>();
private readonly terminalEntries = new Map<string, TerminalEntry>();
private readonly persistedHistory: AgentTimelineItem[] = [];
private readonly initialHandle?: AgentPersistenceHandle;
private initialHandle?: AgentPersistenceHandle;
private readonly resumePurpose: AgentResumePurpose;
private readonly config: AgentSessionConfig;
@@ -1927,6 +1927,33 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.settleCommandsReady();
}
/**
* Resume the durable session in a fresh process after `abandonForegroundTurn()` killed the
* runtime that owned the abandoned turn.
*
* The replay that `initializeResumedSession()` drains is duplicate history: the daemon already
* has every row up to the abandoned turn. Drop the replayed rows and keep the prior
* `historyPending` flag so the timeline is not appended twice.
*/
private async reconnectAbandonedRuntime(): Promise<void> {
if (this.connection || !this.sessionId || this.closed) {
return;
}
// A session created in this process never received a resume handle; its durable id is the
// handle, the same object `describePersistence()` publishes.
this.initialHandle ??= {
provider: this.provider,
sessionId: this.sessionId,
nativeHandle: this.sessionId,
};
const historyLength = this.persistedHistory.length;
const historyPending = this.historyPending;
await this.initializeResumedSession();
this.persistedHistory.length = historyLength;
this.historyPending = historyPending;
this.bootstrapThreadEventPending = false;
}
private async closeAfterInitializationFailure(error: unknown): Promise<never> {
try {
await this.close();
@@ -1963,6 +1990,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.closed) {
throw new Error(`${this.provider} session is closed`);
}
await this.reconnectAbandonedRuntime();
if (!this.connection || !this.sessionId) {
throw new Error(`${this.provider} session is not initialized`);
}
@@ -2554,6 +2582,46 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
/**
* ACP turns end when `session/prompt` returns. The manager force-cancels after two seconds
* without that answer, so an agent that keeps working after `session/cancel` leaves this
* session's turn slot held. The next `startTurn` would then throw "A foreground turn is
* already active" on every later prompt, because the daemon believes the agent is idle.
*
* The runtime that still owns that turn cannot be trusted, so this kills it and drops the
* slot. The next prompt respawns through `initializeResumedSession()`; `startTurn` also
* reconnects when it finds no connection, which covers the very next message.
*/
async abandonForegroundTurn(): Promise<void> {
if (!this.sessionId) {
return;
}
this.synthesizeCanceledToolCalls();
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
this.submittedUserMessageTurnId = null;
const child = this.child;
this.connection = null;
this.child = null;
// A command from createTerminal is a peer of the agent process. The dead connection can no
// longer release it, so stop those terminals here as well.
const terminations = Array.from(this.terminalEntries.values(), (terminal) =>
this.terminateProcess(terminal.child, { gracefulTimeoutMs: 200, forceTimeoutMs: 500 }),
);
this.terminalEntries.clear();
if (child) {
terminations.push(
this.terminateProcess(child, { gracefulTimeoutMs: 200, forceTimeoutMs: 500 }),
);
}
const results = await Promise.all(terminations);
if (results.includes("kill-timeout")) {
throw new Error("ACP abandoned turn: the provider process did not exit");
}
}
async close(): Promise<void> {
if (this.closed) {
return;
@@ -0,0 +1,92 @@
import { expect, test } from "vitest";
import type { CanUseTool, PermissionMode, Query } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentPermissionRequest } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
// The injected SDK adapter supplies requests through Claude's permission callback.
async function createPlanApproval(mode: PermissionMode) {
let requestPermission: CanUseTool | undefined;
let sdkMode: PermissionMode = mode;
const query = Object.assign((async function* () {})(), {
async setPermissionMode(next: PermissionMode) {
sdkMode = next;
},
close() {},
});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude",
queryFactory: ({ options }) => {
requestPermission = options.canUseTool;
return query as Query;
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: mode,
});
let request: AgentPermissionRequest | undefined;
session.subscribe((event) => {
if (event.type === "permission_requested") request = event.request;
});
await session.setMode("plan");
if (!requestPermission) throw new Error("SDK permission callback was not installed");
const resolution = requestPermission(
"ExitPlanMode",
{ plan: "Implement the plan" },
{
signal: new AbortController().signal,
toolUseID: "plan-approval",
},
);
if (!request) throw new Error("No plan approval request was emitted");
return { session, request, resolution, sdkMode: () => sdkMode };
}
test.each([
["auto", "Implement with Auto mode"],
["bypassPermissions", "Implement with Bypass"],
] as const)("plan approval offers an explicit return to %s", async (mode, label) => {
const plan = await createPlanApproval(mode);
try {
expect(plan.request.actions).toContainEqual(
expect.objectContaining({
id: "implement_resume",
label,
behavior: "allow",
intent: "implement_resume",
}),
);
} finally {
await plan.session.respondToPermission(plan.request.id, { behavior: "deny" });
await plan.resolution;
await plan.session.close();
}
});
test.each([
["auto", "implement_resume", "auto"],
["bypassPermissions", "implement_resume", "bypassPermissions"],
["auto", "implement", "acceptEdits"],
["bypassPermissions", "implement", "acceptEdits"],
["default", "implement_resume", "acceptEdits"],
["acceptEdits", "implement_resume", "acceptEdits"],
] as const)("approving from %s with %s enters %s", async (mode, action, expected) => {
const plan = await createPlanApproval(mode);
try {
await plan.session.respondToPermission(plan.request.id, {
behavior: "allow",
selectedActionId: action,
});
await expect(plan.resolution).resolves.toMatchObject({ behavior: "allow" });
expect(plan.sdkMode()).toBe(expected);
expect(await plan.session.getCurrentMode()).toBe(expected);
if (mode === "default" || mode === "acceptEdits") {
expect(plan.request.actions?.some((entry) => entry.id === "implement_resume")).toBe(false);
}
} finally {
await plan.session.close();
}
});
@@ -1172,7 +1172,7 @@ function buildClaudePlanPermissionActions(
},
];
if (resumeMode === "bypassPermissions") {
if (resumeMode === "bypassPermissions" || resumeMode === "auto") {
actions.push({
id: "implement_resume",
label: `Implement with ${getClaudeModeLabel(resumeMode)}`,
@@ -2813,11 +2813,11 @@ class ClaudeAgentSession implements AgentSession {
if (response.behavior === "allow") {
if (pending.request.kind === "plan") {
const selectedActionId = response.selectedActionId;
const resumeMode = this.planResumeMode;
const shouldResumePriorMode =
selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions";
const targetMode: PermissionMode = shouldResumePriorMode
? "bypassPermissions"
: "acceptEdits";
selectedActionId === "implement_resume" &&
(resumeMode === "bypassPermissions" || resumeMode === "auto");
const targetMode: PermissionMode = shouldResumePriorMode ? resumeMode : "acceptEdits";
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
@@ -0,0 +1,16 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
const docsRoot = path.resolve(import.meta.dirname, "../../../public-docs");
it.each(["schedules.md", "schedules-cli.md", "mcp.md", "worktrees.md"])(
"%s makes schedule run retention discoverable",
async (file) => {
const markdown = await readFile(path.join(docsRoot, file), "utf8");
expect(markdown).toContain("Archive on finish");
if (file !== "schedules.md") {
expect(markdown).toContain("/docs/schedules#keep-run-workspaces");
}
},
);
+2
View File
@@ -155,6 +155,8 @@ Both use the same cron engine, but they have deliberately different interfaces.
MCP heartbeats are ephemeral: create or delete them. To change one, delete it and create a replacement. Pause, resume, update, inspect, logs, and run-once apply to new-agent schedules only.
New-agent run workspaces are archived when each run ends by default. `create_schedule` and `update_schedule` do not expose **Archive on finish** (`archiveOnFinish`); [change the option in the app](/docs/schedules#keep-run-workspaces) to keep subsequent run workspaces active.
### Agent profiles
| Tool | Function |
+2
View File
@@ -10,6 +10,8 @@ category: Schedules
`paseo schedule` creates and manages new-agent [schedules](/docs/schedules) from your terminal, useful for headless boxes and scripts. Every run starts a fresh agent.
Run workspaces are archived when each run ends by default. The CLI has no **Archive on finish** flag; [change the option in the app](/docs/schedules#keep-run-workspaces) to keep subsequent run workspaces active.
## Create
Overnight refactor on Codex:
+8
View File
@@ -35,3 +35,11 @@ Both run on a cadence you set. To start an agent from an external event instead
- **[From chat](/docs/schedules-chat)** — ask the agent in a chat and it sets the schedule up for you.
- **[From the CLI](/docs/schedules-cli)** — `paseo schedule create`, for headless boxes and scripts.
- **[Over MCP](/docs/mcp)** — agents create and manage schedules programmatically.
## Keep run workspaces
Each new-agent schedule run creates a workspace. **Archive on finish** is on by default: when the run ends, Paseo archives that workspace and its agent. With worktree isolation, archiving also removes the worktree after its last active workspace is archived.
To keep runs available, open the schedule in the app's Schedules view, turn off **Archive on finish**, and save. Paseo keeps each run's workspace and agent active until you archive them; runs that fail before creating an agent are still cleaned up. This option also applies to local isolation; archiving a local workspace leaves the project directory in place.
The stored option is `target.config.archiveOnFinish`, a boolean that defaults to `true` when omitted. The CLI and MCP schedule tools do not expose this option; use the app to change it. Heartbeats use an existing agent and do not have this option.
+2
View File
@@ -40,6 +40,8 @@ With a custom root, Paseo keeps the same hashed layout under that directory:
3. Review the diff against the base branch
4. Merge or archive the workspace; after the last workspace using it is archived, Paseo runs teardown and removes the worktree
Scheduled runs archive their workspaces when they finish by default. Turn off **Archive on finish** in the schedule's app form to [keep run workspaces](/docs/schedules#keep-run-workspaces) and their worktrees until you archive them.
## Create a worktree-backed workspace
The examples below use the current directory as the source checkout. Pass `--path ~/dev/my-app` to create the workspace from another checkout.