Merge main into project migration branch

This commit is contained in:
Mohamed Boudra
2026-07-19 00:28:33 +02:00
407 changed files with 31578 additions and 9380 deletions
@@ -20,7 +20,8 @@ export type AgentLoaderManager = Pick<
| "getRegisteredProviderIds"
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
>;
> &
Partial<Pick<AgentManager, "touchAgentActivity" | "waitForAgentClose">>;
export interface EnsureAgentLoadedDeps {
agentManager: AgentLoaderManager;
@@ -29,15 +30,45 @@ export interface EnsureAgentLoadedDeps {
logger: Logger;
}
export async function ensureUnarchivedAgentLoaded(
agentId: string,
deps: EnsureAgentLoadedDeps & {
agentManager: AgentLoaderManager & Pick<AgentManager, "closeAgent">;
},
): Promise<ManagedAgent> {
const record = await deps.agentStorage.get(agentId);
if (record?.archivedAt) {
throw new Error(`Agent is archived: ${agentId}`);
}
const agent = await ensureAgentLoaded(agentId, deps);
const latestRecord = await deps.agentStorage.get(agentId);
if (latestRecord?.archivedAt) {
await deps.agentManager.closeAgent(agentId).catch((error: unknown) => {
deps.logger.warn({ err: error, agentId }, "Failed to close concurrently archived agent");
});
throw new Error(`Agent is archived: ${agentId}`);
}
return agent;
}
export async function ensureAgentLoaded(
agentId: string,
deps: EnsureAgentLoadedDeps,
): Promise<ManagedAgent> {
const existing = deps.agentManager.getAgent(agentId);
await deps.agentManager.waitForAgentClose?.(agentId);
const existing =
deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId);
if (existing) {
return existing;
}
// A close may have started after the first barrier observed no in-flight
// work. Once the live lookup is empty, this second barrier closes that gap
// before storage-backed resume begins.
await deps.agentManager.waitForAgentClose?.(agentId);
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
return inflight;
@@ -75,6 +106,7 @@ export async function ensureAgentLoaded(
snapshot = await deps.agentManager.createAgent(config, agentId, {
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
@@ -17,6 +17,7 @@ import { AgentStorage } from "./agent-storage.js";
import { toAgentPayload } from "./agent-projections.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
import type { StoredAgentRecord } from "./agent-storage.js";
import type {
AgentClient,
@@ -34,6 +35,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
ImportProviderSessionInput,
ResolveAgentDefaultModeInput,
} from "./agent-sdk-types.js";
import type { PaseoToolCatalog } from "./tools/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
@@ -110,11 +112,15 @@ function expectArchivedAgentRecord(
}
class TestAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly provider: AgentProvider;
readonly capabilities = TEST_CAPABILITIES;
readonly createdConfigs: AgentSessionConfig[] = [];
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
constructor(provider: AgentProvider = "codex") {
this.provider = provider;
}
async isAvailable(): Promise<boolean> {
return true;
}
@@ -128,18 +134,18 @@ class TestAgentClient implements AgentClient {
return {
models: [
{
provider: "codex",
provider: this.provider,
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
},
{
provider: "codex",
provider: this.provider,
id: "gpt-5.4-mini",
label: "GPT-5.4 Mini",
},
{
provider: "codex",
provider: this.provider,
id: "gpt-5.2-codex",
label: "GPT-5.2 Codex",
},
@@ -155,7 +161,7 @@ class TestAgentClient implements AgentClient {
): Promise<AgentSession> {
this.resumeOverrides.push(config);
return new TestAgentSession({
provider: "codex",
provider: this.provider,
cwd: config?.cwd ?? process.cwd(),
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
});
@@ -934,9 +940,43 @@ test("normalizeConfig injects the provider default model when omitted", async ()
);
expect(snapshot.config.model).toBe("gpt-5.4");
expect(snapshot.config.modeId).toBe("auto-review");
});
test("normalizeConfig injects Claude's automatic approval default when omitted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-claude-default-test-"));
const manager = new AgentManager({
clients: { claude: new TestAgentClient("claude") },
logger,
});
const snapshot = await manager.createAgent({ provider: "claude", cwd: workdir }, undefined, {
workspaceId: undefined,
});
expect(snapshot.config.modeId).toBe("auto");
});
test("normalizeConfig uses a capability-aware provider mode default", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mode-default-test-"));
class CapabilityAwareClient extends TestAgentClient {
override async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise<string> {
return input.env?.CLAUDE_CODE_USE_BEDROCK === "1" ? "default" : "auto";
}
}
const manager = new AgentManager({
clients: { codex: new CapabilityAwareClient() },
logger,
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
});
expect(snapshot.config.modeId).toBe("default");
});
test("createAgent forwards request env into the spawned provider process", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-env-test-"));
const client = new EnvProbeAgentClient();
@@ -996,7 +1036,7 @@ test("normalizeConfig strips legacy 'default' model id", async () => {
);
expect(snapshot.config.model).toBe("gpt-5.4");
expect(snapshot.config.modeId).toBe("auto");
expect(snapshot.config.modeId).toBe("auto-review");
});
test("listDraftCommands returns no commands without guessing a missing model", async () => {
@@ -1093,7 +1133,7 @@ test("listDraftCommands uses explicit model config without default model fetchin
provider: "codex",
cwd: workdir,
model: "gpt-5.4",
modeId: "auto",
modeId: "auto-review",
},
]);
});
@@ -1193,7 +1233,7 @@ test("listDraftFeatures uses explicit model config without default model fetchin
provider: "codex",
cwd: workdir,
model: "gpt-5.4",
modeId: "auto",
modeId: "auto-review",
},
]);
});
@@ -1650,7 +1690,7 @@ test("createAgent passes daemon launch env through the provider launch context",
provider: "codex",
cwd: workdir,
model: "gpt-5.4",
modeId: "auto",
modeId: "auto-review",
});
expect(client.lastLaunchContext).toEqual({
agentId: snapshot.id,
@@ -2447,7 +2487,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
});
expect(client.lastResumeOverrides).toMatchObject({
model: "gpt-5.4",
modeId: "auto",
modeId: "auto-review",
systemPrompt: "new prompt",
mcpServers: {
paseo: {
@@ -7159,6 +7199,464 @@ test("closeAgent persists one final closed snapshot", async () => {
}
});
test("collectIdleAgents releases an idle runtime and resumes the same agent and timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-collection-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let activeSession: TestAgentSession | null = null;
const client = new (class extends NativeArchiveRecordingClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
activeSession = new TestAgentSession(config);
return activeSession;
}
})();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000210",
});
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "workspace-idle-collection",
});
await manager.appendTimelineItem(created.id, {
type: "user_message",
text: "Keep this timeline",
});
activeSession?.pushEvent({
type: "provider_subagent",
provider: "codex",
event: {
type: "upsert",
id: "retained-provider-child",
title: "Retained provider child",
status: "completed",
},
});
await manager.flush();
const timelineBeforeCollection = manager.getTimeline(created.id);
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection).toEqual({
collected: [
{
agentId: created.id,
provider: "codex",
sessionId: created.persistence?.sessionId,
},
],
failures: [],
});
expect(manager.getAgent(created.id)).toBeNull();
expect(client.archivedHandles).toEqual([]);
const stored = await storage.get(created.id);
expect(stored).toMatchObject({
id: created.id,
lastStatus: "closed",
workspaceId: "workspace-idle-collection",
});
expect(stored?.archivedAt).toBeFalsy();
const resumed = await ensureAgentLoaded(created.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(resumed.id).toBe(created.id);
expect(resumed.persistence).toEqual(created.persistence);
expect(manager.getTimeline(created.id)).toEqual(timelineBeforeCollection);
expect(manager.listProviderSubagents(created.id)).toEqual([
expect.objectContaining({
id: "retained-provider-child",
title: "Retained provider child",
status: "completed",
}),
]);
const idleBeforeOpen = resumed.updatedAt;
await ensureAgentLoaded(created.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await expect(
manager.collectIdleAgents({ cutoff: idleBeforeOpen, protectedAgentIds: new Set() }),
).resolves.toMatchObject({ collected: [] });
await expect(manager.runAgent(created.id, "Continue the same agent")).resolves.toMatchObject({
finalText: "",
canceled: false,
});
expect(manager.getAgent(created.id)?.id).toBe(created.id);
} finally {
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("archiving an idle-collected parent still cascades to its managed children", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-collected-parent-archive-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
try {
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Collected parent" },
undefined,
{ workspaceId: undefined },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Managed child" },
undefined,
{
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
workspaceId: undefined,
},
);
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set([child.id]),
});
await manager.archiveSnapshot(parent.id, new Date().toISOString());
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
expect((await storage.get(child.id))?.archivedAt).toEqual(expect.any(String));
expect(manager.getAgent(child.id)).toBeNull();
} finally {
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-load-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await manager.archiveSnapshot(agent.id, new Date().toISOString());
await expect(
ensureUnarchivedAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
}),
).rejects.toThrow(`Agent is archived: ${agent.id}`);
expect(manager.getAgent(agent.id)).toBeNull();
} finally {
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const resumeStarted = deferred<void>();
const resumeAllowed = deferred<void>();
const client = new (class extends TestAgentClient {
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
resumeStarted.resolve();
await resumeAllowed.promise;
return super.resumeSession(handle, config, launchContext);
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
const load = ensureUnarchivedAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await resumeStarted.promise;
await manager.archiveSnapshot(agent.id, new Date().toISOString());
resumeAllowed.resolve();
await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`);
expect(manager.getAgent(agent.id)).toBeNull();
expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String));
} finally {
resumeAllowed.resolve();
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const resumeStarted = deferred<void>();
const resumeAllowed = deferred<void>();
const client = new (class extends TestAgentClient {
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
resumeStarted.resolve();
await resumeAllowed.promise;
return super.resumeSession(handle, config, launchContext);
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
const sharedLoad = ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await resumeStarted.promise;
const protectedLoad = ensureUnarchivedAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await manager.archiveSnapshot(agent.id, new Date().toISOString());
resumeAllowed.resolve();
await sharedLoad;
await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`);
expect(manager.getAgent(agent.id)).toBeNull();
expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String));
} finally {
resumeAllowed.resolve();
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-"));
const client = new (class extends TestAgentClient {
readonly sessions: TestAgentSession[] = [];
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new TestAgentSession(config);
this.sessions.push(session);
return session;
}
})();
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("load waits for an in-flight collection close and creates only one resumed runtime", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-close-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const closeStarted = deferred<void>();
const closeAllowed = deferred<void>();
const client = new (class extends TestAgentClient {
resumeCount = 0;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new (class extends TestAgentSession {
override async close(): Promise<void> {
closeStarted.resolve();
await closeAllowed.promise;
}
})(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumeCount += 1;
return super.resumeSession(handle, config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir },
"00000000-0000-4000-8000-000000000216",
{ workspaceId: undefined },
);
const collection = manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await closeStarted.promise;
const loads = Promise.all([
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
]);
expect(client.resumeCount).toBe(0);
closeAllowed.resolve();
const [first, second] = await loads;
await collection;
expect(first.id).toBe(created.id);
expect(second.id).toBe(created.id);
expect(client.resumeCount).toBe(1);
} finally {
await manager.closeAgent("00000000-0000-4000-8000-000000000216").catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("provider close failure still persists and emits a resumable closed agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new (class extends TestAgentSession {
override async close(): Promise<void> {
throw new Error("provider cleanup failed");
}
})(config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
try {
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir },
"00000000-0000-4000-8000-000000000217",
{ workspaceId: undefined },
);
const closed = waitForAgentLifecycle(manager, created.id, "closed");
const collection = await manager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
await closed;
expect(collection.collected).toEqual([]);
expect(collection.failures).toHaveLength(1);
expect(collection.failures[0]).toMatchObject({
agentId: created.id,
provider: "codex",
error: expect.objectContaining({ message: "provider cleanup failed" }),
});
const stored = await storage.get(created.id);
expect(stored).toMatchObject({ lastStatus: "closed" });
expect(stored?.archivedAt).toBeFalsy();
await expect(
ensureAgentLoaded(created.id, { agentManager: manager, agentStorage: storage, logger }),
).resolves.toMatchObject({ id: created.id, lifecycle: "idle" });
} finally {
await manager.closeAgent("00000000-0000-4000-8000-000000000217").catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("hydrateTimeline keeps provider user_message items when no canonical user history exists", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-"));
const storagePath = join(workdir, "agents");
+186 -20
View File
@@ -44,6 +44,7 @@ import {
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js";
import {
InMemoryAgentTimelineStore,
type SeedAgentTimelineOptions,
@@ -116,6 +117,7 @@ interface PreparedSessionConfig {
interface NormalizeConfigOptions {
resolveDefaultModel?: boolean;
env?: Record<string, string>;
}
interface TimeoutOptions {
@@ -232,6 +234,7 @@ export interface CreateAgentOptions {
initialTitle?: string | null;
// undefined is an explicit decision: the agent never appears in the sidebar.
workspaceId: string | undefined;
owner?: AgentOwner;
}
export interface AgentManagerOptions {
@@ -306,6 +309,7 @@ interface ManagedAgentBase {
* Null/undefined for legacy agents created before ownership stamping.
*/
workspaceId?: string;
owner?: AgentOwner;
capabilities: AgentCapabilityFlags;
config: AgentSessionConfig;
runtimeInfo?: AgentRuntimeInfo;
@@ -388,6 +392,21 @@ export interface AgentMetricsSnapshot {
};
}
export interface IdleAgentCollectionEntry {
agentId: string;
provider: AgentProvider;
sessionId?: string;
}
export interface IdleAgentCollectionFailure extends IdleAgentCollectionEntry {
error: unknown;
}
export interface IdleAgentCollectionResult {
collected: IdleAgentCollectionEntry[];
failures: IdleAgentCollectionFailure[];
}
type ActiveManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
@@ -565,6 +584,7 @@ export class AgentManager {
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
@@ -780,6 +800,10 @@ export class AgentManager {
};
}
subscriptionCount(): number {
return this.subscribers.size;
}
listAgents(): ManagedAgent[] {
return Array.from(this.agents.values())
.filter((agent) => !agent.internal)
@@ -942,6 +966,19 @@ export class AgentManager {
return agent ? { ...agent } : null;
}
touchAgentActivity(id: string): ManagedAgent | null {
const agent = this.agents?.get(id);
if (!agent) {
return null;
}
this.touchUpdatedAt(agent);
return { ...agent };
}
async waitForAgentClose(agentId: string): Promise<void> {
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
}
getTimeline(id: string): AgentTimelineItem[] {
this.requireAgent(id);
return this.timelineStore.getItems(id);
@@ -997,7 +1034,12 @@ export class AgentManager {
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId);
await this.deleteAgentState(resolvedAgentId);
const { storedConfig, launchConfig } = await this.prepareSessionConfig(
config,
resolvedAgentId,
options?.env,
);
this.requireEnabledProvider(storedConfig.provider);
const client = await this.requireAvailableClient({
provider: storedConfig.provider,
@@ -1010,6 +1052,7 @@ export class AgentManager {
labels: options.labels,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
});
}
@@ -1033,6 +1076,7 @@ export class AgentManager {
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
},
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(
@@ -1050,6 +1094,7 @@ export class AgentManager {
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
},
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
@@ -1078,7 +1123,10 @@ export class AgentManager {
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = await client.resumeSession(handle, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, options);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
persistence: handle,
});
}
importProviderSession(input: {
@@ -1229,6 +1277,7 @@ export class AgentManager {
return this.registerSession(session, storedConfig, agentId, {
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
@@ -1298,7 +1347,24 @@ export class AgentManager {
}
}
async closeAgent(agentId: string): Promise<void> {
closeAgent(agentId: string): Promise<void> {
const existing = this.inFlightAgentCloses.get(agentId);
if (existing) {
return existing;
}
const close = this.closeAgentRuntime(agentId);
this.inFlightAgentCloses.set(agentId, close);
const clearClose = () => {
if (this.inFlightAgentCloses.get(agentId) === close) {
this.inFlightAgentCloses.delete(agentId);
}
};
void close.then(clearClose, clearClose);
return close;
}
private async closeAgentRuntime(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId);
this.logger.trace(
{
@@ -1313,12 +1379,19 @@ export class AgentManager {
"agent.manager.close.start",
);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
await agent.session.close();
this.timelineStore.delete(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
let closeError: unknown;
try {
await agent.session.close();
} catch (error) {
closeError = error;
}
let persistError: unknown;
try {
await this.persistSnapshot(closedAgent);
} catch (error) {
persistError = error;
}
await this.persistSnapshot(closedAgent);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
{
@@ -1328,6 +1401,58 @@ export class AgentManager {
},
"agent.manager.close.complete",
);
if (closeError !== undefined) {
throw closeError;
}
if (persistError !== undefined) {
throw persistError;
}
}
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
);
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
@@ -1347,6 +1472,7 @@ export class AgentManager {
const { archivedAt } = await this.markRecordArchived(stored);
agent.updatedAt = new Date(archivedAt);
await this.closeAgent(agentId);
this.discardRetainedAgentState(agentId);
await this.cascadeArchiveChildren(agentId);
@@ -1373,8 +1499,7 @@ export class AgentManager {
if (this.agents.has(record.id)) {
await this.archiveAgent(record.id);
} else {
await this.markRecordArchived(record);
await this.cascadeArchiveChildren(record.id);
await this.archiveSnapshot(record.id, new Date().toISOString());
}
}
}
@@ -1420,6 +1545,7 @@ export class AgentManager {
provider: record.provider,
cwd: record.cwd,
workspaceId: record.workspaceId,
owner: record.owner,
session: null,
capabilities: STORED_AGENT_CAPABILITIES,
config: buildStoredAgentConfig(record),
@@ -1660,11 +1786,15 @@ export class AgentManager {
if (this.agents.has(agentId)) {
this.notifyAgentState(agentId);
} else if (!nextRecord.internal) {
this.dispatchArchivedStoredAgent(nextRecord);
} else {
this.discardRetainedAgentState(agentId);
if (!nextRecord.internal) {
this.dispatchArchivedStoredAgent(nextRecord);
}
}
await this.fireAgentArchived(agentId);
await this.cascadeArchiveChildren(agentId);
return nextRecord;
}
@@ -2315,6 +2445,11 @@ export class AgentManager {
await this.durableTimelineStore.deleteAgent(agentId);
}
async deleteAgentState(agentId: string): Promise<void> {
this.discardRetainedAgentState(agentId);
await this.deleteCommittedTimeline(agentId);
}
async getLastAssistantMessage(agentId: string): Promise<string | null> {
const agent = this.agents.get(agentId);
if (!agent) {
@@ -2574,6 +2709,7 @@ export class AgentManager {
initialTitle?: string | null;
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
},
): Promise<ManagedAgent> {
let registered = false;
@@ -2623,6 +2759,7 @@ export class AgentManager {
await this.refreshSessionState(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
managed.lifecycle = "idle";
this.touchUpdatedAt(managed);
await this.persistSnapshot(managed);
this.assertAgentRegistrationActive(managed);
this.emitState(managed, { persist: false });
@@ -2671,6 +2808,7 @@ export class AgentManager {
| undefined;
}): Promise<{ durableTimelineHasRows: boolean }> {
const { agentId, now, options } = params;
const timelineAlreadyPrimed = this.timelineStore.has(agentId);
const explicitTimelineSeed = buildExplicitTimelineSeedForRegister(now, options);
const shouldSeedFromDurable =
!explicitTimelineSeed &&
@@ -2680,7 +2818,8 @@ export class AgentManager {
? await this.loadCommittedTimelineSeed(agentId, now)
: null;
const durableTimelineHasRows =
durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1;
timelineAlreadyPrimed ||
(durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1);
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
if (timelineSeed || !this.timelineStore.has(agentId)) {
this.timelineStore.initialize(agentId, timelineSeed ?? { timestamp: now.toISOString() });
@@ -2709,6 +2848,7 @@ export class AgentManager {
attention?: AttentionState;
persistence?: AgentPersistenceHandle;
workspaceId?: string;
owner?: AgentOwner;
}
| undefined;
}): ActiveManagedAgent {
@@ -2718,6 +2858,7 @@ export class AgentManager {
provider: config.provider,
cwd: config.cwd,
workspaceId: options?.workspaceId,
owner: options?.owner,
session,
capabilities: session.capabilities,
config,
@@ -2786,9 +2927,23 @@ export class AgentManager {
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
};
}
private discardRetainedAgentState(agentId: string): void {
this.timelineStore.delete(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
}
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
this.emitState(agent, options);
}
@@ -3925,17 +4080,27 @@ export class AgentManager {
}
if (!normalized.modeId) {
try {
normalized.modeId =
getAgentProviderDefinition(normalized.provider).defaultModeId ?? undefined;
} catch {
// Unknown provider
}
normalized.modeId = await this.resolveDefaultModeId(normalized, options.env);
}
return normalized;
}
private async resolveDefaultModeId(
config: AgentSessionConfig,
env?: Record<string, string>,
): Promise<string | undefined> {
const providerDefault = await this.clients
.get(config.provider)
?.resolveDefaultModeId?.({ config, env });
if (providerDefault) return providerDefault;
try {
return getAgentProviderDefinition(config.provider).defaultModeId ?? undefined;
} catch {
return undefined;
}
}
private async resolveDefaultModelId(config: AgentSessionConfig): Promise<string | undefined> {
const client = this.clients.get(config.provider);
if (!client) {
@@ -3957,8 +4122,9 @@ export class AgentManager {
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
env?: Record<string, string>,
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config));
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env });
const launchConfig = this.applyDaemonAppendSystemPrompt(
withRuntimePaseoMcpServer({
config: storedConfig,
@@ -0,0 +1,16 @@
import { z } from "zod";
export const AgentOwnerSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("daemon"),
daemonId: z.string(),
executionId: z.string(),
}),
]);
export type AgentOwner = z.infer<typeof AgentOwnerSchema>;
export type DaemonAgentOwner = Extract<AgentOwner, { kind: "daemon" }>;
export function daemonExecutionKey(owner: DaemonAgentOwner): string {
return `${owner.daemonId}\0${owner.executionId}`;
}
@@ -93,6 +93,7 @@ export function toStoredAgentRecord(
? agent.attention.attentionTimestamp.toISOString()
: null,
internal: options?.internal,
owner: agent.owner,
} satisfies StoredAgentRecord;
}
@@ -38,13 +38,17 @@ function createCapturedLogger(): CapturedLogger {
interface FinishNotificationScenarioOptions {
childLastAssistantMessage?: string | null;
childParentAgentId?: string | null;
requireParentOwnership?: boolean;
parentPromptError?: Error;
logger?: Logger;
}
interface FinishNotificationScenario {
startWatchingChild(): void;
finishChild(): void;
finishChildAndReadParentPrompt(): Promise<string>;
wasParentPrompted(): boolean;
}
function createFinishNotificationScenario(
@@ -52,6 +56,7 @@ function createFinishNotificationScenario(
): FinishNotificationScenario {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
let resolveParentPrompt: ((prompt: string) => void) | null = null;
let parentPrompted = false;
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent");
@@ -85,6 +90,7 @@ function createFinishNotificationScenario(
Reflect.set(agentManager, "tryRunOutOfBand", () => false);
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
parentPrompted = true;
resolveParentPrompt?.(prompt);
return (async function* noop() {})();
});
@@ -96,7 +102,12 @@ function createFinishNotificationScenario(
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
Reflect.set(agentStorage, "get", async (agentId: string) => {
if (agentId === "child-agent") {
return { title: "Child Agent" };
const parentAgentId =
options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId;
return {
title: "Child Agent",
labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {},
};
}
return null;
});
@@ -108,14 +119,11 @@ function createFinishNotificationScenario(
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
requireParentOwnership: options?.requireParentOwnership,
logger: options?.logger ?? createTestLogger(),
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
finishChild() {
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
@@ -127,9 +135,18 @@ function createFinishNotificationScenario(
type: "agent_state",
agent: childAgent,
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
this.finishChild();
return parentPrompt;
},
wasParentPrompted() {
return parentPrompted;
},
};
}
@@ -192,6 +209,26 @@ test("finish notifications tell the parent the child's last assistant message",
);
});
test("detaching a child ends its parent-owned finish notification", async () => {
const scenario = createFinishNotificationScenario({
childParentAgentId: null,
requireParentOwnership: true,
});
scenario.startWatchingChild();
scenario.finishChild();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(scenario.wasParentPrompted()).toBe(false);
});
test("follow-up finish notifications do not require a parent relationship", async () => {
const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" });
scenario.startWatchingChild();
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished.");
});
test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => {
const captured = createCapturedLogger();
const scenario = createFinishNotificationScenario({
@@ -4,6 +4,7 @@ import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
@@ -244,6 +245,7 @@ export interface SetupFinishNotificationParams {
agentStorage: AgentStorage;
childAgentId: string;
callerAgentId: string;
requireParentOwnership?: boolean;
logger: Logger;
}
@@ -264,7 +266,14 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
}
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params;
const {
agentManager,
agentStorage,
childAgentId,
callerAgentId,
requireParentOwnership = false,
logger,
} = params;
let hasSeenRunning = false;
let fired = false;
let unsubscribe: (() => void) | null = null;
@@ -282,6 +291,9 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
}
const record = await agentStorage.get(childAgentId);
if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) {
return;
}
const title = record?.title ?? childAgentId;
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
const body = formatFinishNotificationBody({
@@ -382,6 +382,8 @@ export async function generateStructuredAgentResponse<T>(
await manager.closeAgent(agent.id);
} catch {
// ignore cleanup errors
} finally {
await manager.deleteAgentState(agent.id).catch(() => undefined);
}
}
}
@@ -630,6 +630,7 @@ export interface AgentSession {
): Promise<AgentPermissionResult | void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
@@ -667,6 +668,12 @@ export type FetchCatalogOptions =
export interface ProviderCatalog {
models: AgentModelDefinition[];
modes: AgentMode[];
defaultModeId?: string | null;
}
export interface ResolveAgentDefaultModeInput {
config: AgentSessionConfig;
env?: Record<string, string>;
}
export interface AgentClient {
@@ -689,6 +696,7 @@ export interface AgentClient {
* The registry is responsible for merging configured model overrides.
*/
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>;
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
@@ -707,12 +715,12 @@ export interface AgentClient {
isAvailable(): Promise<boolean>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
/**
* Archive a persisted session in the native provider (best-effort).
* Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close().
* Called when Paseo archives an agent so the provider's own UI reflects the same state.
*/
archiveNativeSession?(handle: AgentPersistenceHandle): Promise<void>;
/**
* Unarchive a persisted session in the native provider.
* Unarchive a durable native session in the provider.
* Called before Paseo clears its archived flag so provider resume can succeed.
*/
unarchiveNativeSession?(handle: AgentPersistenceHandle): Promise<void>;
@@ -1,4 +1,4 @@
import { promises as fs } from "node:fs";
import { promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import { z } from "zod";
import type { Logger } from "pino";
@@ -8,6 +8,7 @@ import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
const SERIALIZABLE_CONFIG_SCHEMA = z
.object({
@@ -64,6 +65,7 @@ const STORED_AGENT_SCHEMA = z.object({
attentionTimestamp: z.string().nullable().optional(),
internal: z.boolean().optional(),
archivedAt: z.string().nullable().optional(),
owner: AgentOwnerSchema.optional(),
});
export type SerializableAgentConfig = Pick<
@@ -88,6 +90,8 @@ export class AgentStorage {
private pathsById: Map<string, Set<string>> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
private deleting: Set<string> = new Set();
private daemonAgentIdsByExecution: Map<string, string> = new Map();
private daemonExecutionKeysByAgentId: Map<string, string> = new Map();
private loaded = false;
private baseDir: string;
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
@@ -112,6 +116,12 @@ export class AgentStorage {
return this.cache.get(agentId) ?? null;
}
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
await this.load();
const agentId = this.daemonAgentIdsByExecution.get(daemonExecutionKey(owner));
return agentId ? (this.cache.get(agentId) ?? null) : null;
}
async upsert(record: StoredAgentRecord): Promise<void> {
await this.load();
await this.queueRecordWrite(record);
@@ -157,6 +167,7 @@ export class AgentStorage {
}
this.cache.set(agentId, record);
this.indexOwner(record);
this.pathById.set(agentId, nextPath);
}
@@ -186,6 +197,7 @@ export class AgentStorage {
);
this.cache.delete(agentId);
this.removeOwnerIndex(agentId);
this.pathById.delete(agentId);
this.pathsById.delete(agentId);
}
@@ -248,6 +260,8 @@ export class AgentStorage {
this.cache.clear();
this.pathById.clear();
this.pathsById.clear();
this.daemonAgentIdsByExecution.clear();
this.daemonExecutionKeysByAgentId.clear();
try {
const records = await this.scanDisk();
@@ -266,7 +280,7 @@ export class AgentStorage {
private async scanDisk(): Promise<StoredAgentRecord[]> {
const records: StoredAgentRecord[] = [];
let entries: Array<import("node:fs").Dirent> = [];
let entries: Dirent[] = [];
try {
entries = await fs.readdir(this.baseDir, { withFileTypes: true });
} catch (error) {
@@ -310,6 +324,7 @@ export class AgentStorage {
const { record, filePath } = item;
records.push(record);
this.cache.set(record.id, record);
this.indexOwner(record);
this.pathById.set(record.id, filePath);
this.addIndexedPath(record.id, filePath);
}
@@ -350,6 +365,28 @@ export class AgentStorage {
}
}
private indexOwner(record: StoredAgentRecord): void {
this.removeOwnerIndex(record.id);
if (record.owner?.kind === "daemon") {
const key = daemonExecutionKey(record.owner);
const previousAgentId = this.daemonAgentIdsByExecution.get(key);
if (previousAgentId && previousAgentId !== record.id) {
this.daemonExecutionKeysByAgentId.delete(previousAgentId);
}
this.daemonAgentIdsByExecution.set(key, record.id);
this.daemonExecutionKeysByAgentId.set(record.id, key);
}
}
private removeOwnerIndex(agentId: string): void {
const key = this.daemonExecutionKeysByAgentId.get(agentId);
if (!key) return;
if (this.daemonAgentIdsByExecution.get(key) === agentId) {
this.daemonAgentIdsByExecution.delete(key);
}
this.daemonExecutionKeysByAgentId.delete(agentId);
}
private async waitForPendingWrite(agentId: string): Promise<void> {
await (this.pendingWrites.get(agentId) ?? Promise.resolve()).catch(() => undefined);
}
@@ -0,0 +1,47 @@
import { expect, test } from "vitest";
import type { AgentManagerEvent, AgentSubscriber } from "./agent-manager.js";
import { registerAgentAutoArchive } from "./create-agent-lifecycle-dispatch.js";
class AgentLifecycleEvents {
private readonly listeners = new Set<AgentSubscriber>();
subscribe(listener: AgentSubscriber): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
completeTurn(agentId: string): void {
const event: AgentManagerEvent = {
type: "agent_stream",
agentId,
event: { type: "turn_completed", provider: "codex" },
};
for (const listener of this.listeners) listener(event);
}
listenerCount(): number {
return this.listeners.size;
}
}
test("auto-archive self-releases once and later cancellation waits harmlessly", async () => {
const agentId = "4a7e2521-286d-4ad5-af35-e091c55302e3";
const agents = new AgentLifecycleEvents();
let archiveCount = 0;
const registration = registerAgentAutoArchive({
agentManager: agents,
agentId,
archive: async () => {
archiveCount += 1;
},
});
agents.completeTurn(agentId);
await registration.cancel();
await registration.cancel();
agents.completeTurn(agentId);
expect(archiveCount).toBe(1);
expect(agents.listenerCount()).toBe(0);
});
@@ -3,11 +3,7 @@ import type pino from "pino";
import type { ForgeService } from "../../services/forge-service.js";
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
import {
archiveByScope,
type ActiveWorkspaceRef,
resolveWorkspaceIdAtPath,
} from "../workspace-archive-service.js";
import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js";
import type {
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
@@ -18,7 +14,7 @@ import type {
FirstAgentContext,
SessionOutboundMessage,
} from "../messages.js";
import type { AgentManager } from "./agent-manager.js";
import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
interface CreateAgentLifecycleDispatchDependencies {
@@ -42,6 +38,20 @@ interface CreateAgentLifecycleDispatchDependencies {
logger: pino.Logger;
}
export interface LifecycleRegistration {
cancel(): Promise<void>;
}
interface AgentLifecycleEvents {
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void;
}
const inactiveRegistration: LifecycleRegistration = { cancel: async () => undefined };
type AutoArchiveTarget =
| { kind: "agent-only" }
| { kind: "created-worktree"; result: CreatePaseoWorktreeWorkflowResult };
export class CreateAgentLifecycleDispatch {
private readonly autoArchiveAgentIds = new Set<string>();
@@ -67,15 +77,15 @@ export class CreateAgentLifecycleDispatch {
autoArchive: boolean | undefined;
agentId: string;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
}): void {
}): LifecycleRegistration {
if (input.autoArchive !== true) {
return;
return inactiveRegistration;
}
this.registerAutoArchiveOnTerminalState(input.agentId, {
worktreePath: input.createdWorktree?.worktree.worktreePath ?? null,
repoRoot: input.createdWorktree?.repoRoot ?? null,
});
return this.registerAutoArchiveOnTerminalState(
input.agentId,
toAutoArchiveTarget(input.createdWorktree),
);
}
async cleanupCreatedWorktreeAfterFailedAgentCreate(input: {
@@ -89,8 +99,7 @@ export class CreateAgentLifecycleDispatch {
await this.archiveAutoCreatedWorktree({
agentId: null,
worktreePath: createdWorktree.worktree.worktreePath,
repoRoot: createdWorktree.repoRoot,
createdWorktree,
}).catch((archiveError) => {
this.dependencies.logger.warn(
{
@@ -145,42 +154,26 @@ export class CreateAgentLifecycleDispatch {
private registerAutoArchiveOnTerminalState(
agentId: string,
options: { worktreePath: string | null; repoRoot: string | null },
): void {
const unsubscribe = this.dependencies.agentManager.subscribe(
(event) => {
if (event.type !== "agent_stream") {
return;
}
if (
event.event.type !== "turn_completed" &&
event.event.type !== "turn_failed" &&
event.event.type !== "turn_canceled"
) {
return;
}
unsubscribe();
void this.autoArchiveAgentOnce(agentId, options);
},
{ agentId, replayState: false },
);
target: AutoArchiveTarget,
): LifecycleRegistration {
return registerAgentAutoArchive({
agentManager: this.dependencies.agentManager,
agentId,
archive: () => this.autoArchiveAgentOnce(agentId, target),
});
}
private async autoArchiveAgentOnce(
agentId: string,
options: { worktreePath: string | null; repoRoot: string | null },
): Promise<void> {
private async autoArchiveAgentOnce(agentId: string, target: AutoArchiveTarget): Promise<void> {
if (this.autoArchiveAgentIds.has(agentId)) {
return;
}
this.autoArchiveAgentIds.add(agentId);
try {
if (options.worktreePath) {
if (target.kind === "created-worktree") {
await this.archiveAutoCreatedWorktree({
agentId,
worktreePath: options.worktreePath,
repoRoot: options.repoRoot,
createdWorktree: target.result,
});
return;
}
@@ -193,10 +186,11 @@ export class CreateAgentLifecycleDispatch {
private async archiveAutoCreatedWorktree(options: {
agentId: string | null;
worktreePath: string;
repoRoot: string | null;
createdWorktree: CreatePaseoWorktreeWorkflowResult;
}): Promise<void> {
const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, {
const { createdWorktree } = options;
const worktreePath = createdWorktree.worktree.worktreePath;
const ownership = await isPaseoOwnedWorktreeCwd(worktreePath, {
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
});
@@ -204,49 +198,76 @@ export class CreateAgentLifecycleDispatch {
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
}
const workspaceId = await resolveWorkspaceIdAtPath(
await archiveByScope(
{
paseoHome: this.dependencies.paseoHome,
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
github: this.dependencies.github,
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
sessionLogger: this.dependencies.logger,
},
{
scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId },
requestId: randomUUID(),
},
options.worktreePath,
);
if (!workspaceId) {
this.dependencies.logger.warn(
{ worktreePath: options.worktreePath },
"Could not resolve workspace for auto-archive; skipping",
);
} else {
await archiveByScope(
{
paseoHome: this.dependencies.paseoHome,
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
github: this.dependencies.github,
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd,
listActiveWorkspaces: this.dependencies.listActiveWorkspaces,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds:
this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving,
killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace,
sessionLogger: this.dependencies.logger,
},
{
scope: { kind: "workspace", workspaceId },
repoRoot: options.repoRoot ?? ownership.repoRoot ?? null,
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
requestId: randomUUID(),
},
);
}
if (options.agentId) {
this.dependencies.emitAgentRemove(options.agentId);
}
}
}
export function registerAgentAutoArchive(input: {
agentManager: AgentLifecycleEvents;
agentId: string;
archive: () => Promise<unknown>;
}): LifecycleRegistration {
let unsubscribe: (() => void) | null = null;
let archiveTask: Promise<unknown> | null = null;
const release = () => {
if (!unsubscribe) return;
const subscribed = unsubscribe;
unsubscribe = null;
subscribed();
};
const registration: LifecycleRegistration = {
async cancel() {
release();
await archiveTask;
},
};
unsubscribe = input.agentManager.subscribe(
(event) => {
if (event.type !== "agent_stream") return;
if (
event.event.type !== "turn_completed" &&
event.event.type !== "turn_failed" &&
event.event.type !== "turn_canceled"
) {
return;
}
release();
archiveTask = input.archive();
},
{ agentId: input.agentId, replayState: false },
);
return registration;
}
function toAutoArchiveTarget(
createdWorktree: CreatePaseoWorktreeWorkflowResult | null,
): AutoArchiveTarget {
return createdWorktree
? { kind: "created-worktree", result: createdWorktree }
: { kind: "agent-only" };
}
@@ -27,12 +27,13 @@ function createRealAgentManager(storage: AgentStorage): AgentManager {
// worktree service).
function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) {
const worktreePath = join(args.repoRoot, "worktree");
mkdirSync(worktreePath, { recursive: true });
const workspaceCwd = join(worktreePath, "packages", "app");
mkdirSync(workspaceCwd, { recursive: true });
return async (): Promise<CreatePaseoWorktreeWorkflowResult> =>
({
worktree: { worktreePath },
intent: {},
workspace: { workspaceId: args.createdWorkspaceId },
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
repoRoot: args.repoRoot,
created: true,
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
@@ -321,6 +322,59 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
const storedChild = await storage.get(child.id);
expect(storedChild?.workspaceId).toBe("ws-new-worktree");
expect(child.cwd).toBe(join(workdir, "worktree", "packages", "app"));
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("mcp create exposes the created worktree before dispatching the initial prompt", async () => {
const workdir = mkdtempSync(join(tmpdir(), "create-agent-worktree-callback-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = createRealAgentManager(storage);
const createdWorktree = await fakeWorktreeCreator({
repoRoot: workdir,
createdWorkspaceId: "ws-created-worktree",
})();
let observed:
| {
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
lifecycle: ManagedAgent["lifecycle"] | null;
}
| undefined;
try {
await createAgentCommand(
{
agentManager,
agentStorage: storage,
logger,
providerSnapshotManager: {
async resolveCreateConfig() {
return {};
},
},
createPaseoWorktree: async () => createdWorktree,
},
{
kind: "mcp",
provider: "codex",
cwd: workdir,
title: "worktree callback",
initialPrompt: "Say done.",
background: true,
notifyOnFinish: false,
worktree: { worktreeName: "feature", baseBranch: "main" },
onCreated: ({ agentId, createdWorktree: callbackWorktree }) => {
observed = {
createdWorktree: callbackWorktree,
lifecycle: agentManager.getAgent(agentId)?.lifecycle ?? null,
};
},
},
);
expect(observed).toEqual({ createdWorktree, lifecycle: "idle" });
} finally {
rmSync(workdir, { recursive: true, force: true });
}
@@ -1,6 +1,5 @@
import type { Logger } from "pino";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
@@ -15,6 +14,7 @@ import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../
import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js";
import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js";
import type { AgentStorage } from "../agent-storage.js";
import type { AgentOwner } from "../agent-owner.js";
import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js";
import { resolveCreateAgentTitles } from "../create-agent-title.js";
@@ -25,6 +25,7 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js";
import { resolveCreateAgentIntent } from "./intent.js";
export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig;
@@ -41,7 +42,7 @@ export interface CreateAgentCommandDependencies {
paseoHome?: string;
worktreesRoot?: string;
terminalManager?: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
providerSnapshotManager: Pick<ProviderSnapshotManager, "resolveCreateConfig">;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id.
ensureWorkspaceForCreate?: EnsureWorkspaceForCreate;
@@ -93,6 +94,13 @@ export interface CreateAgentFromMcpInput {
notifyOnFinish: boolean;
internal?: boolean;
detached?: boolean;
owner?: AgentOwner;
env?: Record<string, string>;
onCreated?: (created: {
agentId: string;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
}) => void;
onWorktreeCreated?: (createdWorktree: CreatePaseoWorktreeWorkflowResult) => void;
callerAgentId?: string;
callerContext?: {
lockedCwd?: string;
@@ -118,6 +126,7 @@ export interface CreateAgentCommandResult {
background: boolean;
initialPromptStarted: boolean;
initialPromptError: unknown | null;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
}
export type BoundCreateAgentCommand = (
@@ -158,6 +167,7 @@ interface ResolvedCreateAgent {
background: boolean;
promptFailure: CreateAgentPromptFailureMode;
promptLogger?: Logger;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
}
export async function createAgentCommand(
@@ -182,6 +192,9 @@ export async function createAgentCommand(
let liveSnapshot = snapshot;
let initialPromptStarted = false;
let initialPromptError: unknown | null = null;
if (input.kind === "mcp") {
input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null });
}
if (resolved.prompt !== undefined) {
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot);
initialPromptStarted = sendResult.started;
@@ -195,6 +208,7 @@ export async function createAgentCommand(
agentStorage: dependencies.agentStorage,
childAgentId: snapshot.id,
callerAgentId: input.callerAgentId,
requireParentOwnership: true,
logger: dependencies.logger,
});
}
@@ -205,6 +219,7 @@ export async function createAgentCommand(
background: resolved.background,
initialPromptStarted,
initialPromptError,
...(resolved.createdWorktree ? { createdWorktree: resolved.createdWorktree } : {}),
};
}
@@ -294,20 +309,30 @@ async function resolveMcpCreateAgent(
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
: null;
const cwd = resolveMcpInitialCwd(input, parentAgent);
const { resolvedCwd, setupContinuation, createdWorkspaceId } = await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
});
const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } =
await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
});
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
const workspaceId = await resolveMcpWorkspaceId({
dependencies,
input,
parentAgent,
setupContinuation,
createdWorkspaceId,
resolvedCwd,
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
caller: parentAgent
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
: null,
labels: input.labels,
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
legacyDetached: input.detached ?? false,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }),
createWorkspace: async () => ({
workspaceId: requireResolvedWorkspaceId(
await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""),
),
cwd: resolvedCwd,
}),
});
const resolvedCreateConfig = await resolveMcpProviderCreateConfig({
dependencies,
@@ -317,30 +342,26 @@ async function resolveMcpCreateAgent(
parentAgent,
});
const labels = mergeLabels({
callerAgentId: input.callerAgentId,
detached: input.detached ?? false,
childAgentDefaultLabels: input.callerContext?.childAgentDefaultLabels,
labels: input.labels,
});
const trimmedPrompt = input.initialPrompt?.trim() ?? "";
return {
config: buildMcpSessionConfig({
input,
resolvedProviderModel,
provider,
resolvedCwd,
resolvedCwd: intent.cwd,
trimmedPrompt,
resolvedMode: resolvedCreateConfig.modeId,
resolvedFeatures: resolvedCreateConfig.featureValues,
}),
createOptions: {
...(labels ? { labels } : {}),
workspaceId: requireResolvedWorkspaceId(workspaceId),
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
workspaceId: intent.workspaceId,
owner: input.owner,
env: input.env,
},
prompt: trimmedPrompt ? trimmedPrompt : undefined,
setupContinuation,
createdWorktree,
background: input.background,
promptFailure: input.promptFailure ?? "log",
};
@@ -361,34 +382,6 @@ function resolveMcpInitialCwd(
});
}
async function resolveMcpWorkspaceId(params: {
dependencies: CreateAgentCommandDependencies;
input: CreateAgentFromMcpInput;
parentAgent: ManagedAgent | null;
setupContinuation?: AgentWorktreeSetupContinuation;
createdWorkspaceId?: string;
resolvedCwd: string;
}): Promise<string | undefined> {
// MCP callers resolve workspace ownership before this point. Worktree
// creation wins because the new agent lives in the fresh worktree workspace.
// Otherwise use the explicit workspace id, then the parent workspace for
// direct internal callers. Ownership is never resolved from cwd.
if (params.setupContinuation) {
return params.createdWorkspaceId;
}
if (params.input.workspaceId) {
return params.input.workspaceId;
}
if (params.parentAgent?.workspaceId) {
return params.parentAgent.workspaceId;
}
return ensureWorkspaceForMcpCreate(
params.dependencies,
params.resolvedCwd,
params.input.initialPrompt ?? "",
);
}
async function resolveMcpProviderCreateConfig(params: {
dependencies: CreateAgentCommandDependencies;
input: CreateAgentFromMcpInput;
@@ -518,6 +511,7 @@ async function resolveMcpCwd(params: {
resolvedCwd: string;
setupContinuation?: AgentWorktreeSetupContinuation;
createdWorkspaceId?: string;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
}> {
const { dependencies, worktree } = params;
if (!worktree) {
@@ -573,9 +567,10 @@ async function resolveMcpCwd(params: {
},
});
return {
resolvedCwd: createdWorktree.worktree.worktreePath,
resolvedCwd: createdWorktree.workspace.cwd,
setupContinuation: createdWorktree.setupContinuation,
createdWorkspaceId: createdWorktree.workspace.workspaceId,
createdWorktree,
};
}
@@ -603,22 +598,3 @@ async function createMcpWorktree(
throw toWorktreeRequestError(error);
}
}
function mergeLabels(params: {
callerAgentId: string | undefined;
detached: boolean;
childAgentDefaultLabels: Record<string, string> | undefined;
labels: Record<string, string> | undefined;
}): Record<string, string> | undefined {
const mergedLabels = {
...(!params.detached && params.callerAgentId
? { [PARENT_AGENT_ID_LABEL]: params.callerAgentId }
: {}),
...params.childAgentDefaultLabels,
...params.labels,
};
if (params.detached) {
delete mergedLabels[PARENT_AGENT_ID_LABEL];
}
return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined;
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { resolveCreateAgentIntent } from "./intent.js";
describe("resolveCreateAgentIntent", () => {
it("keeps caller parentage when an explicit workspace changes placement", async () => {
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: "workspace-isolated",
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
labels: { purpose: "review" },
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/isolated" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-isolated",
cwd: "/isolated",
parentAgentId: "parent-agent",
labels: {
purpose: "review",
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
});
});
it("defaults an agent caller to its workspace without creating one", async () => {
let createCount = 0;
const intent = await resolveCreateAgentIntent({
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => {
createCount += 1;
return { workspaceId: "workspace-created", cwd: "/created" };
},
});
expect(intent.workspaceId).toBe("workspace-parent");
expect(intent.cwd).toBe("/parent");
expect(intent.parentAgentId).toBe("parent-agent");
expect(createCount).toBe(0);
});
it("creates a workspace for a human caller with no workspace context", async () => {
const intent = await resolveCreateAgentIntent({
caller: null,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-created",
cwd: "/created",
parentAgentId: null,
labels: {},
});
});
it("keeps legacy detached creation independent", async () => {
const intent = await resolveCreateAgentIntent({
caller: { id: "parent-agent", cwd: "/parent", workspaceId: "workspace-parent" },
labels: { [PARENT_AGENT_ID_LABEL]: "spoofed-parent" },
legacyDetached: true,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/unused" }),
createWorkspace: async () => ({ workspaceId: "workspace-created", cwd: "/created" }),
});
expect(intent).toEqual({
workspaceId: "workspace-parent",
cwd: "/parent",
parentAgentId: null,
labels: {},
});
});
});
@@ -0,0 +1,64 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
export interface CreateAgentCaller {
id: string;
cwd: string;
workspaceId?: string;
}
export interface CreateAgentPlacement {
workspaceId: string;
cwd: string;
}
export interface CreateAgentIntent {
workspaceId: string;
cwd: string;
parentAgentId: string | null;
labels: Record<string, string>;
}
export async function resolveCreateAgentIntent(input: {
explicitWorkspaceId?: string;
caller: CreateAgentCaller | null;
labels?: Record<string, string>;
childAgentDefaultLabels?: Record<string, string>;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>;
legacyDetached?: boolean;
}): Promise<CreateAgentIntent> {
const parentAgentId = input.legacyDetached ? null : (input.caller?.id ?? null);
const placement = await resolvePlacement(input);
const labels = {
...input.childAgentDefaultLabels,
...input.labels,
...(parentAgentId ? { [PARENT_AGENT_ID_LABEL]: parentAgentId } : {}),
};
// COMPAT(detachedCreate): legacy callers may still request detached creation.
// Added in v0.2.0; remove after 2027-01-17 once detached creation is outside the floor.
// The delete also strips a parent label injected through input.labels.
if (input.legacyDetached) {
delete labels[PARENT_AGENT_ID_LABEL];
}
return { ...placement, parentAgentId, labels };
}
async function resolvePlacement(input: {
explicitWorkspaceId?: string;
caller: CreateAgentCaller | null;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>;
}): Promise<CreateAgentPlacement> {
if (input.explicitWorkspaceId) {
return input.resolveWorkspace(input.explicitWorkspaceId);
}
if (input.caller) {
if (!input.caller.workspaceId) {
throw new Error(`Caller agent ${input.caller.id} has no workspace`);
}
return { workspaceId: input.caller.workspaceId, cwd: input.caller.cwd };
}
return input.createWorkspace();
}
@@ -22,9 +22,12 @@ import {
AgentSnapshotPayloadSchema,
} from "@getpaseo/protocol/messages";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "../workspace-registry.js";
import type {
CreateScheduleInput,
@@ -46,11 +49,13 @@ import { WorkspaceAutoName } from "../workspace-auto-name.js";
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
import type { ForgeService } from "../../services/forge-service.js";
import { areEquivalentPaths } from "../../utils/path.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
@@ -670,13 +675,68 @@ function createPaseoWorktreeForMcpTest(options: {
paseoHome: options.paseoHome,
deps: { forgeOverrides: { github } },
});
const workspaceRegistry = {
const projectRegistry: ProjectRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (projectId) => projects.get(projectId) ?? null,
getOrCreateActiveByRoot: async (allocation) => {
const existing = Array.from(projects.values()).find(
(project) =>
areEquivalentPaths(project.rootPath, allocation.rootPath) && !project.archivedAt,
);
if (existing) return existing;
const project = createPersistedProjectRecord({
projectId: `prj_test_${projects.size + 1}`,
rootPath: allocation.rootPath,
kind: allocation.kind,
displayName: allocation.displayName,
createdAt: allocation.timestamp,
updatedAt: allocation.timestamp,
});
projects.set(project.projectId, project);
return project;
},
upsert: async (record) => {
projects.set(record.projectId, record);
},
archive: async (projectId, archivedAt) => {
const project = projects.get(projectId);
if (project) projects.set(projectId, { ...project, archivedAt });
},
remove: async (projectId) => {
projects.delete(projectId);
},
};
const workspaceRegistry: WorkspaceRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
update: async (workspaceId, updater) => {
const workspace = workspaces.get(workspaceId);
if (!workspace) return null;
const updated = updater(workspace);
workspaces.set(workspaceId, updated);
return updated;
},
upsert: async (record: PersistedWorkspaceRecord) => {
workspaces.set(record.workspaceId, record);
},
archive: async (workspaceId, archivedAt) => {
const workspace = workspaces.get(workspaceId);
if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt });
},
remove: async (workspaceId) => {
workspaces.delete(workspaceId);
},
};
const workspaceProvisioning = createWorkspaceProvisioningService({
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger: createTestLogger(),
});
const workspaceAutoName = new WorkspaceAutoName({
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
workspaceRegistry,
@@ -710,14 +770,8 @@ function createPaseoWorktreeForMcpTest(options: {
...(workflowOptions?.resolveDefaultBranch
? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
projects.set(record.projectId, record);
},
},
workspaceRegistry,
workspaceGitService,
workspaceProvisioning,
}),
warmWorkspaceGitData: async () => {},
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
@@ -1148,6 +1202,42 @@ describe("create_agent MCP tool", () => {
).toBe(true);
});
it("creates a fresh local workspace for canonical top-level creation", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({
id: "top-level-agent",
provider: "codex",
cwd: existingCwd,
workspaceId: "workspace-created",
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Top-level agent" },
} as ManagedAgent);
const ensureWorkspace = vi.fn(async () => "workspace-created");
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate: ensureWorkspace,
logger,
});
await registeredTool(server, "create_agent").handler({
title: "Top-level agent",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
});
expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" });
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: existingCwd }),
undefined,
{ workspaceId: "workspace-created" },
);
});
it("rejects partial explicit workspace shape", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
@@ -1454,7 +1544,7 @@ describe("create_agent MCP tool", () => {
expect(parsed.success).toBe(true);
});
it("accepts each create_worktree target kind", async () => {
it("exposes workspace tools instead of worktree tools", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -1462,31 +1552,14 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_worktree");
for (const target of [
{ kind: "branch-off", worktreeSlug: "feature-x", baseBranch: "main" },
{ kind: "branch-off" },
{ kind: "checkout-branch", branch: "head-ref" },
{ kind: "checkout-pr", githubPrNumber: 42 },
] as const) {
const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, target });
expect(parsed.success).toBe(true);
}
});
it("rejects create_worktree without a target", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "create_worktree");
const parsed = await tool.inputSchema.safeParseAsync({});
expect(parsed.success).toBe(false);
expect(lookupTool(server, "create_workspace")).toBeDefined();
expect(lookupTool(server, "list_workspaces")).toBeDefined();
expect(lookupTool(server, "archive_workspace")).toBeDefined();
expect(lookupTool(server, "create_worktree")).toBeUndefined();
expect(lookupTool(server, "list_worktrees")).toBeUndefined();
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
expect(lookupTool(server, "detach_agent")).toBeUndefined();
expect(lookupTool(server, "update_heartbeat")).toBeUndefined();
});
it("surfaces createAgent validation failures", async () => {
@@ -2389,7 +2462,7 @@ describe("create_agent MCP tool", () => {
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
it("registers and broadcasts a workspace when create_worktree creates a worktree", async () => {
it("creates a worktree-isolated workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-create-worktree-"));
const repoDir = join(tempDir, "repo");
@@ -2434,25 +2507,19 @@ describe("create_agent MCP tool", () => {
>,
logger,
});
const tool = registeredTool(server, "create_worktree");
const tool = registeredTool(server, "create_workspace");
const response = await tool.handler({
cwd: repoDir,
target: {
kind: "branch-off",
worktreeSlug: "tool-worktree",
branchName: "feature/tool-worktree",
baseBranch: "main",
},
isolation: "worktree",
path: repoDir,
worktreeSlug: "tool-worktree",
branchName: "feature/tool-worktree",
baseBranch: "main",
});
expect(response.structuredContent.branchName).toBe("feature/tool-worktree");
expect(response.structuredContent.worktreePath).toContain("tool-worktree");
expect(response.structuredContent.isolation).toBe("worktree");
expect(response.structuredContent.cwd).toContain("tool-worktree");
expect(response.structuredContent.workspaceId).toBe(broadcasts[0]);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:create-worktree",
});
expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
@@ -2461,7 +2528,149 @@ describe("create_agent MCP tool", () => {
}
});
it("forces a workspace git snapshot refresh when archive_worktree deletes a worktree", async () => {
it("creates a worktree workspace from a project root without a path", async () => {
const { agentManager, agentStorage } = createTestDeps();
const project = createPersistedProjectRecord({
projectId: "project-source",
rootPath: REPO_CWD,
kind: "git",
displayName: "source",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
});
const receivedInputs: CreatePaseoWorktreeInput[] = [];
const createPaseoWorktree: CreatePaseoWorktreeWorkflowFn = async (input) => {
receivedInputs.push(input);
return {
worktree: { branchName: "project-worktree", worktreePath: TARGET_CWD },
intent: { kind: "branch-off", branchName: "project-worktree", baseBranch: "main" },
workspace: createPersistedWorkspaceRecord({
workspaceId: "ws-project-source",
projectId: project.projectId,
cwd: TARGET_CWD,
kind: "worktree",
displayName: "project-worktree",
title: input.title ?? null,
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
}),
repoRoot: REPO_CWD,
created: true,
};
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
projectRegistry: {
get: async (projectId) => (projectId === project.projectId ? project : null),
},
createPaseoWorktree,
logger,
});
const response = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), {
isolation: "worktree",
projectId: project.projectId,
worktreeSlug: "project-worktree",
title: "Project workspace",
});
expect(response.structuredContent.workspaceId).toBe("ws-project-source");
expect(receivedInputs).toEqual([
expect.objectContaining({
cwd: REPO_CWD,
projectId: project.projectId,
title: "Project workspace",
}),
]);
});
it("preserves branch checkout and pull request checkout workspace modes", async () => {
const { agentManager, agentStorage } = createTestDeps();
const createPaseoWorktree = vi.fn(async (input: CreatePaseoWorktreeInput) => ({
worktree: {
branchName: input.refName ?? "pr-42",
worktreePath: "/tmp/worktrees/selected",
},
intent: {
kind: "checkout-branch" as const,
branchName: input.refName ?? "pr-42",
},
workspace: createPersistedWorkspaceRecord({
workspaceId: "ws-selected",
projectId: "project-1",
cwd: "/tmp/worktrees/selected",
kind: "worktree",
displayName: "selected",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
}),
repoRoot: REPO_CWD,
created: true,
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
createPaseoWorktree,
logger,
});
const tool = registeredTool(server, "create_workspace");
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-branch",
branch: "existing-work",
worktreeSlug: "existing-work-copy",
});
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-pr",
prNumber: 42,
forge: "gitlab",
});
await invokeToolWithParsedInput(tool, {
isolation: "worktree",
path: REPO_CWD,
mode: "checkout-pr",
prNumber: 43,
});
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
action: "checkout",
refName: "existing-work",
worktreeSlug: "existing-work-copy",
}),
);
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
action: "checkout",
checkoutSource: {
kind: "change_request",
forge: "gitlab",
number: 42,
},
}),
);
expect(createPaseoWorktree).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
action: "checkout",
checkoutSource: {
kind: "change_request",
number: 43,
},
}),
);
});
it("archives a worktree-isolated workspace by workspace id", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-")),
@@ -2514,13 +2723,15 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const createTool = registeredTool(server, "create_workspace");
const archiveTool = registeredTool(server, "archive_workspace");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-tool-worktree", baseBranch: "main" },
isolation: "worktree",
path: repoDir,
worktreeSlug: "archive-tool-worktree",
baseBranch: "main",
});
const createdWorktreePath = z.string().parse(created.structuredContent.worktreePath);
const createdWorktreePath = z.string().parse(created.structuredContent.cwd);
listActiveWorkspaces.mockImplementation(async () => [
{ workspaceId: "ws-archive-tool-worktree", cwd: createdWorktreePath, kind: "worktree" },
]);
@@ -2530,19 +2741,13 @@ describe("create_agent MCP tool", () => {
workspaceGitService.getSnapshot.mockClear();
await archiveTool.handler({
cwd: repoDir,
worktreePath: created.structuredContent.worktreePath,
workspaceId: "ws-archive-tool-worktree",
});
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
["ws-archive-tool-worktree"],
@@ -2557,7 +2762,22 @@ describe("create_agent MCP tool", () => {
}
});
it("archives every workspace on a directory and removes the directory", async () => {
it("rejects archiving a missing workspace", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
listActiveWorkspaces: async () => [],
logger,
});
await expect(
registeredTool(server, "archive_workspace").handler({ workspaceId: "missing-workspace" }),
).rejects.toThrow("Workspace not found: missing-workspace");
});
it("keeps an owned worktree while another workspace still references it", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-multi-")),
@@ -2616,13 +2836,15 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const createTool = registeredTool(server, "create_workspace");
const archiveTool = registeredTool(server, "archive_workspace");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-multi-worktree", baseBranch: "main" },
isolation: "worktree",
path: repoDir,
worktreeSlug: "archive-multi-worktree",
baseBranch: "main",
});
const worktreePath = z.string().parse(created.structuredContent.worktreePath);
const worktreePath = z.string().parse(created.structuredContent.cwd);
// Populate the active workspaces with the real created path so archiveByScope
// matches it against the worktree directory.
@@ -2632,19 +2854,18 @@ describe("create_agent MCP tool", () => {
];
await archiveTool.handler({
cwd: repoDir,
worktreePath,
workspaceId: "ws-mcp-A",
});
expect(archivedWorkspaceIds).toContain("ws-mcp-A");
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
await expect(access(worktreePath)).rejects.toThrow();
expect(archivedWorkspaceIds).not.toContain("ws-mcp-B");
await expect(access(worktreePath)).resolves.toBeUndefined();
} finally {
await removeTempDir(tempDir);
}
});
it("archives a worktree by slug", async () => {
it("does not expose worktree path or slug operations", async () => {
const { agentManager, agentStorage } = createTestDeps();
const tempDir = realpathSync.native(
await mkdtemp(join(tmpdir(), "paseo-mcp-archive-worktree-slug-")),
@@ -2692,71 +2913,41 @@ describe("create_agent MCP tool", () => {
github: createGitHubServiceStub(),
logger,
});
const createTool = registeredTool(server, "create_worktree");
const archiveTool = registeredTool(server, "archive_worktree");
const created = await createTool.handler({
cwd: repoDir,
target: { kind: "branch-off", worktreeSlug: "archive-slug-worktree", baseBranch: "main" },
});
const response = await archiveTool.handler({
cwd: repoDir,
worktreeSlug: "archive-slug-worktree",
});
expect(response.structuredContent).toEqual({ success: true });
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "archive-worktree",
});
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(repoDir);
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(repoDir, {
force: true,
reason: "mcp:archive-worktree",
});
await expect(
access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow();
expect(lookupTool(server, "create_worktree")).toBeUndefined();
expect(lookupTool(server, "archive_worktree")).toBeUndefined();
} finally {
await removeTempDir(tempDir);
}
});
it("routes list_worktrees through WorkspaceGitService", async () => {
it("lists active workspace descriptors", async () => {
const { agentManager, agentStorage } = createTestDeps();
const workspaceGitService = {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => [
{
path: "/tmp/paseo/worktrees/repo/feature",
branchName: "feature",
createdAt: "2026-04-12T00:00:00.000Z",
},
]),
};
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-feature",
projectId: "project-1",
cwd: "/tmp/paseo/worktrees/repo/feature",
kind: "worktree",
displayName: "feature",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
>,
workspaceRegistry: {
get: vi.fn(async () => workspace),
list: vi.fn(async () => [workspace]),
upsert: vi.fn(async () => undefined),
},
logger,
});
const tool = registeredTool(server, "list_worktrees");
const tool = registeredTool(server, "list_workspaces");
const response = await tool.handler({ cwd: REPO_CWD });
const response = await tool.handler({});
expect(workspaceGitService.listWorktrees).toHaveBeenCalledWith(REPO_CWD, {
reason: "mcp:list-worktrees",
});
expect(response.structuredContent.worktrees).toEqual([
{
path: "/tmp/paseo/worktrees/repo/feature",
branchName: "feature",
createdAt: "2026-04-12T00:00:00.000Z",
},
expect(response.structuredContent.workspaces).toEqual([
expect.objectContaining({ workspaceId: "ws-feature", isolation: "worktree" }),
]);
});
@@ -3781,11 +3972,11 @@ describe("create_schedule MCP tool", () => {
cron: "*/5 * * * *",
name: "Default schedule",
}),
).rejects.toThrow("provider is required when target is new-agent");
).rejects.toThrow("provider");
expect(createOrReplace).not.toHaveBeenCalled();
});
it("keeps create_schedule provider overrides compatible with provider and provider/model forms", async () => {
it("keeps provider forms compatible without materializing default schedule isolation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
createStoredSchedule(input),
@@ -3809,6 +4000,12 @@ describe("create_schedule MCP tool", () => {
cron: "*/10 * * * *",
provider: "codex/gpt-5.4",
});
await tool.handler({
prompt: "say hello in a worktree",
cron: "*/15 * * * *",
provider: "codex",
isolation: "worktree",
});
expect(createOrReplace).toHaveBeenNthCalledWith(
1,
@@ -3835,9 +4032,22 @@ describe("create_schedule MCP tool", () => {
},
}),
);
expect(createOrReplace).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
target: {
type: "new-agent",
config: {
provider: "codex",
cwd: process.cwd(),
isolation: "worktree",
},
},
}),
);
});
it("returns create_schedule structured content with inherited feature values", async () => {
it("inherits the caller provider, model, and features when provider is omitted", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
@@ -3868,12 +4078,17 @@ describe("create_schedule MCP tool", () => {
const response = await tool.handler({
prompt: "say hello",
cron: "*/5 * * * *",
provider: "opencode/openai/gpt-5.5",
});
expect(response.structuredContent.target).toMatchObject({
expect(response.structuredContent.target).toEqual({
type: "new-agent",
config: { featureValues: { auto_accept: true } },
config: {
provider: "opencode",
cwd: REPO_CWD,
modeId: "build",
model: "openai/gpt-5.5",
featureValues: { auto_accept: true },
},
});
});
@@ -4049,6 +4264,60 @@ describe("create_heartbeat MCP tool", () => {
});
});
describe("heartbeat ownership MCP tools", () => {
const logger = createTestLogger();
it("deletes the caller's heartbeat", async () => {
const { agentManager, agentStorage } = createTestDeps();
const heartbeat = createStoredSchedule({
prompt: "check status",
cadence: { type: "cron", expression: "*/15 * * * *" },
target: { type: "agent", agentId: "parent-agent" },
});
const inspect = vi.fn(async () => heartbeat);
const deleteSchedule = vi.fn(async () => undefined);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: {
inspect,
delete: deleteSchedule,
} as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
await registeredTool(server, "delete_heartbeat").handler({ id: heartbeat.id });
expect(deleteSchedule).toHaveBeenCalledWith(heartbeat.id);
});
it("rejects another agent's heartbeat", async () => {
const { agentManager, agentStorage } = createTestDeps();
const foreignHeartbeat = createStoredSchedule({
prompt: "foreign",
cadence: { type: "cron", expression: "0 * * * *" },
target: { type: "agent", agentId: "other-agent" },
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: {
inspect: vi.fn(async () => foreignHeartbeat),
delete: vi.fn(),
} as unknown as ScheduleService,
callerAgentId: "parent-agent",
logger,
});
await expect(
registeredTool(server, "delete_heartbeat").handler({ id: foreignHeartbeat.id }),
).rejects.toThrow("does not belong to caller");
});
});
describe("update_schedule MCP tool", () => {
const logger = createTestLogger();
@@ -4071,6 +4340,16 @@ describe("update_schedule MCP tool", () => {
};
}
function scheduleServiceWithUpdate(
update: (input: UpdateScheduleInput) => Promise<StoredSchedule>,
stored = makeStoredSchedule(),
): ScheduleService {
return {
update,
inspect: vi.fn(async () => stored),
} as unknown as ScheduleService;
}
it("calls scheduleService.update with correct input", async () => {
const { agentManager, agentStorage } = createTestDeps();
const stored = makeStoredSchedule();
@@ -4084,7 +4363,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4110,7 +4389,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4122,7 +4401,7 @@ describe("update_schedule MCP tool", () => {
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
});
});
@@ -4134,7 +4413,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4163,7 +4442,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4176,7 +4455,7 @@ describe("update_schedule MCP tool", () => {
expect(update).toHaveBeenCalledWith({
id: "schedule-1",
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
});
});
@@ -4184,7 +4463,7 @@ describe("update_schedule MCP tool", () => {
{
label: "whitespace cron field",
input: { id: "schedule-1", every: "10m", cron: " " },
cadence: { type: "every", everyMs: 600000 },
cadence: { type: "cron", expression: "*/10 * * * *" },
},
{
label: "blank every field for cron cadence",
@@ -4204,7 +4483,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4224,7 +4503,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4246,7 +4525,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4269,7 +4548,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4293,7 +4572,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4327,7 +4606,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update, stored),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4358,7 +4637,7 @@ describe("update_schedule MCP tool", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { update } as unknown as ScheduleService,
scheduleService: scheduleServiceWithUpdate(update),
logger,
});
const tool = registeredTool(server, "update_schedule");
@@ -4401,11 +4680,15 @@ describe("schedule_logs MCP tool", () => {
const { agentManager, agentStorage } = createTestDeps();
const runs = [makeRun({ id: "run-1" }), makeRun({ id: "run-2", status: "failed" })];
const logs = vi.fn(async (_id: string) => runs);
const inspect = vi.fn(async () => ({
id: "schedule-1",
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp" } },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
scheduleService: { logs } as unknown as ScheduleService,
scheduleService: { logs, inspect } as unknown as ScheduleService,
logger,
});
const tool = registeredTool(server, "schedule_logs");
@@ -1,6 +1,11 @@
import type { AgentProviderNotice } from "./agent-sdk-types.js";
export const SETTING_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = {
type: "info",
message: "This change applies next turn.",
export const MODE_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = {
type: "warning",
message: "Permission mode applies next turn",
};
export const THINKING_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = {
type: "warning",
message: "Thinking level applies next turn",
};
@@ -814,7 +814,7 @@ test("enabled: false keeps provider metadata in registry", () => {
id: "claude",
label: "Claude",
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
defaultModeId: "default",
defaultModeId: "auto",
enabled: false,
});
expect(registry.claude.modes).toEqual(
@@ -1487,6 +1487,31 @@ describe("fetchCatalog", () => {
expect(catalog.models.map((model) => model.id)).toEqual(["profile-model", "extra-model"]);
});
test("replacement models still resolve the provider's capability-aware default mode", async () => {
const resolveDefaultModeId = vi.fn(async () => "default");
const injectedClient = {
provider: "codex",
capabilities: {},
resolveDefaultModeId,
isAvailable: vi.fn(async () => true),
} satisfies Partial<AgentClient> as AgentClient;
const registry = buildProviderRegistry(logger, {
providerOverrides: {
codex: { models: [{ id: "profile-model", label: "Profile Model" }] },
},
});
const catalog = await registry.codex.fetchCatalog(
{ scope: "workspace", cwd: "/tmp/catalog", force: false },
injectedClient,
);
expect(catalog.defaultModeId).toBe("default");
expect(resolveDefaultModeId).toHaveBeenCalledWith({
config: { provider: "codex", cwd: "/tmp/catalog" },
});
});
test("additionalModels can override replacement model fields", async () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
@@ -14,6 +14,7 @@ import type {
ProviderCatalog,
ResolveAgentCreateConfigInput,
ResolveAgentCreateConfigResult,
ResolveAgentDefaultModeInput,
} from "./agent-sdk-types.js";
import {
isDefaultAgentCreateConfigUnattended,
@@ -427,12 +428,20 @@ function wrapClientProvider(
fetchCatalog: async (options) => {
const catalog = await inner.fetchCatalog(options);
return {
...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
profileModelsAreAdditive,
}),
modes: catalog.modes,
};
},
resolveDefaultModeId: inner.resolveDefaultModeId
? async ({ config, env }: ResolveAgentDefaultModeInput) =>
await inner.resolveDefaultModeId?.({
config: { ...config, provider: inner.provider },
env,
})
: undefined,
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
listFeatures: listFeatures
@@ -516,17 +525,25 @@ function createRegistryEntry(
// the single catalog API; otherwise use static/empty modes with no runtime.
const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels);
if (hasStaticModes) {
const defaultModeId = await catalogClient.resolveDefaultModeId?.({
config: {
provider,
cwd: options.scope === "workspace" ? options.cwd : process.cwd(),
},
});
return {
models,
modes: decorateModes(resolved.definition.modes),
defaultModeId,
};
}
const catalog = await catalogClient.fetchCatalog(options);
return { models, modes: decorateModes(catalog.modes) };
return { ...catalog, models, modes: decorateModes(catalog.modes) };
}
const catalog = await catalogClient.fetchCatalog(options);
return {
...catalog,
models: mergeModels(
provider,
resolved.profileModels,
@@ -117,9 +117,11 @@ describe("ProviderSnapshotManager public surface", () => {
try {
const snapshot = manager.getSnapshot("/tmp/project");
const claude = snapshot.find((entry) => entry.provider === "claude");
const codex = snapshot.find((entry) => entry.provider === "codex");
expect(claude?.status).toBe("loading");
expect(claude?.label).toBe("Claude");
expect(claude?.defaultModeId).toBe("default");
expect(claude?.defaultModeId).toBe("auto");
expect(codex?.defaultModeId).toBe("auto-review");
} finally {
manager.destroy();
}
@@ -220,6 +222,34 @@ describe("ProviderSnapshotManager public surface", () => {
}
});
test("ready snapshots publish the catalog's capability-aware default mode", async () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: async () => ({
models: [],
modes: [{ id: "default", label: "Default", description: "Ask before running tools" }],
defaultModeId: "default",
}),
}),
},
});
try {
const entry = await manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
expect(entry).toMatchObject({ status: "ready", defaultModeId: "default" });
} finally {
manager.destroy();
}
});
test("explicit refresh re-probes only the requested warm provider", async () => {
const cwd = "/tmp/project";
const isAvailableCodex = vi.fn(async () => true);
@@ -799,6 +799,8 @@ export class ProviderSnapshotManager {
setEntry({
...base,
defaultModeId:
catalog.defaultModeId === undefined ? definition.defaultModeId : catalog.defaultModeId,
status: "ready",
enabled: true,
models: catalog.models,
@@ -218,6 +218,37 @@ test("rejects auto mode when Claude Code uses Bedrock", async () => {
}
});
test.each([
{ env: {}, expected: "auto" },
{ env: { CLAUDE_CODE_USE_BEDROCK: "1" }, expected: "default" },
{ env: { CLAUDE_CODE_USE_VERTEX: "true" }, expected: "default" },
])("defaults to $expected for transport environment $env", async ({ env, expected }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env },
});
await expect(
client.resolveDefaultModeId({
config: { provider: "claude", cwd: process.cwd() },
}),
).resolves.toBe(expected);
});
test("launch environment participates in the Claude default mode", async () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env: {} },
});
await expect(
client.resolveDefaultModeId({
config: { provider: "claude", cwd: process.cwd() },
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
}),
).resolves.toBe("default");
});
test("allows launch env to disable inherited Bedrock transport for auto mode", async () => {
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
@@ -704,8 +704,8 @@ describe("ClaudeAgentSession features", () => {
});
await expect(session.setThinkingOption?.("ultracode")).resolves.toEqual({
type: "info",
message: "This change applies next turn.",
type: "warning",
message: "Thinking level applies next turn",
});
await session.close();
@@ -50,7 +50,7 @@ import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./quer
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
materializeProviderImage,
@@ -91,6 +91,7 @@ import {
type ListImportableSessionsOptions,
type McpServerConfig,
type ProviderCatalog,
type ResolveAgentDefaultModeInput,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import {
@@ -1472,7 +1473,28 @@ export class ClaudeAgentClient implements AgentClient {
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
const models = await getClaudeModelsWithSettings(this.logger, this.configDir);
return { models, modes: DEFAULT_MODES };
const modes = detectIneligibleAutoModeTransport(
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
)
? DEFAULT_MODES.filter((mode) => mode.id !== "auto")
: DEFAULT_MODES;
return {
models,
modes,
defaultModeId: modes.some((mode) => mode.id === "auto") ? "auto" : "default",
};
}
async resolveDefaultModeId({
config,
env: launchEnv,
}: ResolveAgentDefaultModeInput): Promise<string> {
const env = createProviderEnv({
baseEnv: process.env,
runtimeSettings: this.runtimeSettings,
overlays: [config.extra?.claude?.env, launchEnv],
});
return detectIneligibleAutoModeTransport(env) ? "default" : "auto";
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
@@ -2209,7 +2231,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.queryRestartNeeded = true;
if (this.activeForegroundTurnId || this.autonomousTurn) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
return THINKING_APPLIES_NEXT_TURN_NOTICE;
}
}
@@ -347,12 +347,12 @@ describe("Codex app-server provider", () => {
const session = createSession({ modeId: "auto", thinkingOptionId: "medium" });
await expect(session.setMode("full-access")).resolves.toEqual({
type: "info",
message: "This change applies next turn.",
type: "warning",
message: "Permission mode applies next turn",
});
await expect(session.setThinkingOption?.("high")).resolves.toEqual({
type: "info",
message: "This change applies next turn.",
type: "warning",
message: "Thinking level applies next turn",
});
session.activeForegroundTurnId = null;
@@ -92,7 +92,10 @@ import {
resolveBinaryVersion,
} from "./diagnostic-utils.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../provider-notices.js";
import {
MODE_APPLIES_NEXT_TURN_NOTICE,
THINKING_APPLIES_NEXT_TURN_NOTICE,
} from "../provider-notices.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
function assertChildWithPipes(
@@ -3943,7 +3946,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.currentMode = modeId;
this.cachedRuntimeInfo = null;
if (this.activeForegroundTurnId) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
return MODE_APPLIES_NEXT_TURN_NOTICE;
}
}
@@ -3961,7 +3964,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
if (this.activeForegroundTurnId) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
return THINKING_APPLIES_NEXT_TURN_NOTICE;
}
}
@@ -6376,8 +6379,21 @@ export class CodexAppServerAgentClient implements AgentClient {
}
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
const models = await this.fetchModelsFromAppServer();
return { models, modes: CODEX_MODES };
const [models, autoReviewEnabled] = await Promise.all([
this.fetchModelsFromAppServer(),
this.resolveAutoReviewEnabled(),
]);
return {
models,
defaultModeId: autoReviewEnabled ? "auto-review" : DEFAULT_CODEX_MODE_ID,
modes: autoReviewEnabled
? CODEX_MODES
: CODEX_MODES.filter((mode) => mode.id !== "auto-review"),
};
}
async resolveDefaultModeId(): Promise<string> {
return (await this.resolveAutoReviewEnabled()) ? "auto-review" : DEFAULT_CODEX_MODE_ID;
}
private async fetchModelsFromAppServer(): Promise<AgentModelDefinition[]> {
@@ -140,6 +140,29 @@ describe("JsonlRpcProcess", () => {
}
});
test("null timeout waits past short wall-clock limits until the response arrives", async () => {
const transport = startProcess();
try {
await expect(
transport.request({ type: "echo", value: "slow", delayMs: 80 }, null),
).resolves.toMatchObject({ value: "slow" });
} finally {
await transport.close();
}
});
test("null timeout still rejects when the process is closed", async () => {
const transport = startProcess();
await transport.request({ type: "echo", value: "ready" });
const request = transport.request({ type: "hang" }, null);
const rejection = expect(request).rejects.toThrow("JSONL RPC process is closed");
await transport.close();
await rejection;
});
test("rejects pending requests and publishes stderr when the child exits", async () => {
const transport = startProcess();
const exit = nextExit(transport);
@@ -4,7 +4,14 @@ import type { Logger } from "pino";
import { spawnProcess } from "../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
const DEFAULT_TIMEOUT_MS = 30_000;
/** Default wall-clock timeout for control-plane / short RPC calls. */
export const JSONL_RPC_DEFAULT_TIMEOUT_MS = 30_000;
/**
* Pass as `timeoutMs` to wait only for a response, process death, or `close()`.
* Use for long-running blocking RPCs (e.g. LLM-backed compact).
*/
export const JSONL_RPC_NO_TIMEOUT = null;
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -28,7 +35,7 @@ interface JsonlRpcResponse {
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
timer: NodeJS.Timeout | null;
}
export interface JsonlRpcExit {
@@ -116,7 +123,7 @@ export class JsonlRpcProcess {
startRequest(
command: { type: string; [key: string]: unknown },
timeoutMs = DEFAULT_TIMEOUT_MS,
timeoutMs: number | null = JSONL_RPC_DEFAULT_TIMEOUT_MS,
): { id: string; promise: Promise<unknown> } {
if (this.disposed) {
return {
@@ -127,14 +134,14 @@ export class JsonlRpcProcess {
const id = `req_${this.nextRequestId}`;
this.nextRequestId += 1;
const promise = new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
const timer = createRequestTimeout(timeoutMs, () => {
this.pending.delete(id);
reject(
new Error(
`${this.diagnosticName} request timed out for ${command.type}\n${this.stderrBuffer}`.trim(),
),
);
}, timeoutMs);
});
this.pending.set(id, { resolve, reject, timer });
this.send({ ...command, id });
});
@@ -143,7 +150,7 @@ export class JsonlRpcProcess {
request(
command: { type: string; [key: string]: unknown },
timeoutMs = DEFAULT_TIMEOUT_MS,
timeoutMs: number | null = JSONL_RPC_DEFAULT_TIMEOUT_MS,
): Promise<unknown> {
return this.startRequest(command, timeoutMs).promise;
}
@@ -228,7 +235,9 @@ export class JsonlRpcProcess {
if (!pending) {
return;
}
clearTimeout(pending.timer);
if (pending.timer) {
clearTimeout(pending.timer);
}
this.pending.delete(response.id);
if (!response.success) {
pending.reject(
@@ -247,9 +256,25 @@ export class JsonlRpcProcess {
}
this.disposed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
if (pending.timer) {
clearTimeout(pending.timer);
}
pending.reject(error);
}
this.pending.clear();
}
}
/**
* Schedule a request timeout, or return null when the call should wait
* indefinitely for a response, process exit, or close().
*/
function createRequestTimeout(
timeoutMs: number | null,
onTimeout: () => void,
): NodeJS.Timeout | null {
if (timeoutMs == null || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return null;
}
return setTimeout(onTimeout, timeoutMs);
}
@@ -67,6 +67,13 @@ describe("OMP agent client and session", () => {
});
});
test("preserves max as the selected thinking option", async () => {
const omp = new OmpHarness();
await omp.start({ thinkingOptionId: "max" });
expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "max"]));
});
test("streams a prompt through completion", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -225,7 +232,10 @@ describe("OMP agent client and session", () => {
expect.objectContaining({ name: "review", kind: "skill" }),
]),
);
await expect(omp.setMode("ask")).resolves.toEqual(expect.objectContaining({ type: "info" }));
await expect(omp.setMode("ask")).resolves.toEqual({
type: "warning",
message: "Start a new OMP session to change approval mode",
});
});
test("rewinds natively, interrupts, and shuts down", async () => {
@@ -136,7 +136,8 @@ const OMP_THINKING_OPTIONS: ReadonlyArray<{
{ id: "low", label: "Low", description: "Faster reasoning" },
{ id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true },
{ id: "high", label: "High", description: "Deeper reasoning" },
{ id: "xhigh", label: "XHigh", description: "Maximum reasoning" },
{ id: "xhigh", label: "XHigh", description: "Extra-high reasoning" },
{ id: "max", label: "Max", description: "Maximum reasoning" },
] as const;
export interface OmpAgentClientOptions {
@@ -269,7 +270,8 @@ function isOmpThinkingLevel(value: string | null | undefined): value is OmpThink
value === "low" ||
value === "medium" ||
value === "high" ||
value === "xhigh"
value === "xhigh" ||
value === "max"
);
}
@@ -1082,9 +1084,8 @@ export class OmpAgentSession implements AgentSession {
throw new Error(`Invalid OMP mode '${modeId}'`);
}
return {
type: "info",
message:
"OMP approval mode is set when the agent launches. Start a new OMP session to use a different mode.",
type: "warning",
message: "Start a new OMP session to change approval mode",
};
}
@@ -1,6 +1,14 @@
import { z } from "zod";
export const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
export const OmpThinkingLevelSchema = z.enum([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
export const OmpImageContentSchema = z
.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() })
@@ -37,6 +37,28 @@ describe("opencode agent commands E2E", () => {
}
}, 60_000);
test("listing commands resumes an idle-collected agent", async () => {
const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"),
cwd: "/tmp",
title: "Collected OpenCode Commands Test Agent",
});
const collection = await ctx.daemon.daemon.agentManager.collectIdleAgents({
cutoff: new Date(Date.now() + 1_000),
protectedAgentIds: new Set(),
});
expect(collection.failures).toEqual([]);
expect(collection.collected.map((entry) => entry.agentId)).toContain(agent.id);
expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)).toBeNull();
const result = await ctx.client.listCommands({ agentId: agent.id });
expect(result.error).toBeNull();
expect(result.commands.length).toBeGreaterThan(0);
expect(ctx.daemon.daemon.agentManager.getAgent(agent.id)?.id).toBe(agent.id);
}, 60_000);
test("sendMessage executes a slash command without arguments", async () => {
const agent = await ctx.client.createAgent({
...getFullAccessConfig("opencode"),
@@ -194,7 +194,8 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
test("creates a session with valid id and provider", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient());
const openCode = new TestOpenCodeClient();
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
@@ -206,9 +207,49 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
expect(session.provider).toBe("opencode");
await session.close();
expect(openCode.calls.sessionAbort).toEqual([{ sessionID: "session-1", directory: cwd }]);
expect(openCode.calls.sessionUpdate).toEqual([]);
rmSync(cwd, { recursive: true, force: true });
}, 60_000);
test("archives and unarchives the durable native session through client hooks", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
const archiveClient = new TestOpenCodeClient();
const unarchiveClient = new TestOpenCodeClient();
runtime.enqueueClient(archiveClient);
runtime.enqueueClient(unarchiveClient);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const handle = {
provider: "opencode" as const,
sessionId: "session-1",
metadata: { cwd },
};
await client.archiveNativeSession(handle);
await client.unarchiveNativeSession(handle);
expect(archiveClient.calls.sessionUpdate).toEqual([
{
sessionID: "session-1",
directory: cwd,
time: { archived: expect.any(Number) },
},
]);
expect(unarchiveClient.calls.sessionUpdate).toEqual([
{
sessionID: "session-1",
directory: cwd,
time: { archived: null },
},
]);
expect(runtime.acquisitions.every((acquisition) => acquisition.releaseCount === 1)).toBe(true);
rmSync(cwd, { recursive: true, force: true });
});
test("single turn completes with streaming deltas", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
@@ -713,65 +754,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
});
describe("OpenCode adapter context-window normalization", () => {
test("close reconciliation aborts then archives upstream session", async () => {
const abort = vi.fn().mockResolvedValue({ data: true, error: undefined });
const update = vi.fn().mockResolvedValue({
data: { id: "session-1", time: { archived: Date.now() } },
error: undefined,
});
await __openCodeInternals.reconcileOpenCodeSessionClose({
client: {
session: {
abort,
update,
},
} as never,
sessionId: "session-1",
directory: "/tmp/project",
logger: createTestLogger(),
});
expect(abort).toHaveBeenCalledWith({
sessionID: "session-1",
directory: "/tmp/project",
});
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith({
sessionID: "session-1",
directory: "/tmp/project",
time: {
archived: expect.any(Number),
},
});
});
test("close reconciliation still archives when abort returns an error", async () => {
const abort = vi.fn().mockResolvedValue({
data: undefined,
error: { data: {}, errors: [], success: false },
});
const update = vi.fn().mockResolvedValue({
data: { id: "session-1", time: { archived: Date.now() } },
error: undefined,
});
await __openCodeInternals.reconcileOpenCodeSessionClose({
client: {
session: {
abort,
update,
},
} as never,
sessionId: "session-1",
directory: "/tmp/project",
logger: createTestLogger(),
});
expect(abort).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledTimes(1);
});
test("builds OpenCode file parts for image prompt blocks", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([
@@ -2345,6 +2327,7 @@ describe("OpenCode persisted sessions", () => {
describe("OpenCode provider subagent contract", () => {
async function createAdoptedChildSession(): Promise<{
readonly runtime: TestOpenCodeHarness;
readonly provider: OpenCodeAgentClient;
readonly parent: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>>;
readonly child: Awaited<ReturnType<OpenCodeAgentClient["resumeSession"]>>;
readonly childClient: TestOpenCodeClient;
@@ -2387,9 +2370,36 @@ describe("OpenCode provider subagent contract", () => {
undefined,
{ env: { PASEO_AGENT_ID: "child-agent" } },
);
return { runtime, parent, child, childClient };
return { runtime, provider: client, parent, child, childClient };
}
test("archives an adopted child on the parent's registered OpenCode server", async () => {
const { runtime, provider, parent, child } = await createAdoptedChildSession();
const archiveClient = new TestOpenCodeClient();
runtime.enqueueClient(archiveClient);
await provider.archiveNativeSession({
provider: "opencode",
sessionId: "ses_child_external",
metadata: { cwd: "/workspace/repo" },
});
expect(archiveClient.calls.sessionUpdate).toEqual([
{
sessionID: "ses_child_external",
directory: "/workspace/repo",
time: { archived: expect.any(Number) },
},
]);
expect(runtime.acquisitions.at(-1)).toEqual({
kind: "existing",
url: runtime.server.url,
releaseCount: 1,
});
await child.close();
await parent.close();
});
test("resumes an adopted child on the parent's registered OpenCode server", async () => {
const runtime = new TestOpenCodeHarness();
const parentClient = new TestOpenCodeClient();
@@ -3033,11 +3043,11 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "timeline",
id: "ses_child_with_history",
item: {
item: expect.objectContaining({
type: "assistant_message",
text: "Persisted child result.",
messageId: "msg_child_history",
},
}),
timestamp: "1970-01-01T00:00:02.000Z",
},
}),
@@ -3111,11 +3121,11 @@ describe("OpenCode provider subagent contract", () => {
event: expect.objectContaining({
type: "timeline",
id: "ses_child_hydrating",
item: {
item: expect.objectContaining({
type: "assistant_message",
text: "Hydration did not lose this.",
messageId: "msg_child_hydrating",
},
}),
}),
}),
),
@@ -431,7 +431,7 @@ function isOpenCodeNotFoundError(error: unknown): boolean {
);
}
async function reconcileOpenCodeSessionClose(params: {
async function abortOpenCodeSession(params: {
client: Pick<OpencodeClient, "session">;
sessionId: string;
directory: string;
@@ -462,31 +462,6 @@ async function reconcileOpenCodeSessionClose(params: {
"Failed to abort OpenCode session during close",
);
}
try {
const response = await client.session.update({
sessionID: sessionId,
directory,
time: { archived: Date.now() },
});
if (response.error && !isOpenCodeNotFoundError(response.error)) {
logger.warn(
{
sessionId,
error: toDiagnosticErrorMessage(response.error),
},
"Failed to archive OpenCode session during close",
);
}
} catch (error) {
logger.warn(
{
sessionId,
error: toDiagnosticErrorMessage(error),
},
"Failed to archive OpenCode session during close",
);
}
}
function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
@@ -1254,7 +1229,6 @@ export const __openCodeInternals = {
hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey,
reconcileOpenCodeSessionClose,
resolveOpenCodeModelLookupKeyFromAssistantMessage,
resolveOpenCodeSelectedModelContextWindow,
isSelectableOpenCodeAgent,
@@ -1353,7 +1327,7 @@ export class OpenCodeAgentClient implements AgentClient {
url,
);
} catch (error) {
acquisition.release();
await acquisition.release();
throw error;
}
}
@@ -1407,7 +1381,7 @@ export class OpenCodeAgentClient implements AgentClient {
registeredAcquisition !== null,
);
} catch (error) {
acquisition.release();
await acquisition.release();
throw error;
}
}
@@ -1439,7 +1413,7 @@ export class OpenCodeAgentClient implements AgentClient {
]);
return { models, modes };
} finally {
acquisition.release();
await acquisition.release();
}
}
@@ -1455,7 +1429,7 @@ export class OpenCodeAgentClient implements AgentClient {
try {
return await listOpenCodeCommandsFromSdk(client, openCodeConfig.cwd);
} finally {
acquisition.release();
await acquisition.release();
}
}
@@ -1476,7 +1450,7 @@ export class OpenCodeAgentClient implements AgentClient {
try {
return await collectOpenCodeImportableSessionsFromSdk(client, options);
} finally {
acquisition.release();
await acquisition.release();
}
}
@@ -1512,7 +1486,57 @@ export class OpenCodeAgentClient implements AgentClient {
},
});
} finally {
acquisition.release();
await acquisition.release();
}
}
async archiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
await this.setNativeSessionArchived(handle, Date.now());
}
async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise<void> {
await this.setNativeSessionArchived(handle, null);
}
private async setNativeSessionArchived(
handle: AgentPersistenceHandle,
archivedAt: number | null,
): Promise<void> {
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
if (!metadata.cwd) {
throw new Error("OpenCode native archive update requires the original working directory");
}
const registeredServerUrl = getOpenCodeChildSessionServerUrl(handle.sessionId);
const acquisition =
(registeredServerUrl ? this.serverManager.acquireExisting(registeredServerUrl) : null) ??
(await this.serverManager.acquireCurrent());
const client = this.createOpenCodeClient({
baseUrl: acquisition.server.url,
directory: metadata.cwd,
});
try {
// OpenCode accepts null to clear the archive timestamp, but this SDK
// release's generated request type still exposes only number.
const updateSession = client.session.update.bind(client.session) as (parameters: {
sessionID: string;
directory?: string;
time?: { archived?: number | null };
}) => ReturnType<typeof client.session.update>;
const response = readOpenCodeRecord(
await updateSession({
sessionID: handle.sessionId,
directory: metadata.cwd,
time: { archived: archivedAt },
}),
);
if (response?.error) {
throw new Error(
`Failed to ${archivedAt === null ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`,
);
}
} finally {
await acquisition.release();
}
}
@@ -2897,7 +2921,7 @@ class OpenCodeAgentSession implements AgentSession {
private childHydrationCompleted = false;
private readonly unrelatedSessionIds = new Set<string>();
private selectedModelContextWindowMaxTokens: number | undefined;
private releaseServer: (() => void) | null;
private releaseServer: (() => Promise<void>) | null;
private eventStreamAbortController: AbortController | null = null;
private eventStreamReady: Deferred<void> | null = null;
private eventStreamTask: Promise<void> | null = null;
@@ -2911,7 +2935,7 @@ class OpenCodeAgentSession implements AgentSession {
sessionId: string,
logger: Logger,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void,
releaseServer?: () => Promise<void>,
persistSession = true,
private readonly agentId?: string,
private readonly serverUrl?: string,
@@ -3891,7 +3915,7 @@ class OpenCodeAgentSession implements AgentSession {
this.eventStreamReady = null;
this.eventStreamTask = null;
this.subscribers.clear();
await reconcileOpenCodeSessionClose({
await abortOpenCodeSession({
client: this.client,
sessionId: this.sessionId,
directory: this.config.cwd,
@@ -3900,7 +3924,7 @@ class OpenCodeAgentSession implements AgentSession {
await this.deleteProviderSessionIfEphemeral();
this.activeForegroundTurnId = null;
} finally {
this.releaseServer?.();
await this.releaseServer?.();
this.releaseServer = null;
}
}
@@ -37,10 +37,10 @@ describe("OpenCodeServerManager generations", () => {
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
expect(runtime.terminatedPorts).toEqual([]);
newAcquisition.release();
oldAcquisition.release();
await newAcquisition.release();
await oldAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4101]);
expect(runtime.terminatedPorts).toEqual([4102, 4101]);
});
test("new acquisitions after rotation use the new server", async () => {
@@ -48,22 +48,22 @@ describe("OpenCodeServerManager generations", () => {
const oldAcquisition = await manager.acquireCurrent();
const rotatedAcquisition = await manager.acquireNew();
rotatedAcquisition.release();
const nextAcquisition = await manager.acquireCurrent();
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
expect(runtime.terminatedPorts).toEqual([]);
nextAcquisition.release();
oldAcquisition.release();
await rotatedAcquisition.release();
expect(runtime.terminatedPorts).toEqual([]);
await nextAcquisition.release();
await oldAcquisition.release();
});
test("concurrent new-server acquisitions share one fresh generation", async () => {
const { manager, runtime } = createTestManager([4251, 4252, 4253]);
const initialAcquisition = await manager.acquireCurrent();
initialAcquisition.release();
await initialAcquisition.release();
const [modelsAcquisition, modesAcquisition] = await Promise.all([
manager.acquireNew(),
@@ -74,8 +74,8 @@ describe("OpenCodeServerManager generations", () => {
expect(modesAcquisition.server.url).toBe("http://127.0.0.1:4252");
expect(runtime.launchedPorts).toEqual([4251, 4252]);
modesAcquisition.release();
modelsAcquisition.release();
await modesAcquisition.release();
await modelsAcquisition.release();
});
test("release is idempotent", async () => {
@@ -83,12 +83,12 @@ describe("OpenCodeServerManager generations", () => {
const oldAcquisition = await manager.acquireCurrent();
const newAcquisition = await manager.acquireNew();
newAcquisition.release();
await newAcquisition.release();
oldAcquisition.release();
oldAcquisition.release();
await oldAcquisition.release();
await oldAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4301]);
expect(runtime.terminatedPorts).toEqual([4302, 4301]);
});
test("shutdown kills current and retired servers", async () => {
@@ -152,16 +152,16 @@ describe("OpenCodeServerManager generations", () => {
const dedicatedStart = manager.acquireDedicated({ TEST_ENV: "custom" });
await runtime.settle();
currentAcquisition.release();
expect(runtime.terminatedPorts).toEqual([]);
await currentAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4473]);
runtime.processForPort(4474).announceListening();
const dedicatedAcquisition = await dedicatedStart;
expect(dedicatedAcquisition.server.url).toBe("http://127.0.0.1:4474");
dedicatedAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4474]);
await dedicatedAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4473, 4474]);
});
test("acquireExisting keeps a retired dedicated server alive until every reference releases", async () => {
@@ -172,10 +172,10 @@ describe("OpenCodeServerManager generations", () => {
expect(existingAcquisition?.server.url).toBe("http://127.0.0.1:4475");
dedicatedAcquisition.release();
await dedicatedAcquisition.release();
expect(runtime.terminatedPorts).toEqual([]);
existingAcquisition?.release();
await existingAcquisition?.release();
expect(runtime.terminatedPorts).toEqual([4475]);
});
@@ -187,7 +187,7 @@ describe("OpenCodeServerManager generations", () => {
expect(manager.acquireExisting("http://127.0.0.1:9999")).toBe(null);
acquisition.release();
await acquisition.release();
expect(runtime.terminatedPorts).toEqual([4476]);
expect(manager.acquireExisting(url)).toBe(null);
});
@@ -197,12 +197,26 @@ describe("OpenCodeServerManager generations", () => {
const firstAcquisition = await manager.acquireCurrent();
const secondAcquisition = await manager.acquireNew();
secondAcquisition.release();
await secondAcquisition.release();
const thirdAcquisition = await manager.acquireNew();
thirdAcquisition.release();
firstAcquisition.release();
await thirdAcquisition.release();
await firstAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4502, 4501]);
expect(runtime.terminatedPorts).toEqual([4502, 4503, 4501]);
});
test("final release detaches the terminating generation before a concurrent acquire", async () => {
const { manager, runtime } = createTestManager([4551, 4552]);
const first = await manager.acquireCurrent();
const release = first.release();
const next = await manager.acquireCurrent();
await release;
expect(next.server.url).toBe("http://127.0.0.1:4552");
expect(runtime.terminatedPorts).toEqual([4551]);
await next.release();
});
});
@@ -258,7 +272,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
}),
]);
acquisition.release();
await acquisition.release();
await manager.shutdown();
} finally {
rmSync(tempDir, { recursive: true, force: true });
@@ -21,11 +21,10 @@ const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
export interface OpenCodeServerAcquisition {
server: { port: number; url: string };
release: () => void;
release: () => Promise<void>;
}
export interface OpenCodeServerManagerLike {
ensureRunning(): Promise<{ port: number; url: string }>;
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
acquireNew(): Promise<OpenCodeServerAcquisition>;
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
@@ -135,12 +134,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
process.on("SIGINT", cleanup);
}
async ensureRunning(): Promise<{ port: number; url: string }> {
const acquisition = await this.acquireCurrent();
acquisition.release();
return acquisition.server;
}
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
const server = await this.getCurrentServer();
return this.acquireServer(server);
@@ -160,7 +153,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
await server.ready;
return acquisition;
} catch (error) {
acquisition.release();
await acquisition.release();
throw error;
}
}
@@ -188,20 +181,37 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
server.refCount += 1;
let released = false;
let releasePromise: Promise<void> | null = null;
return {
server: { port: server.port, url: server.url },
release: () => {
if (released) {
return;
release: async () => {
if (releasePromise) {
return releasePromise;
}
released = true;
server.refCount -= 1;
this.cleanupRetiredServers();
releasePromise = this.releaseServer(server);
return releasePromise;
},
};
}
private async releaseServer(server: OpenCodeServerGeneration): Promise<void> {
server.refCount = Math.max(0, server.refCount - 1);
if (server.refCount > 0) {
return;
}
if (this.currentServer === server) {
this.currentServer = null;
server.retired = true;
}
if (!server.retired) {
return;
}
this.retiredServers.delete(server);
await this.killServer(server);
}
private async getNewServer(): Promise<OpenCodeServerGeneration> {
if (this.newServerPromise) {
return this.newServerPromise;
@@ -261,14 +271,14 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
existing.retired = true;
this.retiredServers.add(existing);
this.currentServer = null;
this.cleanupRetiredServers();
await this.cleanupRetiredServers();
}
if (this.startPromise) {
const pending = await this.startPromise;
pending.retired = true;
this.retiredServers.add(pending);
this.currentServer = null;
this.cleanupRetiredServers();
await this.cleanupRetiredServers();
}
}
@@ -417,13 +427,15 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
this.retiredServers.clear();
}
private cleanupRetiredServers(): void {
private async cleanupRetiredServers(): Promise<void> {
const cleanup: Promise<void>[] = [];
for (const server of Array.from(this.retiredServers)) {
if (server.refCount === 0) {
this.retiredServers.delete(server);
void this.killServer(server);
cleanup.push(this.killServer(server));
}
}
await Promise.all(cleanup);
}
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
@@ -10,12 +10,6 @@ export interface TestOpenCodeServerAcquisition {
export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
readonly acquisitions: TestOpenCodeServerAcquisition[] = [];
readonly server = { port: 1234, url: "http://127.0.0.1:1234" };
ensureRunningCount = 0;
async ensureRunning(): Promise<{ port: number; url: string }> {
this.ensureRunningCount += 1;
return this.server;
}
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
return this.recordAcquisition({ kind: "current" });
@@ -47,7 +41,7 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
this.acquisitions.push(acquisition);
return {
server: this.server,
release: () => {
release: async () => {
acquisition.released = true;
},
};
@@ -53,16 +53,12 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
this.acquisitions.push(acquisition);
return {
server: this.server,
release: () => {
release: async () => {
acquisition.releaseCount += 1;
},
};
}
async ensureRunning(): Promise<{ port: number; url: string }> {
return this.server;
}
readonly createClient = (options: { baseUrl: string; directory: string }): OpencodeClient => {
this.clientCreations.push(options);
const client = this.clients.shift() ?? new TestOpenCodeClient();
@@ -2,7 +2,7 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { PiCliRuntime } from "./cli-runtime.js";
import type { PiRuntimeLaunch } from "./runtime.js";
@@ -47,10 +47,7 @@ function createRuntime(
});
}
function replyToCommands(
child: PiChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
function onPiCommand(child: PiChild, handler: (command: Record<string, unknown>) => void): void {
let buffer = "";
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
@@ -59,34 +56,68 @@ function replyToCommands(
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
try {
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: false,
error: message,
})}\n`,
);
}
handler(JSON.parse(line) as Record<string, unknown>);
}
});
}
function replyToCommands(
child: PiChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
onPiCommand(child, (command) => {
try {
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: false,
error: message,
})}\n`,
);
}
});
}
function capturePendingCommand(child: PiChild, type: string): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
onPiCommand(child, (command) => {
if (command.type === type) {
resolve(command);
}
});
});
}
function writePiResponse(
child: PiChild,
command: Record<string, unknown>,
data: unknown = {},
): void {
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data,
})}\n`,
);
}
describe("PiCliRuntime", () => {
test("starts pi in rpc mode and resolves command responses", async () => {
const child = createPiChild();
@@ -282,6 +313,51 @@ describe("PiCliRuntime", () => {
await rejection;
});
test("compact waits beyond the default control-plane timeout for a late response", async () => {
vi.useFakeTimers();
const child = createPiChild();
const pendingCompact = capturePendingCommand(child, "compact");
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
try {
const compactPromise = session.compact("focus on tests");
const compactCommand = await pendingCompact;
await vi.advanceTimersByTimeAsync(35_000);
expect(compactCommand).toMatchObject({
type: "compact",
customInstructions: "focus on tests",
id: expect.any(String),
});
writePiResponse(child, compactCommand, {
summary: "done",
firstKeptEntryId: "entry-1",
tokensBefore: 120_000,
estimatedTokensAfter: 20_000,
});
await expect(compactPromise).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
await session.close();
}
});
test("compact without a wall-clock timeout rejects when the session closes", async () => {
const child = createPiChild();
const pendingCompact = capturePendingCommand(child, "compact");
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const compactPromise = session.compact();
await pendingCompact;
const rejection = expect(compactPromise).rejects.toThrow("Pi RPC session is closed");
await session.close();
await rejection;
});
test("disposes the Pi process", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
@@ -2,7 +2,11 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
import {
JSONL_RPC_NO_TIMEOUT,
JsonlRpcProcess,
type JsonlRpcLaunch,
} from "../jsonl-rpc-process.js";
import {
buildPiLaunch,
type PiRuntime,
@@ -26,6 +30,14 @@ const DEFAULT_PI_COMMAND: [string, ...string[]] = [
];
const DEFAULT_COMMANDS_RPC_NAME = "get_commands";
/**
* Pi RPC timeout policy:
* - Control-plane / accept-and-stream (`prompt`, `get_state`, `abort`, …): default 30s
* - Long-running blocking LLM jobs (`compact`): no wall-clock timeout — complete on
* response, process death, or session close (`JsonlRpcProcess.failAll` / `close`).
*/
const PI_COMPACT_REQUEST_TIMEOUT_MS = JSONL_RPC_NO_TIMEOUT;
export interface PiCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
@@ -112,10 +124,16 @@ class PiCliRuntimeSession implements PiRuntimeSession {
}
async compact(customInstructions?: string): Promise<void> {
await this.request({
type: "compact",
...(customInstructions ? { customInstructions } : {}),
});
// Compact is a blocking LLM summarization job; Pi only returns the RPC
// response after the summary is written. A control-plane 30s timeout falsely
// fails long sessions while the real compact continues (issue #1946).
await this.request(
{
type: "compact",
...(customInstructions ? { customInstructions } : {}),
},
PI_COMPACT_REQUEST_TIMEOUT_MS,
);
}
async setAutoCompaction(enabled: boolean): Promise<void> {
@@ -135,7 +153,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
return data.messages ?? [];
}
async getAvailableModels(timeoutMs?: number): Promise<PiModel[]> {
async getAvailableModels(timeoutMs?: number | null): Promise<PiModel[]> {
const data = (await this.request({ type: "get_available_models" }, timeoutMs)) as {
models?: PiModel[];
};
@@ -208,7 +226,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
await this.process.close(new Error("Pi RPC session is closed"));
}
request(command: PiRpcCommand, timeoutMs?: number): Promise<unknown> {
request(command: PiRpcCommand, timeoutMs?: number | null): Promise<unknown> {
return this.process.request(command, timeoutMs);
}
@@ -51,12 +51,15 @@ export interface PiRuntimeSession {
abort(): Promise<void>;
getState(): Promise<PiSessionState>;
getMessages(): Promise<PiAgentMessage[]>;
getAvailableModels(timeoutMs?: number): Promise<PiModel[]>;
getAvailableModels(timeoutMs?: number | null): Promise<PiModel[]>;
setModel(provider: string, modelId: string): Promise<PiModel>;
setThinkingLevel(level: string): Promise<void>;
getSessionStats(): Promise<PiSessionStats>;
getCommands(): Promise<PiRpcSlashCommand[]>;
request(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<unknown>;
request(
command: { type: string; [key: string]: unknown },
timeoutMs?: number | null,
): Promise<unknown>;
sendRawFrame(frame: object & { type: string }): void;
respondToExtensionUiRequest(
id: string,
@@ -227,7 +227,7 @@ export class FakePiSession implements PiRuntimeSession {
return this.messages;
}
async getAvailableModels(_timeoutMs?: number): Promise<PiModel[]> {
async getAvailableModels(_timeoutMs?: number | null): Promise<PiModel[]> {
return this.models;
}
File diff suppressed because it is too large Load Diff
@@ -486,8 +486,6 @@ describe("archiveIfSafe", () => {
}),
{
scope: { kind: "workspace", workspaceId: "ws-auto-archive" },
repoRoot: "/tmp/repo",
paseoWorktreesBaseRoot: undefined,
requestId: "auto-archive-on-merge",
},
);
@@ -138,8 +138,6 @@ export async function archiveIfSafe(input: {
},
{
scope: { kind: "workspace", workspaceId },
repoRoot: ownership.repoRoot ?? null,
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
requestId: "auto-archive-on-merge",
},
);
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -18,7 +19,9 @@ describe("bootstrap provider availability", () => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
tempRoots
.splice(0)
.map((root) => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })),
);
});
@@ -26,12 +29,13 @@ describe("bootstrap provider availability", () => {
const { createPaseoDaemon } = await import("./bootstrap.js");
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-"));
tempRoots.push(root);
const binDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-provider-bin-"));
tempRoots.push(binDir);
process.env.PATH = binDir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
const gitPath = execFileSync(process.platform === "win32" ? "where" : "which", ["git"], {
encoding: "utf8",
})
.split(/\r?\n/)[0]
.trim();
process.env.PATH = path.dirname(gitPath);
expect(execFileSync("git", ["--version"], { encoding: "utf8" })).toMatch(/git version/i);
const paseoHome = path.join(root, ".paseo");
const staticDir = path.join(root, "static");
const agentStoragePath = path.join(paseoHome, "agents");
@@ -0,0 +1,56 @@
import { expect, test } from "vitest";
import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js";
test("reconciliation emits workspace updates when observer sync fails", async () => {
const emittedWorkspaceIds: string[][] = [];
const syncFailure = new Error("workspace observer unavailable");
await fanOutReconciledWorkspaceUpdates({
sessions: [
{
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {
throw syncFailure;
},
emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => {
emittedWorkspaceIds.push(Array.from(workspaceIds));
},
},
],
workspaceIds: ["ws-reclassified"],
logger: { warn: () => {} },
});
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
});
test("reconciliation isolates workspace update failures between sessions", async () => {
const emittedWorkspaceIds: string[][] = [];
const warnings: unknown[] = [];
await fanOutReconciledWorkspaceUpdates({
sessions: [
{
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {},
emitWorkspaceUpdatesForExternalWorkspaceIds: async () => {
throw new Error("session closed");
},
},
{
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {},
emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => {
emittedWorkspaceIds.push(Array.from(workspaceIds));
},
},
],
workspaceIds: ["ws-reclassified"],
logger: {
warn: (context) => {
warnings.push(context);
},
},
});
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
expect(warnings).toHaveLength(1);
});
+192 -36
View File
@@ -89,12 +89,42 @@ function formatListenTarget(listenTarget: ListenTarget | null): string | null {
return listenTarget.path;
}
export async function fanOutReconciledWorkspaceUpdates(input: {
sessions: Iterable<{
syncWorkspaceGitObserversForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void>;
emitWorkspaceUpdatesForExternalWorkspaceIds(
workspaceIds: Iterable<string>,
options: { skipReconcile: boolean },
): Promise<void>;
}>;
workspaceIds: readonly string[];
logger: Pick<Logger, "warn">;
}): Promise<void> {
await Promise.all(
Array.from(input.sessions, async (session) => {
try {
await session.syncWorkspaceGitObserversForExternalWorkspaceIds(input.workspaceIds);
} catch (error) {
input.logger.warn(
{ err: error },
"Failed to sync workspace Git observers after reconciliation",
);
}
try {
await session.emitWorkspaceUpdatesForExternalWorkspaceIds(input.workspaceIds, {
skipReconcile: true,
});
} catch (error) {
input.logger.warn({ err: error }, "Failed to emit workspace updates after reconciliation");
}
}),
);
}
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { createGitHubService } from "../services/github-service.js";
import {
createPaseoWorktree as createRegisteredPaseoWorktree,
createLocalCheckoutWorkspace,
} from "./paseo-worktree-service.js";
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
@@ -172,9 +202,23 @@ import {
createAgentCommand,
type CreateAgentCommandDependencies,
} from "./agent/create-agent/create.js";
import { archiveAgentCommand } from "./agent/lifecycle-command.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
import {
HubRelationshipController,
type HubRelationshipClock,
type HubRelationshipRetryPolicy,
} from "./hub/relationship-controller.js";
import {
DirectHubRelationshipRemote,
type HubRelationshipRemote,
} from "./hub/relationship-remote.js";
import { DaemonExecutions } from "./hub/daemon-executions.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
const IDLE_AGENT_RUNTIME_TTL_MS = 2 * 60 * 1000;
const IDLE_AGENT_RUNTIME_SWEEP_INTERVAL_MS = 15 * 1000;
const DOWNLOAD_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
@@ -404,6 +448,13 @@ export interface PaseoDaemon {
getListenTarget(): ListenTarget | null;
}
export interface PaseoDaemonDependencies {
hubRelationshipRemote?: HubRelationshipRemote;
hubRelationshipClock?: HubRelationshipClock;
hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy;
createHubDaemonId?: () => string;
}
function createBootstrapManagedProcessRegistry(
config: Pick<PaseoDaemonConfig, "paseoHome" | "managedProcesses">,
logger: Logger,
@@ -481,6 +532,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
export async function createPaseoDaemon(
config: PaseoDaemonConfig,
rootLogger: Logger,
dependencies: PaseoDaemonDependencies = {},
): Promise<PaseoDaemon> {
const logger = rootLogger.child({ module: "bootstrap" });
const bootstrapStart = performance.now();
@@ -547,7 +599,7 @@ export async function createPaseoDaemon(
serviceProxy,
onChange: createScriptStatusEmitter({
sessions: () =>
wsServer?.listActiveSessions().map((session) => ({
wsServer?.listTrustedSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message),
})) ?? [],
serviceProxy,
@@ -744,6 +796,12 @@ export async function createPaseoDaemon(
forgeOverrides: { github },
},
});
const workspaceProvisioning = createWorkspaceProvisioningService({
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
const providerSnapshotManager = new ProviderSnapshotManager({
logger: providerSnapshotLogger,
@@ -788,21 +846,19 @@ export async function createPaseoDaemon(
workspaceRegistry,
logger,
workspaceGitService,
onProjectUpdate: (update) => wsServer?.publishProjectUpdate(update),
onWorkspacesChanged: async (workspaceIds) => {
await fanOutReconciledWorkspaceUpdates({
sessions: wsServer?.listTrustedSessions() ?? [],
workspaceIds,
logger,
});
},
});
await workspaceReconciliation.start();
void workspaceReconciliation.runOnce().catch((error) => {
logger.warn({ err: error }, "Initial workspace reconciliation failed");
});
void (async () => {
try {
const result = await workspaceReconciliation.runOnce();
logger.info(
{
elapsed: elapsed(),
changeCount: result.changesApplied.length,
},
"Workspace registries reconciled",
);
} catch (error) {
logger.error({ err: error }, "Background workspace reconciliation failed");
}
})();
await chatService.initialize();
logger.info({ elapsed: elapsed() }, "Chat service initialized");
const checkoutDiffManager = new CheckoutDiffManager({
@@ -811,7 +867,7 @@ export async function createPaseoDaemon(
workspaceGitService,
});
const archiveWorkspaceRecordExternal = async (workspaceId: string) => {
const sessions = wsServer?.listActiveSessions() ?? [];
const sessions = wsServer?.listTrustedSessions() ?? [];
if (sessions.length > 0) {
await Promise.all(
sessions.map((session) => session.archiveWorkspaceRecordForExternalMutation(workspaceId)),
@@ -833,9 +889,9 @@ export async function createPaseoDaemon(
cwd: string,
firstAgentContext?: FirstAgentContext,
): Promise<string> => {
const workspace = await createLocalCheckoutWorkspace(
{ cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) },
{ projectRegistry, workspaceRegistry, workspaceGitService },
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
cwd,
resolveFirstAgentPromptTitle(firstAgentContext),
);
if (firstAgentContext) {
workspaceAutoName.scheduleForDirectory({
@@ -854,24 +910,27 @@ export async function createPaseoDaemon(
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
kind: workspace.kind,
worktreeRoot: workspace.worktreeRoot,
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
mainRepoRoot: workspace.mainRepoRoot,
}));
};
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
for (const session of wsServer?.listTrustedSessions() ?? []) {
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
}
};
const clearWorkspaceArchivingExternal = (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
for (const session of wsServer?.listTrustedSessions() ?? []) {
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
}
};
const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable<string>) => {
const workspaceIdList = Array.from(workspaceIds);
await Promise.all(
(wsServer?.listActiveSessions() ?? []).map((session) =>
(wsServer?.listTrustedSessions() ?? []).map((session) =>
session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList),
),
);
@@ -942,15 +1001,14 @@ export async function createPaseoDaemon(
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
}
: {}),
projectRegistry,
workspaceRegistry,
workspaceGitService,
workspaceProvisioning,
});
},
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
?.listTrustedSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
@@ -989,6 +1047,57 @@ export async function createPaseoDaemon(
};
const createAgent = (input: Parameters<typeof createAgentCommand>[1]) =>
createAgentCommand(createAgentCommandDependencies, input);
const hubAgentLifecycle = new CreateAgentLifecycleDispatch({
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
agentManager,
agentStorage,
github,
workspaceGitService,
createPaseoWorktreeWorkflow: createPaseoWorktreeForTools,
archiveAgentForClose: (agentId) =>
archiveAgentCommand({ agentManager, agentStorage, logger }, agentId),
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emit: emitExternalSessionMessage,
emitAgentRemove: () => undefined,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
killTerminalsForWorkspace: (workspaceId) =>
killTerminalsForWorkspace({ terminalManager, sessionLogger: logger }, workspaceId),
logger,
});
const hubRelationships = new HubRelationshipController({
paseoHome: config.paseoHome,
serverId,
daemonPublicKey: daemonKeyPair.publicKeyB64,
logger,
remote: dependencies.hubRelationshipRemote ?? new DirectHubRelationshipRemote(),
clock: dependencies.hubRelationshipClock,
retryPolicy: dependencies.hubRelationshipRetryPolicy,
createDaemonId: dependencies.createHubDaemonId,
attachSocket: async (socket, options) => {
if (!wsServer) throw new Error("WebSocket server is not running");
await wsServer.attachHubSocket(socket, options);
},
createExecutionAgents: (daemonId) =>
new DaemonExecutions({
daemonId,
agentManager,
agentStorage,
createAgent,
registerAutoArchive: ({ agentId, createdWorktree }) =>
hubAgentLifecycle.registerAutoArchiveIfRequested({
autoArchive: true,
agentId,
createdWorktree,
}),
cleanupFailedCreate: (input) =>
hubAgentLifecycle.cleanupCreatedWorktreeAfterFailedAgentCreate(input),
}),
});
const loopService = new LoopService({
paseoHome: config.paseoHome,
@@ -1003,9 +1112,9 @@ export async function createPaseoDaemon(
cwd: string;
firstAgentContext: FirstAgentContext;
}) => {
const workspace = await createLocalCheckoutWorkspace(
{ cwd: input.cwd, title: resolveFirstAgentPromptTitle(input.firstAgentContext) },
{ projectRegistry, workspaceRegistry, workspaceGitService },
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
input.cwd,
resolveFirstAgentPromptTitle(input.firstAgentContext),
);
workspaceAutoName.scheduleForDirectory({
workspaceId: workspace.workspaceId,
@@ -1026,7 +1135,7 @@ export async function createPaseoDaemon(
await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]);
return result;
};
const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => {
const archiveScheduleWorkspaceExternal = async (workspaceId: string) => {
await archiveByScope(
{
paseoHome: config.paseoHome,
@@ -1053,8 +1162,6 @@ export async function createPaseoDaemon(
},
{
scope: { kind: "workspace", workspaceId },
repoRoot,
paseoWorktreesBaseRoot: config.worktreesRoot,
requestId: "schedule-run-finish",
},
);
@@ -1065,11 +1172,44 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
createAgent,
createLocalCheckoutWorkspace: createScheduleLocalWorkspaceExternal,
createDirectoryWorkspace: createScheduleLocalWorkspaceExternal,
createPaseoWorktreeWorkspace: createSchedulePaseoWorktreeExternal,
archiveWorkspace: archiveScheduleWorkspaceExternal,
});
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) => {
try {
await scheduleService.completeForAgent(agentId);
@@ -1105,6 +1245,16 @@ export async function createPaseoDaemon(
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
workspaceRegistry,
projectRegistry,
createDirectoryWorkspace: async (cwd, title, projectId) => {
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
cwd,
title,
projectId,
);
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
return workspace;
},
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,
@@ -1383,7 +1533,9 @@ export async function createPaseoDaemon(
},
serviceProxyPublicBaseUrl,
browserToolsBroker,
hubRelationships,
);
await hubRelationships.start();
if (relayEnabled) {
const offer = await createConnectionOfferV2({
@@ -1444,7 +1596,11 @@ export async function createPaseoDaemon(
};
const stop = async () => {
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
clearInterval(idleAgentCollectionTimer);
await inFlightIdleAgentCollection;
// Freeze both ingress and registration before taking the agent closure snapshot.
wsServer?.prepareForShutdown();
agentManager.prepareForShutdown();
@@ -6,6 +6,7 @@ import path from "node:path";
import { DaemonClient } from "./test-utils/index.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
// The daemon-level workspace contract that `paseo run` depends on: each
// local-backed createWorkspace for a cwd mints a fresh, distinct workspace,
@@ -28,9 +29,10 @@ async function mintLocalWorkspace(client: DaemonClient, cwd: string): Promise<st
return result.workspace.id;
}
test("daemon mints a distinct local workspace per run and stamps agents by id", async () => {
test("daemon resolves human and managed CLI workspace ownership", async () => {
const daemon = await createTestPaseoDaemon();
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-cwd-"));
const otherCwd = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-other-cwd-"));
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
@@ -56,14 +58,13 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
const fetchedFirst = await client.fetchAgent({ agentId: firstAgent.id });
expect(fetchedFirst?.agent.workspaceId).toBe(firstWorkspaceId);
// A second bare run in the SAME cwd mints a DISTINCT workspace; each run
// owns its own workspace rather than reattaching to the first.
const secondWorkspaceId = await mintLocalWorkspace(client, cwd);
// A second bare run mints a distinct workspace with its own authoritative cwd.
const secondWorkspaceId = await mintLocalWorkspace(client, otherCwd);
expect(secondWorkspaceId).not.toBe(firstWorkspaceId);
const secondAgent = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
cwd: otherCwd,
workspaceId: secondWorkspaceId,
title: "Second run agent",
});
@@ -80,15 +81,48 @@ test("daemon mints a distinct local workspace per run and stamps agents by id",
const idsBeforeAttach = await workspaceIds(client);
const attachedAgent = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
cwd: path.join(otherCwd, "stale-client-directory"),
workspaceId: firstWorkspaceId,
title: "Attached agent",
});
expect(attachedAgent.workspaceId).toBe(firstWorkspaceId);
expect(attachedAgent.cwd).toBe(cwd);
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
await expect(
client.createAgent({
...getFullAccessConfig("codex"),
cwd,
workspaceId: "wks_missing",
title: "Missing workspace agent",
}),
).rejects.toThrow("Workspace wks_missing not found");
const sameWorkspaceChild = await client.createAgent({
...getFullAccessConfig("codex"),
cwd: otherCwd,
callerAgentId: firstAgent.id,
title: "Same workspace child",
});
expect(sameWorkspaceChild.workspaceId).toBe(firstWorkspaceId);
expect(sameWorkspaceChild.cwd).toBe(firstAgent.cwd);
expect(sameWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id);
expect(await workspaceIds(client)).toEqual(idsBeforeAttach);
const crossWorkspaceChild = await client.createAgent({
...getFullAccessConfig("codex"),
cwd,
workspaceId: secondWorkspaceId,
callerAgentId: firstAgent.id,
title: "Cross workspace child",
});
expect(crossWorkspaceChild.workspaceId).toBe(secondWorkspaceId);
expect(crossWorkspaceChild.cwd).toBe(otherCwd);
expect(crossWorkspaceChild.labels[PARENT_AGENT_ID_LABEL]).toBe(firstAgent.id);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(otherCwd, { recursive: true, force: true });
}
}, 180000);
@@ -1006,6 +1006,7 @@ test("receives server_info on websocket connect", async () => {
expect(serverInfo).not.toBeNull();
expect(serverInfo?.serverId.length).toBeGreaterThan(0);
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
expect(serverInfo?.features?.hubRelationship).toBe(true);
expect(serverInfo?.features?.commitsList).toBe(true);
expect(serverInfo?.desktopManaged).toBe(false);
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
@@ -62,10 +62,10 @@ test("project.add creates a project without creating a workspace", async () => {
expect(added.project).not.toBeNull();
const project = added.project!;
expect(project).toMatchObject({
projectId: repoRoot,
projectRootPath: repoRoot,
projectKind: "git",
});
expect(project.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
const workspaces = await client.fetchWorkspaces({
filter: { projectId: project.projectId },
@@ -73,7 +73,6 @@ test("project.add creates a project without creating a workspace", async () => {
expect(workspaces.entries).toEqual([]);
expect(workspaces.emptyProjects).toEqual([
expect.objectContaining({
projectId: repoRoot,
projectRootPath: repoRoot,
projectKind: "git",
}),
@@ -29,7 +29,7 @@ afterEach(async () => {
cleanupPaths.clear();
});
test("openProject reclassifies an existing directory workspace into its parent git project", async () => {
test("openProject preserves a worktree's exact-root project without rehoming it", async () => {
const previousSupervised = process.env.PASEO_SUPERVISED;
process.env.PASEO_SUPERVISED = "0";
try {
@@ -113,17 +113,13 @@ test("openProject reclassifies an existing directory workspace into its parent g
const persistedWorkspaces = await readRegistry<PersistedWorkspaceRecord>(workspacesPath);
expect(response.error).toBeNull();
expect(response.workspace?.projectId).toBe(repoRoot);
expect(response.workspace?.workspaceKind).toBe("worktree");
expect(response.workspace?.projectId).toBe(worktreeRoot);
expect(persistedProjects.find((project) => project.projectId === repoRoot)?.rootPath).toBe(
repoRoot,
);
expect(
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.projectId,
).toBe(repoRoot);
expect(
persistedWorkspaces.find((workspace) => workspace.workspaceId === worktreeRoot)?.kind,
).toBe("worktree");
).toBe(worktreeRoot);
} finally {
process.env.PASEO_SUPERVISED = previousSupervised;
}
@@ -0,0 +1,123 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdtempSync, realpathSync } from "node:fs";
import { readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, test } from "vitest";
import { withTimeout } from "../../utils/promise-timeout.js";
import { DaemonClient, type DaemonEvent } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { type PersistedProjectRecord } from "../workspace-registry.js";
const cleanupPaths = new Set<string>();
const cleanupDaemons = new Set<TestPaseoDaemon>();
const cleanupClients = new Set<DaemonClient>();
const cleanupListeners = new Set<() => void>();
const execFile = promisify(execFileCallback);
type ProjectUpdatePayload = Extract<DaemonEvent, { type: "project.update" }>["payload"];
function waitForProjectUpdate(
client: DaemonClient,
predicate: (payload: ProjectUpdatePayload) => boolean,
): Promise<ProjectUpdatePayload> {
return new Promise((resolve) => {
const unsubscribe = client.on("project.update", (message) => {
if (!predicate(message.payload)) return;
cleanupListeners.delete(unsubscribe);
unsubscribe();
resolve(message.payload);
});
cleanupListeners.add(unsubscribe);
});
}
afterEach(async () => {
for (const unsubscribe of cleanupListeners) unsubscribe();
cleanupListeners.clear();
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
cleanupClients.clear();
await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined)));
cleanupDaemons.clear();
await Promise.all(
Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })),
);
cleanupPaths.clear();
});
test("an empty project becomes Git without changing its identity or creating a workspace", async () => {
const projectRoot = realpathSync(
mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-")),
);
const paseoHomeRoot = realpathSync(
mkdtempSync(path.join(os.tmpdir(), "paseo-project-becomes-git-home-")),
);
cleanupPaths.add(projectRoot);
cleanupPaths.add(paseoHomeRoot);
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
cleanupDaemons.add(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "project-becomes-git" } });
const added = await client.addProject(projectRoot);
expect(added).toEqual({
requestId: expect.any(String),
project: {
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
projectDisplayName: path.basename(projectRoot),
projectCustomName: null,
projectRootPath: projectRoot,
projectKind: "non_git",
},
error: null,
});
const project = added.project!;
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
const gitProjectUpdate = waitForProjectUpdate(
client,
(payload) =>
payload.kind === "upsert" &&
payload.project.projectId === project.projectId &&
payload.project.projectRootPath === projectRoot &&
payload.project.projectKind === "git",
);
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
const update = await withTimeout({
promise: gitProjectUpdate,
timeoutMs: 10_000,
label: "project.update after git init",
});
expect(update).toEqual({
kind: "upsert",
project: { ...project, projectKind: "git" },
});
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(afterGitInit).toMatchObject({
entries: [],
emptyProjects: [{ ...project, projectKind: "git" }],
});
const persistedProjects = JSON.parse(
await readFile(path.join(daemon.paseoHome, "projects", "projects.json"), "utf8"),
) as PersistedProjectRecord[];
expect(persistedProjects).toContainEqual({
projectId: project.projectId,
rootPath: projectRoot,
kind: "git",
displayName: project.projectDisplayName,
customName: null,
createdAt: expect.any(String),
updatedAt: expect.any(String),
archivedAt: null,
});
}, 30_000);
@@ -0,0 +1,152 @@
import { afterEach, expect, test } from "vitest";
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
let relationship: HubRelationshipHarness | null = null;
afterEach(async () => {
await relationship?.close();
relationship = null;
});
async function launchRelationship(): Promise<HubRelationshipHarness> {
const launched = await HubRelationshipHarness.start();
await launched.beginConnect().result;
launched.connectLatestSocket();
relationship = launched;
return launched;
}
test("sequential replay after reconstruction keeps one durable owned agent", async () => {
const hub = await launchRelationship();
const created = await hub.createOwnedConcurrently();
const reconstructed = await hub.reconstructAndReplay();
expect(reconstructed.replay.agent.id).toBe(created.first.agentId);
expect(reconstructed.replay.agent.status).toBe("closed");
expect(reconstructed.durableAgentCount).toBe(1);
});
test("removing a daemon-owned agent removes its execution association", async () => {
const hub = await launchRelationship();
const created = await hub.createOwnedConcurrently();
const removed = await hub.removeOwnedAgent(created.first.agentId);
expect(removed.durableAgentCount).toBe(0);
});
test("a failed Hub create removes its auto-created worktree", async () => {
const hub = await launchRelationship();
hub.beginOwnedCreate("failed-worktree-create", "failed-worktree-execution", {
modeId: "missing-mode",
worktree: { mode: "branch-off", newBranch: "failed-hub-create" },
});
const response = await hub.ownedCreateResult("failed-worktree-create");
expect(response).toMatchObject({
type: "hub.execution.agent.create.response",
payload: { success: false, executionId: "failed-worktree-execution" },
});
expect(await hub.listedWorktrees()).toHaveLength(1);
expect(await hub.durableOwnedAgentIds()).toEqual([]);
});
test("failed Hub auto-archive creates release their lifecycle subscriptions", async () => {
const hub = await launchRelationship();
const subscriptionBaseline = hub.agentSubscriptionCount();
hub.failProviderPromptStart();
hub.beginOwnedCreate("failed-prompt-create-1", "failed-prompt-execution-1", {
autoArchive: true,
worktree: { mode: "branch-off", newBranch: "failed-prompt-1" },
});
const first = await hub.ownedCreateResult("failed-prompt-create-1");
expect(first).toMatchObject({
type: "hub.execution.agent.create.response",
payload: { success: false, executionId: "failed-prompt-execution-1" },
});
expect(hub.activeOwnedAgentIds()).toEqual([]);
expect(await hub.durableOwnedAgentIds()).toEqual([]);
expect(await hub.listedWorktrees()).toHaveLength(1);
expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline);
hub.failProviderPromptStart();
hub.beginOwnedCreate("failed-prompt-create-2", "failed-prompt-execution-2", {
autoArchive: true,
worktree: { mode: "branch-off", newBranch: "failed-prompt-2" },
});
const second = await hub.ownedCreateResult("failed-prompt-create-2");
expect(second).toMatchObject({
type: "hub.execution.agent.create.response",
payload: { success: false, executionId: "failed-prompt-execution-2" },
});
expect(hub.activeOwnedAgentIds()).toEqual([]);
expect(await hub.durableOwnedAgentIds()).toEqual([]);
expect(await hub.listedWorktrees()).toHaveLength(1);
expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline);
});
test("failed Hub create cleans durable state when provider close rejects", async () => {
const hub = await launchRelationship();
hub.failProviderPromptStart();
hub.failNextProviderSessionClose();
hub.beginOwnedCreate("failed-close-create", "failed-close-execution", {
worktree: { mode: "branch-off", newBranch: "failed-close-worktree" },
});
const response = await hub.ownedCreateResult("failed-close-create");
expect(response).toMatchObject({
type: "hub.execution.agent.create.response",
payload: { success: false, executionId: "failed-close-execution" },
});
expect(hub.activeOwnedAgentIds()).toEqual([]);
expect(await hub.durableOwnedAgentIds()).toEqual([]);
expect(await hub.listedWorktrees()).toHaveLength(1);
});
test("Hub checkout uses the requested branch ref", async () => {
const hub = await launchRelationship();
await hub.createBranch("existing-hub-branch");
hub.beginOwnedCreate("checkout-create", "checkout-execution", {
worktree: { mode: "checkout-branch", branch: "existing-hub-branch" },
});
const response = await hub.ownedCreateResult("checkout-create");
expect(response).toMatchObject({
type: "hub.execution.agent.create.response",
payload: { success: true, executionId: "checkout-execution" },
});
expect(await hub.currentBranch(hub.latestCreatedCwd()!)).toBe("existing-hub-branch");
});
test("failed create never archives a reused worktree", async () => {
const hub = await launchRelationship();
hub.beginOwnedCreate("original-create", "original-execution", {
worktree: { mode: "branch-off", newBranch: "shared-hub-worktree" },
});
const original = await hub.ownedCreateResult("original-create");
const worktreeCwd = original.payload.agent?.cwd;
expect(worktreeCwd).toEqual(expect.any(String));
await hub.ownedTurnCompletion(original.payload.agentId!);
const failedPrompt = "Fail the reused worktree create";
hub.failProviderPromptStart(failedPrompt);
hub.beginOwnedCreate("reused-create", "reused-execution", {
autoArchive: true,
prompt: failedPrompt,
worktree: { mode: "branch-off", newBranch: "shared-hub-worktree" },
});
const reused = await hub.ownedCreateResult("reused-create");
expect(reused).toMatchObject({
type: "hub.execution.agent.create.response",
payload: { success: false, executionId: "reused-execution" },
});
expect(await hub.worktreeState(worktreeCwd!)).toEqual({ exists: true, listed: true });
});
@@ -0,0 +1,301 @@
import type {
AgentSnapshotPayload,
AgentStreamEventPayload,
CreateAgentWorktreeTarget,
} from "@getpaseo/protocol/messages";
import type { AgentManager, AgentManagerEvent, ManagedAgent } from "../agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "../agent/agent-storage.js";
import type { LifecycleRegistration } from "../agent/create-agent-lifecycle-dispatch.js";
import type { BoundCreateAgentCommand } from "../agent/create-agent/create.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
import { buildStoredAgentPayload } from "../agent/agent-projections.js";
import { serializeAgentSnapshot, serializeAgentStreamEvent } from "../messages.js";
import { daemonExecutionKey, type DaemonAgentOwner } from "../agent/agent-owner.js";
export interface HubExecutionAgentCreateInput {
executionId: string;
provider: string;
cwd: string;
workspaceId?: string;
prompt: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
env?: Record<string, string>;
worktree?: CreateAgentWorktreeTarget;
autoArchive?: boolean;
}
export interface OwnedAgentSnapshot {
executionId: string;
agent: AgentSnapshotPayload;
}
export type OwnedAgentEvent =
| { type: "update"; executionId: string; agent: AgentSnapshotPayload }
| {
type: "stream";
executionId: string;
agentId: string;
event: AgentStreamEventPayload;
};
interface DaemonExecutionsOptions {
daemonId: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
createAgent: BoundCreateAgentCommand;
registerAutoArchive?: (input: {
agentId: string;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
}) => LifecycleRegistration;
cleanupFailedCreate?: (input: {
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
createdAgentId: string | null;
}) => Promise<void>;
}
export interface HubExecutionAgents {
create(input: HubExecutionAgentCreateInput): Promise<OwnedAgentSnapshot>;
subscribe(listener: (event: OwnedAgentEvent) => void): () => void;
invalidateAuthority(): Promise<void>;
}
export class DaemonExecutions implements HubExecutionAgents {
private readonly daemonId: string;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly createAgentCommand: BoundCreateAgentCommand;
private readonly pendingCreates = new Map<string, Promise<OwnedAgentSnapshot>>();
private authorityGeneration = 0;
private authorityActive = true;
private readonly registerAutoArchive: (input: {
agentId: string;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
}) => LifecycleRegistration;
private readonly cleanupFailedCreate: NonNullable<DaemonExecutionsOptions["cleanupFailedCreate"]>;
constructor(options: DaemonExecutionsOptions) {
this.daemonId = options.daemonId;
this.agentManager = options.agentManager;
this.agentStorage = options.agentStorage;
this.createAgentCommand = options.createAgent;
this.registerAutoArchive =
options.registerAutoArchive ?? (() => ({ cancel: async () => undefined }));
this.cleanupFailedCreate = options.cleanupFailedCreate ?? (async () => undefined);
}
create(input: HubExecutionAgentCreateInput): Promise<OwnedAgentSnapshot> {
if (!this.authorityActive) {
return Promise.reject(new Error("Hub relationship authority is no longer active"));
}
const owner = this.owner(input.executionId);
const key = daemonExecutionKey(owner);
const pending = this.pendingCreates.get(key);
if (pending) {
return pending;
}
const authorityGeneration = this.authorityGeneration;
const create = this.createOrResolve(owner, input, authorityGeneration).finally(() => {
if (this.pendingCreates.get(key) === create) {
this.pendingCreates.delete(key);
}
});
this.pendingCreates.set(key, create);
return create;
}
async invalidateAuthority(): Promise<void> {
this.authorityActive = false;
this.authorityGeneration++;
await Promise.allSettled(this.pendingCreates.values());
}
subscribe(listener: (event: OwnedAgentEvent) => void): () => void {
return this.agentManager.subscribe(
(event) => {
const owned = this.projectEvent(event);
if (owned) {
listener(owned);
}
},
{ replayState: true },
);
}
private async createOrResolve(
owner: DaemonAgentOwner,
input: HubExecutionAgentCreateInput,
authorityGeneration: number,
): Promise<OwnedAgentSnapshot> {
const existing = await this.agentStorage.findByDaemonExecution(owner);
if (existing) {
this.requireAuthority(authorityGeneration);
return this.resolveRecord(existing);
}
this.requireAuthority(authorityGeneration);
let createdWorktree: CreatePaseoWorktreeWorkflowResult | null = null;
let createdAgentId: string | null = null;
let autoArchiveRegistration: LifecycleRegistration = { cancel: async () => undefined };
let result: Awaited<ReturnType<BoundCreateAgentCommand>>;
try {
result = await this.createAgentCommand({
kind: "mcp",
provider: input.model ? `${input.provider}/${input.model}` : input.provider,
title: input.prompt,
initialPrompt: input.prompt,
promptFailure: "throw",
cwd: input.cwd,
workspaceId: input.workspaceId,
mode: input.modeId,
thinking: input.thinkingOptionId,
features: input.featureValues,
env: input.env,
worktree: toCreateAgentWorktree(input.worktree),
background: true,
notifyOnFinish: false,
owner,
onWorktreeCreated: (worktree) => {
createdWorktree = worktree;
},
onCreated: (created) => {
createdAgentId = created.agentId;
if (input.autoArchive === true) {
autoArchiveRegistration = this.registerAutoArchive({
...created,
createdWorktree: ownedCreatedWorktree(created.createdWorktree),
});
}
},
});
this.requireAuthority(authorityGeneration);
} catch (error) {
try {
await autoArchiveRegistration.cancel();
if (createdAgentId && this.agentManager.getAgent(createdAgentId)) {
try {
await this.agentManager.closeAgent(createdAgentId);
} finally {
await this.agentManager.deleteAgentState(createdAgentId);
}
}
} finally {
try {
await this.cleanupFailedCreate({
createdWorktree: ownedCreatedWorktree(createdWorktree),
createdAgentId: null,
});
} finally {
if (createdAgentId) {
await this.agentStorage.remove(createdAgentId);
}
}
}
throw error;
}
return {
executionId: owner.executionId,
agent: serializeAgentSnapshot(result.liveSnapshot),
};
}
private resolveRecord(record: StoredAgentRecord): OwnedAgentSnapshot {
return this.projectRecord(record);
}
private requireAuthority(authorityGeneration: number): void {
if (!this.authorityActive || authorityGeneration !== this.authorityGeneration) {
throw new Error("Hub relationship authority ended during agent creation");
}
}
private projectRecord(record: StoredAgentRecord): OwnedAgentSnapshot {
const owner = this.requireOwner(record);
const live = this.agentManager.getAgent(record.id);
return {
executionId: owner.executionId,
agent: live
? serializeAgentSnapshot(live)
: {
...buildStoredAgentPayload(record, this.agentManager.getRegisteredProviderIds()),
status: "closed",
},
};
}
private projectEvent(event: AgentManagerEvent): OwnedAgentEvent | null {
if (event.type === "agent_state") {
return this.projectAgentState(event.agent);
}
if (event.type !== "agent_stream") {
return null;
}
const agent = this.agentManager.getAgent(event.agentId);
if (!this.isOwned(agent)) {
return null;
}
const serialized = serializeAgentStreamEvent(event.event);
if (!serialized) {
return null;
}
return {
type: "stream",
executionId: agent.owner.executionId,
agentId: agent.id,
event: serialized,
};
}
private projectAgentState(agent: ManagedAgent): OwnedAgentEvent | null {
if (!this.isOwned(agent)) {
return null;
}
return {
type: "update",
executionId: agent.owner.executionId,
agent: serializeAgentSnapshot(agent),
};
}
private isOwned(agent: ManagedAgent | null): agent is ManagedAgent & { owner: DaemonAgentOwner } {
return agent?.owner?.kind === "daemon" && agent.owner.daemonId === this.daemonId;
}
private owner(executionId: string): DaemonAgentOwner {
return { kind: "daemon", daemonId: this.daemonId, executionId };
}
private requireOwner(record: StoredAgentRecord): DaemonAgentOwner {
const owner = record.owner;
if (owner?.kind !== "daemon" || owner.daemonId !== this.daemonId) {
throw new Error(`Agent ${record.id} is not owned by daemon ${this.daemonId}`);
}
return owner;
}
}
function ownedCreatedWorktree(
worktree: CreatePaseoWorktreeWorkflowResult | null,
): CreatePaseoWorktreeWorkflowResult | null {
return worktree?.created === true ? worktree : null;
}
function toCreateAgentWorktree(target: CreateAgentWorktreeTarget | undefined) {
if (!target) return undefined;
if (target.mode === "branch-off") {
return {
worktreeName: target.newBranch,
baseBranch: target.base,
action: "branch-off" as const,
};
}
if (target.mode === "checkout-branch") {
return { refName: target.branch, action: "checkout" as const };
}
return { githubPrNumber: target.prNumber, action: "checkout" as const };
}
@@ -0,0 +1,86 @@
import { describe, expect, test } from "vitest";
import type {
AgentSnapshotPayload,
HubExecutionAgentCreateRequest,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import type {
HubExecutionAgents,
OwnedAgentEvent,
OwnedAgentSnapshot,
} from "./daemon-executions.js";
import { HubExecutionController } from "./execution-controller.js";
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
reject(error: Error): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((accept, decline) => {
resolve = accept;
reject = decline;
});
return { promise, resolve, reject };
}
class ControlledHubExecutionAgents implements HubExecutionAgents {
private readonly createObserved = deferred<void>();
private readonly createGate = deferred<OwnedAgentSnapshot>();
create(): Promise<OwnedAgentSnapshot> {
this.createObserved.resolve();
return this.createGate.promise;
}
subscribe(_listener: (event: OwnedAgentEvent) => void): () => void {
return () => undefined;
}
async invalidateAuthority(): Promise<void> {}
async creationStarted(): Promise<void> {
await this.createObserved.promise;
}
finishCreate(): void {
this.createGate.resolve({
executionId: "execution-shutdown",
agent: {
id: "agent-shutdown",
status: "running",
} as AgentSnapshotPayload,
});
}
}
describe("HubExecutionController", () => {
test("cleanup fences in-flight creates before the dead session can receive a response", async () => {
const agents = new ControlledHubExecutionAgents();
const messages: SessionOutboundMessage[] = [];
const controller = new HubExecutionController({
agents,
send: (message) => messages.push(message),
});
const create = controller.createAgent({
type: "hub.execution.agent.create.request",
requestId: "shutdown-create",
executionId: "execution-shutdown",
provider: "codex",
cwd: "/tmp/paseo",
prompt: "sleep 30",
} satisfies HubExecutionAgentCreateRequest);
await agents.creationStarted();
const cleanup = controller.cleanup();
agents.finishCreate();
await Promise.all([create, cleanup]);
expect(messages).toEqual([]);
});
});
@@ -0,0 +1,129 @@
import { isAbsolute } from "node:path";
import type {
HubExecutionAgentCreateRequest,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
interface HubExecutionControllerOptions {
agents: HubExecutionAgents;
send: (message: SessionOutboundMessage) => void;
}
export class HubExecutionController {
private readonly agents: HubExecutionAgents;
private readonly send: (message: SessionOutboundMessage) => void;
private readonly unsubscribe: () => void;
private readonly pendingCreates = new Set<Promise<void>>();
private cleanupPromise: Promise<void> | null = null;
private closed = false;
constructor(options: HubExecutionControllerOptions) {
this.agents = options.agents;
this.send = options.send;
this.unsubscribe = this.agents.subscribe((event) => this.sendOwnedEvent(event));
}
cleanup(): Promise<void> {
this.cleanupPromise ??= this.cleanupOnce();
return this.cleanupPromise;
}
private async cleanupOnce(): Promise<void> {
this.closed = true;
this.unsubscribe();
await Promise.allSettled(this.pendingCreates);
}
async createAgent(message: HubExecutionAgentCreateRequest): Promise<void> {
if (this.closed) return;
const create = this.createAgentWithResponse(message);
this.pendingCreates.add(create);
try {
await create;
} finally {
this.pendingCreates.delete(create);
}
}
private async createAgentWithResponse(message: HubExecutionAgentCreateRequest): Promise<void> {
try {
requireNonBlankHubAgentField("executionId", message.executionId);
requireNonBlankHubAgentField("prompt", message.prompt);
requireNonBlankHubAgentField("cwd", message.cwd);
if (!isAbsolute(message.cwd)) throw new Error("Hub agent cwd must be absolute");
const result = await this.agents.create({
executionId: message.executionId,
provider: message.provider,
cwd: message.cwd,
workspaceId: message.workspaceId,
prompt: message.prompt,
model: message.model,
modeId: message.modeId,
thinkingOptionId: message.thinkingOptionId,
featureValues: message.featureValues,
env: message.env,
worktree: message.worktree,
autoArchive: message.autoArchive,
});
if (this.closed) return;
this.send({
type: "hub.execution.agent.create.response",
payload: {
requestId: message.requestId,
executionId: message.executionId,
agentId: result.agent.id,
agent: result.agent,
success: true,
error: null,
},
});
} catch (error) {
if (this.closed) return;
this.send({
type: "hub.execution.agent.create.response",
payload: {
requestId: message.requestId,
executionId: message.executionId,
agentId: null,
agent: null,
success: false,
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private sendOwnedEvent(event: OwnedAgentEvent): void {
if (this.closed) return;
if (event.type === "update") {
this.send({
type: "hub.execution.agent.update",
payload: {
executionId: event.executionId,
agentId: event.agent.id,
agent: event.agent,
},
});
return;
}
this.send({
type: "hub.execution.agent.stream",
payload: {
executionId: event.executionId,
agentId: event.agentId,
event: event.event,
},
});
}
}
function requireNonBlankHubAgentField(
field: "executionId" | "prompt" | "cwd",
value: string,
): void {
if (value.trim().length === 0) {
throw new Error(`Hub agent ${field} cannot be blank`);
}
}
@@ -0,0 +1,145 @@
import { afterEach, expect, test } from "vitest";
import {
HubRelationshipHarness,
SetupFailingArchiveWatchFiles,
} from "./test-utils/relationship-harness.js";
let relationship: HubRelationshipHarness | null = null;
afterEach(async () => {
await relationship?.close();
relationship = null;
});
async function launchRelationship(): Promise<HubRelationshipHarness> {
const launched = await HubRelationshipHarness.start();
await launched.beginConnect().result;
launched.connectLatestSocket();
relationship = launched;
return launched;
}
test("Hub retries one durable daemon execution across concurrency and reconstruction", async () => {
const hub = await launchRelationship();
const created = await hub.createOwnedConcurrently();
const update = await hub.ownedUpdate(created.first.agentId);
const stream = await hub.ownedStream(created.first.agentId);
const reconstructed = await hub.reconstructAndReplay();
expect(created.duplicate.agentId).toBe(created.first.agentId);
expect(update).toMatchObject({
executionId: "execution-1",
agentId: created.first.agentId,
agent: { id: created.first.agentId },
});
expect(stream).toMatchObject({ executionId: "execution-1", agentId: created.first.agentId });
expect(reconstructed.replay.agent.id).toBe(created.first.agentId);
expect(reconstructed.durableAgentCount).toBe(1);
});
test("Hub denies trusted steering and browser dispatch", async () => {
const hub = await launchRelationship();
const localAgentId = await hub.createUnrelatedLocalAgent();
const steeringDenial = await hub.deniedSteering(localAgentId);
const browserDenial = await hub.deniedBrowserDispatch();
expect(steeringDenial).toEqual({
requestId: "denied-steer",
requestType: "send_agent_message_request",
error: "Session is not authorized for send_agent_message_request",
code: "access_denied",
});
expect(browserDenial).toEqual({
requestId: "browser-1",
requestType: "browser.automation.execute.response",
error: "Session is not authorized for browser.automation.execute.response",
code: "access_denied",
});
expect(hub.observedAgentIds()).not.toContain(localAgentId);
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
});
test("Hub sockets reject trusted hello and capabilities", async () => {
const hub = await launchRelationship();
expect(hub.probeTrustedHello()).toBe(4002);
});
test("Hub sockets reject trusted binary frames", async () => {
const hub = await launchRelationship();
expect(hub.probeBinaryFrame()).toBe(4002);
});
test("Hub does not receive trusted broadcasts", async () => {
const hub = await launchRelationship();
const trustedBroadcasts = await hub.trustedBroadcastCount();
const trustedStatus = await hub.trustedDaemonStatus();
expect(trustedBroadcasts).toBe(0);
expect(trustedStatus).toMatchObject({ pid: process.pid, relay: { enabled: false } });
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
});
test("Hub reconnects without retaining trusted session state", async () => {
const hub = await launchRelationship();
const created = await hub.createOwnedConcurrently();
const reconnected = await hub.reconnectAndRetry();
expect(reconnected).toMatchObject({
executionId: "execution-1",
agentId: created.first.agentId,
});
expect(hub.observedTrustedLifecycleMessages()).toEqual([]);
});
test("Hub create forwards worktree and auto-archive through the existing create path", async () => {
const hub = await launchRelationship();
hub.beginOwnedCreate("worktree-create", "execution-worktree", {
worktree: { mode: "branch-off", newBranch: "hub-created-worktree", base: "main" },
autoArchive: true,
prompt: "sleep 30",
modeId: "always-ask",
});
const worktreeCreated = await hub.ownedCreateResult("worktree-create");
const worktreeCwd = hub.latestCreatedCwd();
const permission = await hub.ownedPermissionRequest(worktreeCreated.payload.agentId!);
const duringRun = await hub.worktreeState(worktreeCwd!);
const archiveCompletion = hub.waitForOwnedArchiveCompletion(worktreeCreated.payload.agentId!);
await hub.allowOwnedPermission(worktreeCreated.payload.agentId!, permission.id);
await hub.ownedTurnCompletion(worktreeCreated.payload.agentId!);
const archive = await archiveCompletion;
const afterArchive = await hub.worktreeState(worktreeCwd!);
expect(worktreeCreated).toMatchObject({
type: "hub.execution.agent.create.response",
payload: { success: true, agent: { cwd: worktreeCwd } },
});
expect(worktreeCwd).not.toBe(hub.repoRoot());
expect(duringRun).toEqual({ exists: true, listed: true });
expect(afterArchive).toEqual({ exists: false, listed: false });
expect(archive).toEqual({
agentArchivedAt: expect.any(String),
workspaceArchivedAt: expect.any(String),
});
}, 20_000);
test("archive observation closes its first watcher when the second watcher cannot start", async () => {
const watchFiles = new SetupFailingArchiveWatchFiles(2);
const hub = await HubRelationshipHarness.start(watchFiles);
relationship = hub;
await hub.beginConnect().result;
hub.connectLatestSocket();
hub.beginOwnedCreate("watch-setup-create", "watch-setup-execution");
const created = await hub.ownedCreateResult("watch-setup-create");
await expect(hub.waitForOwnedArchiveCompletion(created.payload.agentId!)).rejects.toThrow(
"Cannot watch",
);
expect(watchFiles.activeDirectories()).toEqual([]);
});
@@ -0,0 +1,806 @@
import { createHash } from "node:crypto";
import { platform } from "node:os";
import { afterEach, describe, expect, test } from "vitest";
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
async function captureUnhandledRejections(action: () => Promise<void>): Promise<unknown[]> {
const rejections: unknown[] = [];
const capture = (reason: unknown) => rejections.push(reason);
process.on("unhandledRejection", capture);
try {
await action();
await new Promise((resolve) => setImmediate(resolve));
} finally {
process.off("unhandledRejection", capture);
}
return rejections;
}
describe("Hub relationship", () => {
let relationship: HubRelationshipHarness | null = null;
afterEach(async () => {
await relationship?.close();
relationship = null;
});
test("the CLI connects, reports status, and disconnects through the daemon", async () => {
relationship = await HubRelationshipHarness.start();
const connected = await relationship.beginConnect().result;
relationship.connectLatestSocket();
const status = await relationship.status();
const enrollment = relationship.enrollmentAttempts()[0];
const secret = relationship.relationshipFile()?.credential?.secret;
const disconnected = await relationship.disconnect();
expect(connected.state).toBe("connecting");
expect(status.state).toBe("connected");
expect(relationship.loggableValues(status)).not.toContain(secret);
expect(relationship.loggableValues(status)).not.toContain(enrollment.credentialVerifier);
expect(relationship.loggableValues(status)).not.toContain(enrollment.token);
expect(relationship.loggableValues(status)).not.toContain(enrollment.idempotencyKey);
expect(disconnected.state).toBe("not_connected");
}, 30_000);
test("Hub URLs cannot persist embedded credentials", async () => {
relationship = await HubRelationshipHarness.start();
await expect(
relationship.beginConnect("ceremony-token", "https://user:password@hub.example").result,
).rejects.toThrow();
expect(relationship.relationshipFile()).toBeNull();
expect(relationship.enrollmentAttempts()).toEqual([]);
});
test("an authenticated external socket can manage the Hub relationship", async () => {
relationship = await HubRelationshipHarness.start();
const responses = await relationship.manageRelationshipFromExternalSocket();
expect(responses.map((response) => response.type)).toEqual([
"hub.management.daemon.connect.response",
"hub.management.daemon.get_status.response",
"hub.management.daemon.disconnect.response",
]);
expect(relationship.enrollmentAttempts()).toHaveLength(1);
expect(relationship.relationshipFile()).toBeNull();
});
test("an authenticated browser socket can manage the Hub relationship", async () => {
relationship = await HubRelationshipHarness.start();
const response = await relationship.connectFromBrowserSocket();
expect(response.type).toBe("hub.management.daemon.connect.response");
expect(relationship.enrollmentAttempts()).toHaveLength(1);
expect(relationship.relationshipFile()?.state).toBe("active");
});
test("persists private generated authority before enrollment and active before dialing", async () => {
relationship = await HubRelationshipHarness.start();
const privateFileMode = platform() === "win32" ? 0o666 : 0o600;
relationship.holdEnrollment();
const connecting = relationship.beginConnect("one-time-token");
const enrollment = await relationship.enrollmentBegins();
const pending = relationship.enrollmentInvocation();
expect(pending).toMatchObject({
record: {
state: "pending",
relationship: {
daemonId: enrollment.daemonId,
idempotencyKey: enrollment.idempotencyKey,
},
credential: { secret: expect.any(String) },
enrollment: { token: "one-time-token" },
identity: { serverId: expect.any(String), daemonPublicKey: expect.any(String) },
},
});
expect(pending.mode).toBe(privateFileMode);
const secret = pending.record.credential?.secret;
expect(secret).toEqual(expect.any(String));
expect(enrollment.credentialVerifier).toBe(
createHash("sha256")
.update(secret ?? "")
.digest("base64url"),
);
relationship.completeEnrollment();
await connecting.result;
await relationship.socketDialed();
expect(relationship.socketInvocation()).toMatchObject({
mode: privateFileMode,
record: { state: "active", relationship: { daemonId: enrollment.daemonId } },
});
});
test("Hub enrollment cannot widen the daemon's locally granted scopes", async () => {
relationship = await HubRelationshipHarness.start();
relationship.returnEnrollmentScopes(["hub.execution.*", "*"]);
await relationship.beginConnect().result;
relationship.connectLatestSocket();
const responses = relationship.sendHubRequestOnLatest({
type: "daemon.get_status.request",
requestId: "scope-escalation",
});
expect(relationship.relationshipFile()?.relationship.scopes).toEqual(["hub.execution.*"]);
expect(responses).toContainEqual({
type: "rpc_error",
payload: {
requestId: "scope-escalation",
requestType: "daemon.get_status.request",
error: "Session is not authorized for daemon.get_status.request",
code: "access_denied",
},
});
});
test("a lost enrollment response reuses the exact ceremony", async () => {
relationship = await HubRelationshipHarness.start();
relationship.holdEnrollment();
const connecting = relationship.beginConnect("same-token");
await relationship.enrollmentBegins();
relationship.loseEnrollmentResponse();
await connecting.result;
await relationship.retry();
const attempts = relationship.enrollmentAttempts();
expect(attempts).toHaveLength(2);
expect(attempts[1]).toEqual(attempts[0]);
expect(relationship.enrolledRelationships()).toBe(1);
});
test("a fresh token replaces the token for a pending enrollment ceremony", async () => {
relationship = await HubRelationshipHarness.start();
relationship.holdEnrollment();
const firstConnect = relationship.beginConnect("expired-token");
const firstResult = expect(firstConnect.result).resolves.toMatchObject({
state: "reconnecting",
});
await relationship.enrollmentBegins();
relationship.loseEnrollmentResponse();
await firstResult;
relationship.holdEnrollment();
const secondConnect = relationship.beginConnect("fresh-token");
const secondResult = expect(secondConnect.result).resolves.toMatchObject({
state: "connecting",
});
const retried = await relationship.enrollmentBegins();
expect(retried.token).toBe("fresh-token");
expect(relationship.relationshipFile()?.enrollment?.token).toBe("fresh-token");
relationship.completeEnrollment();
await secondResult;
});
test("a stale enrollment rejection cannot discard a fresh pending ceremony", async () => {
relationship = await HubRelationshipHarness.start();
relationship.holdEnrollment();
const expiredConnect = relationship.beginConnect("expired-token");
await relationship.enrollmentBegins();
relationship.holdEnrollment();
const freshConnect = relationship.beginConnect("fresh-token");
const freshEnrollment = await relationship.enrollmentBegins();
relationship.rejectEnrollment(0, 403);
await expiredConnect.result;
expect(relationship.relationshipFile()?.enrollment?.token).toBe("fresh-token");
expect(relationship.pendingRelationshipRetries()).toBe(0);
relationship.completeEnrollment();
await freshConnect.result;
expect(freshEnrollment.token).toBe("fresh-token");
expect(relationship.relationshipFile()?.state).toBe("active");
expect(relationship.enrollmentAttempts()).toHaveLength(2);
expect(relationship.pendingRelationshipRetries()).toBe(0);
});
test("a rejected pending enrollment is discarded without blocking daemon restart", async () => {
relationship = await HubRelationshipHarness.start();
relationship.holdEnrollment();
const connecting = relationship.beginConnect("expired-token");
await relationship.enrollmentBegins();
relationship.loseEnrollmentResponse();
await connecting.result;
relationship.rejectNextEnrollment(401);
await relationship.restartDaemon();
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
expect(relationship.relationshipFile()).toBeNull();
});
test("a scheduled enrollment rejection is contained after removing pending authority", async () => {
relationship = await HubRelationshipHarness.start();
relationship.holdEnrollment();
const connecting = relationship.beginConnect("expired-token");
await relationship.enrollmentBegins();
relationship.loseEnrollmentResponse();
await connecting.result;
relationship.rejectNextEnrollment(403);
const unhandledRejections = await captureUnhandledRejections(() => relationship!.retry());
const status = await relationship.status();
expect(status).toMatchObject({ state: "not_connected" });
expect(relationship.relationshipFile()).toBeNull();
expect(relationship.pendingRelationshipRetries()).toBe(0);
expect(unhandledRejections).toEqual([]);
});
test.each(["{not-json", JSON.stringify({ version: 1, state: "unknown" })])(
"an invalid relationship file is quarantined without blocking daemon startup",
async (contents) => {
relationship = await HubRelationshipHarness.start();
await relationship.corruptRelationshipFile(contents);
await relationship.startStoppedDaemon();
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
expect(relationship.relationshipFile()).toBeNull();
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
},
);
test.each([
["a non-HTTP scheme", "ftp://hub.test"],
["embedded credentials", "https://user:password@hub.test"],
["a query", "https://hub.test?token=secret"],
["a fragment", "https://hub.test#secret"],
])("a persisted Hub origin with %s is quarantined before startup", async (_, hubOrigin) => {
relationship = await HubRelationshipHarness.start();
await relationship.corruptRelationshipFile(
JSON.stringify({
version: 1,
state: "pending",
relationship: {
daemonId: "daemon-1",
idempotencyKey: "ceremony-1",
hubOrigin,
createdAt: "2026-07-13T00:00:00.000Z",
scopes: ["hub.execution.*"],
},
credential: { secret: "credential" },
enrollment: { token: "enrollment-token" },
identity: { serverId: "server-1", daemonPublicKey: "public-key" },
}),
);
await relationship.startStoppedDaemon();
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
expect(relationship.relationshipFile()).toBeNull();
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
expect(relationship.enrollmentAttempts()).toEqual([]);
});
test("a persisted Hub relationship cannot widen its local execution scope", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
const persisted = relationship.relationshipFile();
expect(persisted).not.toBeNull();
persisted!.relationship.scopes = ["*"];
await relationship.corruptRelationshipFile(JSON.stringify(persisted));
const socketAttemptsBeforeRestart = relationship.socketAttempts();
await relationship.startStoppedDaemon();
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
expect(relationship.relationshipFile()).toBeNull();
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
expect(relationship.socketAttempts()).toBe(socketAttemptsBeforeRestart);
});
test("a persisted non-WebSocket transport is quarantined before daemon startup", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.corruptRelationshipFile(
JSON.stringify({
version: 1,
state: "active",
relationship: {
id: "relationship-1",
idempotencyKey: "ceremony-1",
hubOrigin: "https://hub.test",
createdAt: "2026-07-13T00:00:00.000Z",
scopes: ["hub.execution.*"],
},
credential: { secret: "credential" },
transport: { kind: "direct_websocket", webSocketUrl: "ftp://hub.test/daemon" },
}),
);
await relationship.startStoppedDaemon();
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
expect(relationship.relationshipFile()).toBeNull();
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
expect(relationship.socketAttempts()).toBe(0);
});
test("a persisted WebSocket transport with a fragment is quarantined before startup", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.corruptRelationshipFile(
JSON.stringify({
version: 1,
state: "active",
relationship: {
id: "relationship-1",
idempotencyKey: "ceremony-1",
hubOrigin: "https://hub.test",
createdAt: "2026-07-13T00:00:00.000Z",
scopes: ["hub.execution.*"],
},
credential: { secret: "credential" },
transport: {
kind: "direct_websocket",
webSocketUrl: "wss://hub.test/daemon#fragment",
},
}),
);
await relationship.startStoppedDaemon();
expect(await relationship.status()).toMatchObject({ state: "not_connected" });
expect(relationship.relationshipFile()).toBeNull();
expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1);
expect(relationship.socketAttempts()).toBe(0);
});
test("disconnect revokes an ambiguous pending enrollment before removing local authority", async () => {
relationship = await HubRelationshipHarness.start();
relationship.holdEnrollment();
const connecting = relationship.beginConnect("one-time-token");
const enrollment = await relationship.enrollmentBegins();
const credential = relationship.relationshipFile()?.credential?.secret;
relationship.loseEnrollmentResponse();
await connecting.result;
relationship.failRevocations(2);
const disconnected = await relationship.disconnect();
expect(disconnected.state).toBe("disconnecting");
expect(relationship.relationshipFile()?.state).toBe("disconnecting");
await relationship.restartDaemon();
await relationship.retry();
expect(relationship.revocationAttempts()).toBe(3);
expect(relationship.latestRevocation()).toEqual(
expect.objectContaining({
daemonId: enrollment.daemonId,
hubOrigin: enrollment.hubOrigin,
credential,
}),
);
expect(relationship.relationshipFile()).toBeNull();
});
test("disconnect revokes only after an in-flight enrollment settles", async () => {
relationship = await HubRelationshipHarness.start();
relationship.holdEnrollment();
const connecting = relationship.beginConnect("one-time-token");
const enrollment = await relationship.enrollmentBegins();
const disconnecting = relationship.beginDisconnect();
await relationship.relationshipStateBecomes("disconnecting");
expect(relationship.revocationAttempts()).toBe(0);
relationship.completeEnrollment();
await connecting.result;
const disconnected = await disconnecting.result;
expect(disconnected.state).toBe("not_connected");
expect(relationship.latestRevocation()?.daemonId).toBe(enrollment.daemonId);
expect(relationship.revocationAttempts()).toBe(1);
expect(relationship.socketAttempts()).toBe(0);
expect(relationship.relationshipFile()).toBeNull();
});
test("daemon restart reconnects the same durable relationship", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
const id = relationship.relationshipFile()?.relationship.daemonId;
relationship.connectLatestSocket();
await relationship.socketDialed();
await relationship.restartDaemon();
await relationship.socketDialed();
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(id);
expect(relationship.socketAttempts()).toBe(2);
});
test("daemon restart closes an interrupted owned turn without replaying its prompt", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
await relationship.socketDialed();
relationship.connectLatestSocket();
const daemonId = relationship.relationshipFile()?.relationship.daemonId;
const prompt = "sleep 30";
relationship.beginOwnedCreate("running-create", "execution-running", {
prompt,
modeId: "full-access",
});
const created = await relationship.ownedCreateResult("running-create");
const running = await relationship.ownedRunningUpdate(created.payload.agentId!);
await relationship.restartDaemon();
await relationship.socketDialed();
relationship.connectLatestSocket();
relationship.beginOwnedCreate("running-duplicate", "execution-running", { prompt });
const duplicate = await relationship.ownedCreateResult("running-duplicate");
expect(duplicate).toMatchObject({
payload: {
success: true,
executionId: "execution-running",
agentId: created.payload.agentId,
agent: { id: created.payload.agentId, status: "closed" },
},
});
const durableAgentIds = await relationship.durableOwnedAgentIds();
expect(running).toMatchObject({
executionId: "execution-running",
agentId: created.payload.agentId,
agent: { id: created.payload.agentId, status: "running" },
});
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
expect(durableAgentIds).toEqual([created.payload.agentId]);
expect(relationship.providerCreations()).toBe(1);
expect(relationship.providerResumes()).toBe(0);
expect(relationship.providerPromptTexts()).toEqual([prompt]);
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
});
test("an owned execution does not persist a daemon-restart prompt intent", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.beginOwnedCreate("intent-create", "execution-intent", { prompt: "sleep 30" });
await relationship.ownedCreateResult("intent-create");
expect(await relationship.hubExecutionIntentFiles()).toEqual([]);
});
test("an ordinary duplicate create does not replay a completed owned turn", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
const prompt = "respond with exactly: completed once";
relationship.beginOwnedCreate("completed-create", "execution-completed", { prompt });
const created = await relationship.ownedCreateResult("completed-create");
await relationship.ownedTurnCompletion(created.payload.agentId!);
relationship.beginOwnedCreate("completed-duplicate", "execution-completed", { prompt });
const duplicate = await relationship.ownedCreateResult("completed-duplicate");
expect(duplicate).toMatchObject({
type: "hub.execution.agent.create.response",
payload: {
success: true,
executionId: "execution-completed",
agentId: created.payload.agentId,
agent: { status: "idle" },
},
});
expect(relationship.providerPromptTexts()).toEqual([prompt]);
expect(await relationship.durableOwnedAgentIds()).toEqual([created.payload.agentId]);
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(1);
});
test("daemon restart closes a completed owned session without replaying its prompt", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
const prompt = "respond with exactly: already completed";
relationship.beginOwnedCreate("idle-create", "execution-idle", { prompt });
const created = await relationship.ownedCreateResult("idle-create");
await relationship.ownedTurnCompletion(created.payload.agentId!);
await relationship.restartDaemon();
await relationship.socketDialed();
relationship.connectLatestSocket();
relationship.beginOwnedCreate("idle-retry", "execution-idle", { prompt });
const retried = await relationship.ownedCreateResult("idle-retry");
expect(retried).toMatchObject({
payload: {
executionId: "execution-idle",
agentId: created.payload.agentId,
agent: { id: created.payload.agentId, status: "closed" },
},
});
expect(relationship.providerPromptTexts()).toEqual([prompt]);
expect(relationship.providerResumes()).toBe(0);
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
});
test("daemon restart closes a failed owned session without replaying its prompt", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
const prompt = "emit a turn failure";
relationship.beginOwnedCreate("failed-create", "execution-failed", { prompt });
const created = await relationship.ownedCreateResult("failed-create");
const failed = await relationship.ownedTurnFailure(created.payload.agentId!);
await relationship.restartDaemon();
await relationship.socketDialed();
relationship.connectLatestSocket();
relationship.beginOwnedCreate("failed-retry", "execution-failed", { prompt });
const retried = await relationship.ownedCreateResult("failed-retry");
expect(failed).toMatchObject({
executionId: "execution-failed",
agentId: created.payload.agentId,
event: { type: "turn_failed" },
});
expect(retried).toMatchObject({
payload: {
executionId: "execution-failed",
agentId: created.payload.agentId,
agent: { id: created.payload.agentId, status: "closed" },
},
});
expect(relationship.providerPromptTexts()).toEqual([prompt]);
expect(relationship.providerResumes()).toBe(0);
expect(relationship.latestOwnedTurnCompletions(created.payload.agentId!)).toBe(0);
});
test("Hub revocation leaves no execution intent artifacts", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.beginOwnedCreate("revoked-create", "execution-revoked", { prompt: "sleep 30" });
await relationship.ownedCreateResult("revoked-create");
relationship.rejectRelationship(401);
expect(await relationship.hubExecutionIntentFiles()).toEqual([]);
});
test("stale socket generations cannot replace or unregister the current socket", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.closeSocket(0, 1006);
await relationship.retry();
relationship.connectSocket(1);
relationship.connectSocket(0);
relationship.closeSocket(0, 1000);
const messages = relationship.sendHubRequestOnLatest({
type: "daemon.get_status.request",
requestId: "still-current",
});
expect(messages).toContainEqual({
type: "rpc_error",
payload: {
requestId: "still-current",
requestType: "daemon.get_status.request",
error: "Session is not authorized for daemon.get_status.request",
code: "access_denied",
},
});
expect(relationship.socketAttempts()).toBe(2);
});
test("replayed create across socket generations shares one pending durable execution", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.holdAgentCreation();
relationship.beginOwnedCreate("first-create");
await relationship.agentCreationAttempts(1);
relationship.closeLatestSocket(1006);
await relationship.retry();
relationship.connectLatestSocket();
relationship.beginOwnedCreate("replayed-create");
relationship.finishAgentCreation();
const replayed = await relationship.ownedCreateResult("replayed-create");
const durableAgentIds = await relationship.durableOwnedAgentIds();
expect(relationship.socketDeliveredResponse(0, "first-create")).toBe(false);
expect(replayed).toMatchObject({
type: "hub.execution.agent.create.response",
payload: {
success: true,
executionId: "execution-race",
agentId: durableAgentIds[0],
},
});
expect(relationship.providerCreations()).toBe(1);
expect(durableAgentIds).toHaveLength(1);
});
test("idempotent retry waits for a pending create across socket generations", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.holdAgentCreation();
relationship.beginOwnedCreate("pending-create", "pending-execution");
await relationship.agentCreationAttempts(1);
relationship.closeLatestSocket(1006);
await relationship.retry();
relationship.connectLatestSocket();
relationship.beginOwnedCreate("pending-retry", "pending-execution");
relationship.finishAgentCreation();
await expect(relationship.ownedCreateResult("pending-retry")).resolves.toMatchObject({
payload: {
executionId: "pending-execution",
agentId: expect.any(String),
agent: { id: expect.any(String) },
},
});
expect(relationship.providerCreations()).toBe(1);
expect(await relationship.durableOwnedAgentIds()).toHaveLength(1);
});
test("force disconnect fences a pending create before removing authority", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.holdAgentCreation();
relationship.beginOwnedCreate("cancelled-create", "cancelled-execution", {
worktree: { mode: "branch-off", newBranch: "cancelled-hub-create" },
});
await relationship.agentCreationAttempts(1);
const disconnecting = relationship.beginDisconnect(true);
await relationship.relationshipStateBecomes(null);
relationship.finishAgentCreation();
await disconnecting.result;
expect(relationship.activeOwnedAgentIds()).toEqual([]);
expect(await relationship.durableOwnedAgentIds()).toEqual([]);
expect(await relationship.listedWorktrees()).toHaveLength(1);
});
test("re-enrollment gives the same daemon fresh execution authority", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.beginOwnedCreate("first-create", "first-execution");
const first = await relationship.ownedCreateResult("first-create");
const daemonId = relationship.relationshipFile()?.relationship.daemonId;
await relationship.disconnect();
await relationship.beginConnect("fresh-token").result;
relationship.connectLatestSocket();
relationship.beginOwnedCreate("second-create", "second-execution");
const second = await relationship.ownedCreateResult("second-create");
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } });
expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } });
expect(relationship.providerCreations()).toBe(2);
});
test("daemon shutdown fences a pending create before closing owned agents", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.holdAgentCreation();
relationship.beginOwnedCreate("shutdown-create", "execution-shutdown", { prompt: "sleep 30" });
await relationship.agentCreationAttempts(1);
const shutdown = relationship.shutdownDaemon();
relationship.finishAgentCreation();
await shutdown;
expect(relationship.socketDeliveredResponse(0, "shutdown-create")).toBe(false);
expect(await relationship.durableOwnedAgentIdsOnDisk()).toEqual([]);
});
test.each([
[4403, "Hub revoked this relationship"],
[401, "Hub rejected socket authentication (401)"],
[403, "Hub rejected socket authentication (403)"],
] as const)("authentication rejection %s revokes permanently", async (code, reason) => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect("private-token").result;
const enrollment = relationship.enrollmentAttempts()[0];
const secret = relationship.relationshipFile()?.credential?.secret;
relationship.rejectRelationship(code);
await relationship.restartDaemon();
const status = await relationship.status();
const persisted = relationship.relationshipFile();
expect(status).toMatchObject({
state: "revoked",
daemonId: enrollment.daemonId,
hub: "https://hub.test",
scopes: "hub.execution.*",
error: reason,
});
expect(persisted?.state).toBe("revoked");
expect(persisted?.relationship).toMatchObject({
daemonId: enrollment.daemonId,
hubOrigin: "https://hub.test",
scopes: ["hub.execution.*"],
});
expect(persisted?.reason).toBe(reason);
expect(persisted).not.toHaveProperty("credential");
expect(persisted).not.toHaveProperty("relationship.idempotencyKey");
expect(relationship.socketAttempts()).toBe(1);
const loggable = relationship.loggableValues(status);
const reconstructed = JSON.stringify({ status, persisted });
expect(reconstructed).not.toContain(secret);
expect(reconstructed).not.toContain(enrollment.credentialVerifier);
expect(reconstructed).not.toContain(enrollment.token);
expect(reconstructed).not.toContain(enrollment.idempotencyKey);
expect(loggable).not.toContain(secret);
expect(loggable).not.toContain(enrollment.credentialVerifier);
expect(loggable).not.toContain(enrollment.token);
expect(loggable).not.toContain(enrollment.idempotencyKey);
});
test("offline disconnect retries across runtime and restart without opening a socket", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.failRevocations(3);
const disconnecting = await relationship.disconnect();
await relationship.retry();
await relationship.restartDaemon();
await relationship.retry();
expect(disconnecting.state).toBe("disconnecting");
expect(relationship.revocationAttempts()).toBe(4);
expect(relationship.socketAttempts()).toBe(1);
expect(relationship.relationshipFile()).toBeNull();
});
test("successful disconnect clears a transient revocation error", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.failRevocations(1);
const disconnecting = await relationship.disconnect();
await relationship.retry();
expect(disconnecting).toMatchObject({ state: "disconnecting", error: expect.any(String) });
expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null });
});
test("force disconnect removes local authority and reports the remote warning", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.failRevocations(1);
await relationship.disconnect();
const forced = await relationship.disconnect(true);
expect(forced.state).toBe("not_connected");
expect(forced.warning).toContain("remote revocation");
expect(relationship.relationshipFile()).toBeNull();
});
test("disconnect leaves no intent artifacts and shutdown closes the owned agent", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.beginOwnedCreate("orphan-create", "execution-orphan", { prompt: "sleep 30" });
const created = await relationship.ownedCreateResult("orphan-create");
expect(created).toMatchObject({ payload: { agent: { status: "running" } } });
await relationship.disconnect(true);
expect(await relationship.hubExecutionIntentFiles()).toEqual([]);
await relationship.restartDaemon();
expect(await relationship.storedOwnedStatus(created.payload.agentId!)).toBe("closed");
});
});
@@ -0,0 +1,556 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
import path from "node:path";
import type pino from "pino";
import { z } from "zod";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.js";
import type { WebSocketLike } from "../websocket-server.js";
import type { HubExecutionAgents } from "./daemon-executions.js";
import type {
HubRelationshipRemote,
HubSocketConnection,
HubSocketEvents,
} from "./relationship-remote.js";
import { HubEnrollmentRejectedError } from "./relationship-remote.js";
import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js";
const FILE_NAME = "hub-relationship.json";
const HUB_EXECUTION_SCOPE = "hub.execution.*";
const SCOPES = [HUB_EXECUTION_SCOPE] as const;
const HubOriginSchema = z
.string()
.url()
.superRefine((value, context) => {
try {
normalizeHubUrl(value);
} catch (error) {
context.addIssue({
code: "custom",
message: error instanceof Error ? error.message : "Invalid Hub URL",
});
}
});
const RelationshipSchema = z.object({
daemonId: z.string().min(1),
idempotencyKey: z.string().min(1),
hubOrigin: HubOriginSchema,
createdAt: z.string(),
scopes: z.tuple([z.literal(HUB_EXECUTION_SCOPE)]),
});
const SanitizedRelationshipSchema = RelationshipSchema.omit({ idempotencyKey: true });
const CredentialSchema = z.object({ secret: z.string().min(1) });
const TransportSchema = z.object({
kind: z.literal("direct_websocket"),
webSocketUrl: z
.string()
.url()
.refine((value) => ["ws:", "wss:"].includes(new URL(value).protocol))
.refine((value) => new URL(value).hash === ""),
});
const PendingSchema = z.object({
version: z.literal(1),
state: z.literal("pending"),
relationship: RelationshipSchema,
credential: CredentialSchema,
enrollment: z.object({ token: z.string().min(1) }),
identity: z.object({ serverId: z.string().min(1), daemonPublicKey: z.string().min(1) }),
});
const ActiveSchema = z.object({
version: z.literal(1),
state: z.literal("active"),
relationship: RelationshipSchema,
credential: CredentialSchema,
transport: TransportSchema,
});
const DisconnectingSchema = z.object({
version: z.literal(1),
state: z.literal("disconnecting"),
relationship: RelationshipSchema,
credential: CredentialSchema,
transport: TransportSchema.optional(),
});
const RevokedSchema = z.object({
version: z.literal(1),
state: z.literal("revoked"),
relationship: SanitizedRelationshipSchema,
transport: TransportSchema.optional(),
reason: z.string().optional(),
});
const RecordSchema = z
.discriminatedUnion("state", [PendingSchema, ActiveSchema, DisconnectingSchema, RevokedSchema])
.superRefine((record, context) => {
if (!("transport" in record) || !record.transport) return;
const hub = new URL(record.relationship.hubOrigin);
const socket = new URL(record.transport.webSocketUrl);
const expectedProtocol = hub.protocol === "https:" ? "wss:" : "ws:";
if (socket.protocol === expectedProtocol && socket.host === hub.host) return;
context.addIssue({
code: "custom",
path: ["transport", "webSocketUrl"],
message: "Hub WebSocket URL must match the Hub origin",
});
});
type PendingRecord = z.infer<typeof PendingSchema>;
type ActiveRecord = z.infer<typeof ActiveSchema>;
type DisconnectingRecord = z.infer<typeof DisconnectingSchema>;
type RevokedRecord = z.infer<typeof RevokedSchema>;
type HubRelationshipRecord = z.infer<typeof RecordSchema>;
export type HubConnectionState =
| "not_connected"
| "connecting"
| "connected"
| "reconnecting"
| "disconnecting"
| "revoked";
export interface HubRelationshipStatus {
state: HubConnectionState;
daemonId: string | null;
hubOrigin: string | null;
scopes: string[];
connectedAt: string | null;
lastError: string | null;
}
export interface HubRelationshipManagement {
connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus>;
status(): HubRelationshipStatus;
disconnect(input: {
force: boolean;
}): Promise<{ status: HubRelationshipStatus; warning?: string }>;
}
export interface ScheduledRelationshipTask {
cancel(): void;
}
export interface HubRelationshipClock {
now(): Date;
schedule(delayMs: number, task: () => void): ScheduledRelationshipTask;
}
export interface HubRelationshipRetryPolicy {
delay(attempt: number): number;
}
export interface HubRelationshipControllerOptions {
paseoHome: string;
serverId: string;
daemonPublicKey: string;
logger: pino.Logger;
remote: HubRelationshipRemote;
clock?: HubRelationshipClock;
retryPolicy?: HubRelationshipRetryPolicy;
createDaemonId?: () => string;
attachSocket: (
socket: WebSocketLike,
options: { daemonId: string; scopes: readonly string[]; agents: HubExecutionAgents },
) => Promise<void>;
createExecutionAgents: (daemonId: string) => HubExecutionAgents;
}
const systemClock: HubRelationshipClock = {
now: () => new Date(),
schedule(delayMs, task) {
const timer = setTimeout(task, delayMs);
timer.unref?.();
return { cancel: () => clearTimeout(timer) };
},
};
function normalizeHubUrl(value: string): string {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Hub URL must use HTTP or HTTPS");
}
if (url.username || url.password) {
throw new Error("Hub URL cannot include credentials");
}
if (url.search || url.hash) {
throw new Error("Hub URL cannot include a query or fragment");
}
url.pathname = url.pathname.replace(/\/$/, "");
return url.toString().replace(/\/$/, "");
}
export class HubRelationshipController implements HubRelationshipManagement {
private readonly filePath: string;
private readonly clock: HubRelationshipClock;
private readonly retryPolicy: HubRelationshipRetryPolicy;
private record: HubRelationshipRecord | null;
private state: HubConnectionState = "not_connected";
private connectedAt: string | null = null;
private lastError: string | null = null;
private socket: HubSocketConnection | null = null;
private retry: ScheduledRelationshipTask | null = null;
private generation = 0;
private enrollmentGeneration = 0;
private retryAttempt = 0;
private readonly inFlightEnrollments = new Set<Promise<void>>();
private executionAgents: { daemonId: string; value: HubExecutionAgents } | null = null;
constructor(private readonly options: HubRelationshipControllerOptions) {
this.filePath = path.join(options.paseoHome, FILE_NAME);
this.clock = options.clock ?? systemClock;
this.retryPolicy = options.retryPolicy ?? new BoundedExponentialHubRetryPolicy();
this.record = this.load();
if (this.record?.state === "revoked") {
this.state = "revoked";
this.lastError = this.record.reason ?? null;
} else if (this.record?.state === "disconnecting") this.state = "disconnecting";
else if (this.record) this.state = "connecting";
}
async start(): Promise<void> {
if (this.record?.state === "active") this.openSocket(this.record, false);
if (this.record?.state === "pending") {
const enrollmentGeneration = this.beginEnrollmentAttempt();
try {
await this.tryEnrollment(this.record, enrollmentGeneration);
} catch (error) {
if (!(error instanceof HubEnrollmentRejectedError)) throw error;
this.options.logger.warn(
{ statusCode: error.statusCode },
"Discarded rejected pending Hub enrollment during startup",
);
}
}
if (this.record?.state === "disconnecting") await this.tryRevocation(this.record);
}
async stop(): Promise<void> {
const pendingExecutionCleanup = this.retireExecutionAgents();
this.cancelLifecycle();
this.socket?.close();
this.socket = null;
await pendingExecutionCleanup;
}
status(): HubRelationshipStatus {
return {
state: this.state,
daemonId: this.record?.relationship.daemonId ?? null,
hubOrigin: this.record?.relationship.hubOrigin ?? null,
scopes: this.record?.relationship.scopes.slice() ?? [],
connectedAt: this.connectedAt,
lastError: this.lastError,
};
}
async connect(input: { hubUrl: string; token: string }): Promise<HubRelationshipStatus> {
if (this.record?.state === "pending") {
if (normalizeHubUrl(input.hubUrl) !== this.record.relationship.hubOrigin) {
throw new Error("A pending Hub enrollment already exists for a different Hub");
}
this.record = { ...this.record, enrollment: { token: input.token } };
const enrollmentGeneration = this.beginEnrollmentAttempt();
this.persist(this.record);
this.state = "connecting";
this.lastError = null;
await this.tryEnrollment(this.record, enrollmentGeneration);
return this.status();
}
if (this.record && this.record.state !== "revoked") {
throw new Error("This daemon already has a Hub relationship");
}
const pending: PendingRecord = {
version: 1,
state: "pending",
relationship: {
daemonId: this.options.createDaemonId?.() ?? randomUUID(),
idempotencyKey: randomUUID(),
hubOrigin: normalizeHubUrl(input.hubUrl),
createdAt: this.clock.now().toISOString(),
scopes: [...SCOPES],
},
credential: { secret: randomBytes(32).toString("base64url") },
enrollment: { token: input.token },
identity: { serverId: this.options.serverId, daemonPublicKey: this.options.daemonPublicKey },
};
this.persist(pending);
this.record = pending;
this.state = "connecting";
this.lastError = null;
await this.tryEnrollment(pending, this.beginEnrollmentAttempt());
return this.status();
}
async disconnect(input: {
force: boolean;
}): Promise<{ status: HubRelationshipStatus; warning?: string }> {
const waitForEnrollment = this.record?.state === "pending";
const pendingCreateCleanup = this.retireExecutionAgents();
this.cancelLifecycle();
this.socket?.close();
this.socket = null;
if (!this.record || this.record.state === "revoked") {
this.remove();
await pendingCreateCleanup;
return { status: this.status() };
}
if (input.force) {
this.remove();
await pendingCreateCleanup;
return {
status: this.status(),
warning: "Local Hub credential removed; remote revocation may remain pending.",
};
}
const disconnecting: DisconnectingRecord = {
version: 1,
state: "disconnecting",
relationship: this.record.relationship,
credential: this.record.credential,
...(this.record.state === "active" ? { transport: this.record.transport } : {}),
};
this.persist(disconnecting);
this.record = disconnecting;
this.state = "disconnecting";
if (waitForEnrollment) {
await Promise.all(this.inFlightEnrollments);
}
await this.tryRevocation(disconnecting);
await pendingCreateCleanup;
return { status: this.status() };
}
private async tryEnrollment(pending: PendingRecord, enrollmentGeneration: number): Promise<void> {
if (enrollmentGeneration !== this.enrollmentGeneration) return;
const verifier = createHash("sha256").update(pending.credential.secret).digest("base64url");
const request = this.options.remote.enroll({
daemonId: pending.relationship.daemonId,
idempotencyKey: pending.relationship.idempotencyKey,
hubOrigin: pending.relationship.hubOrigin,
token: pending.enrollment.token,
serverId: pending.identity.serverId,
daemonPublicKey: pending.identity.daemonPublicKey,
credentialVerifier: verifier,
scopes: pending.relationship.scopes,
});
const settled = request.then(
() => undefined,
() => undefined,
);
this.inFlightEnrollments.add(settled);
try {
const enrollment = await request;
if (enrollmentGeneration !== this.enrollmentGeneration) return;
if (
enrollment.daemonId !== pending.relationship.daemonId ||
!enrollment.scopes.includes(HUB_EXECUTION_SCOPE)
) {
throw new Error("Hub enrollment response did not match the pending relationship");
}
const active: ActiveRecord = {
version: 1,
state: "active",
relationship: pending.relationship,
credential: pending.credential,
transport: { kind: "direct_websocket", webSocketUrl: enrollment.webSocketUrl },
};
this.persist(active);
this.record = active;
this.retry = null;
this.retryAttempt = 0;
this.openSocket(active, false);
} catch (error) {
if (enrollmentGeneration !== this.enrollmentGeneration) return;
if (error instanceof HubEnrollmentRejectedError) {
this.remove();
throw error;
}
this.lastError = error instanceof Error ? error.message : String(error);
this.scheduleEnrollment(pending, enrollmentGeneration);
} finally {
this.inFlightEnrollments.delete(settled);
}
}
private openSocket(record: ActiveRecord, reconnecting: boolean): void {
const generation = ++this.generation;
this.state = reconnecting ? "reconnecting" : "connecting";
const events: HubSocketEvents = {
connected: (socket) => this.socketConnected(generation, record, socket),
rejected: (statusCode) => this.socketRejected(generation, statusCode),
closed: (code) => this.socketClosed(generation, record, code),
failed: (error) => this.socketFailed(generation, record, error),
};
this.socket = this.options.remote.openSocket(
{
daemonId: record.relationship.daemonId,
webSocketUrl: record.transport.webSocketUrl,
credential: record.credential.secret,
},
events,
);
}
private socketConnected(generation: number, record: ActiveRecord, socket: WebSocketLike): void {
if (generation !== this.generation) {
socket.close();
return;
}
this.retryAttempt = 0;
this.state = "connected";
this.connectedAt = this.clock.now().toISOString();
this.lastError = null;
void this.options.attachSocket(socket, {
daemonId: record.relationship.daemonId,
scopes: record.relationship.scopes,
agents: this.executionAgentsFor(record.relationship.daemonId),
});
}
private executionAgentsFor(daemonId: string): HubExecutionAgents {
if (this.executionAgents?.daemonId === daemonId) return this.executionAgents.value;
const value = this.options.createExecutionAgents(daemonId);
this.executionAgents = { daemonId, value };
return value;
}
private retireExecutionAgents(): Promise<void> {
const executionAgents = this.executionAgents;
this.executionAgents = null;
return executionAgents?.value.invalidateAuthority() ?? Promise.resolve();
}
private socketRejected(generation: number, statusCode: 401 | 403): void {
if (generation !== this.generation) return;
this.revoke(`Hub rejected socket authentication (${statusCode})`);
}
private socketClosed(generation: number, record: ActiveRecord, code: number): void {
if (generation !== this.generation) return;
if (code === 4403) {
this.revoke("Hub revoked this relationship");
return;
}
if (this.record?.state === "active") this.scheduleSocket(record);
}
private socketFailed(generation: number, record: ActiveRecord, error: Error): void {
if (generation !== this.generation) return;
this.lastError = error.message;
if (this.record?.state === "active") this.scheduleSocket(record);
}
private scheduleSocket(record: ActiveRecord): void {
this.state = "reconnecting";
this.schedule(() => this.openSocket(record, true));
}
private scheduleEnrollment(record: PendingRecord, enrollmentGeneration: number): void {
if (enrollmentGeneration !== this.enrollmentGeneration) return;
this.state = "reconnecting";
this.retry?.cancel();
const delay = this.retryPolicy.delay(this.retryAttempt++);
this.retry = this.clock.schedule(delay, () => {
if (enrollmentGeneration !== this.enrollmentGeneration) return;
void this.tryEnrollment(record, enrollmentGeneration).catch((error: unknown) => {
if (error instanceof HubEnrollmentRejectedError) return;
this.options.logger.error({ err: error }, "Scheduled Hub enrollment retry failed");
});
});
}
private async tryRevocation(record: DisconnectingRecord): Promise<void> {
const generation = this.generation;
try {
await this.options.remote.revoke({
daemonId: record.relationship.daemonId,
hubOrigin: record.relationship.hubOrigin,
credential: record.credential.secret,
});
if (generation !== this.generation) return;
this.remove();
} catch (error) {
if (generation !== this.generation) return;
this.lastError = error instanceof Error ? error.message : String(error);
this.state = "disconnecting";
this.schedule(() => void this.tryRevocation(record));
}
}
private schedule(task: () => void): void {
this.retry?.cancel();
const generation = this.generation;
const delay = this.retryPolicy.delay(this.retryAttempt++);
this.retry = this.clock.schedule(delay, () => {
if (generation === this.generation) task();
});
}
private revoke(reason: string): void {
void this.retireExecutionAgents();
this.cancelLifecycle();
if (!this.record) return;
const revoked: RevokedRecord = {
version: 1,
state: "revoked",
relationship: {
daemonId: this.record.relationship.daemonId,
hubOrigin: this.record.relationship.hubOrigin,
createdAt: this.record.relationship.createdAt,
scopes: this.record.relationship.scopes,
},
transport: "transport" in this.record ? this.record.transport : undefined,
reason,
};
this.persist(revoked);
this.record = revoked;
this.state = "revoked";
this.lastError = reason;
}
private cancelLifecycle(): void {
++this.generation;
++this.enrollmentGeneration;
this.retry?.cancel();
this.retry = null;
}
private beginEnrollmentAttempt(): number {
this.retry?.cancel();
this.retry = null;
this.retryAttempt = 0;
return ++this.enrollmentGeneration;
}
private persist(record: HubRelationshipRecord): void {
writePrivateFileAtomicSync(this.filePath, `${JSON.stringify(record, null, 2)}\n`);
}
private remove(): void {
void this.retireExecutionAgents();
this.cancelLifecycle();
rmSync(this.filePath, { force: true });
this.record = null;
this.state = "not_connected";
this.connectedAt = null;
this.lastError = null;
}
private load(): HubRelationshipRecord | null {
if (!existsSync(this.filePath)) return null;
let record: HubRelationshipRecord;
try {
record = RecordSchema.parse(JSON.parse(readFileSync(this.filePath, "utf8")));
} catch (error) {
const quarantinePath = path.join(
path.dirname(this.filePath),
`hub-relationship.invalid-${this.clock.now().getTime()}-${randomUUID()}.json`,
);
renameSync(this.filePath, quarantinePath);
ensurePrivateFile(quarantinePath);
this.options.logger.error(
{ err: error, quarantinePath },
"Quarantined invalid Hub relationship authority",
);
return null;
}
ensurePrivateFile(this.filePath);
return record;
}
}
@@ -0,0 +1,551 @@
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import type { AddressInfo, Socket } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, expect, test } from "vitest";
import { WebSocket } from "ws";
import type { HubExecutionAgents } from "./daemon-executions.js";
import {
HubRelationshipController,
type HubRelationshipClock,
type HubRelationshipRetryPolicy,
type ScheduledRelationshipTask,
} from "./relationship-controller.js";
import {
DirectHubRelationshipRemote,
HubEnrollmentRejectedError,
type HubSocketConnection,
type HubSocketEvents,
} from "./relationship-remote.js";
const openServers: ReturnType<typeof createServer>[] = [];
const openUpgradeHubs: UpgradeRejectingHub[] = [];
const openPaseoHomes: string[] = [];
afterEach(async () => {
for (const hub of openUpgradeHubs.splice(0)) hub.destroyConnections();
await Promise.all(openServers.splice(0).map((server) => closeServer(server)));
await Promise.all(openPaseoHomes.splice(0).map((home) => rm(home, { recursive: true })));
});
test.each([401, 403, 404])(
"revocation status %s clears already-invalid local authority",
async (status) => {
const hubOrigin = await startHubReturning(status);
const remote = new DirectHubRelationshipRemote();
await expect(
remote.revoke({ daemonId: "daemon-1", hubOrigin, credential: "invalid" }),
).resolves.toBeUndefined();
},
);
test("transient revocation failures remain retryable", async () => {
const hubOrigin = await startHubReturning(503);
const remote = new DirectHubRelationshipRemote();
await expect(
remote.revoke({ daemonId: "daemon-1", hubOrigin, credential: "credential" }),
).rejects.toThrow("Hub revocation failed (503)");
});
test.each([408, 429])("transient enrollment status %s remains retryable", async (status) => {
const hubOrigin = await startHubReturning(status);
const remote = new DirectHubRelationshipRemote();
const error = await remote
.enroll({
daemonId: "daemon-1",
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
})
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(HubEnrollmentRejectedError);
expect((error as Error).message).toBe(`Hub enrollment failed (${status})`);
});
test("enrollment rejects a transport URL that cannot open a WebSocket", async () => {
const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon");
const remote = new DirectHubRelationshipRemote();
await expect(
remote.enroll({
daemonId: "daemon-1",
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
}),
).rejects.toThrow("Hub WebSocket URL must use ws or wss");
});
test("enrollment rejects a WebSocket URL with a fragment", async () => {
const hubOrigin = await startEnrollmentHub("ws://hub.test/daemon#fragment");
const remote = new DirectHubRelationshipRemote();
await expect(
remote.enroll({
daemonId: "daemon-1",
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
}),
).rejects.toThrow("Hub WebSocket URL cannot include a fragment");
});
test("enrollment rejects a WebSocket outside the enrolled Hub authority", async () => {
const hubOrigin = await startEnrollmentHub("ws://other-hub.test/daemon");
const remote = new DirectHubRelationshipRemote();
await expect(
remote.enroll({
daemonId: "daemon-1",
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
}),
).rejects.toThrow("Hub WebSocket URL must match the Hub origin");
});
test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (operation) => {
const hubOrigin = await startStalledHub();
const remote = new DirectHubRelationshipRemote({ requestTimeoutMs: 25 });
const request =
operation === "enrollment"
? remote.enroll({
daemonId: "daemon-1",
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
})
: remote.revoke({
daemonId: "daemon-1",
hubOrigin,
credential: "credential",
});
await expect(request).rejects.toThrow("Hub request timed out");
});
test.each([401, 403] as const)(
"socket authentication status %s is terminal and releases the failed upgrade",
async (status) => {
const hub = await UpgradeRejectingHub.start([status]);
const outcomes = new SocketOutcomes();
const remote = new DirectHubRelationshipRemote();
const socket = remote.openSocket(
{
daemonId: "daemon-1",
webSocketUrl: hub.webSocketUrl,
credential: "invalid",
},
outcomes.events,
) as HubSocketConnection & WebSocket;
expect(await outcomes.next()).toEqual({ type: "rejected", status });
await hub.expectAttemptReleased(1);
expect(await outcomes.afterCleanup()).toEqual([{ type: "rejected", status }]);
expect(socket.readyState).toBe(WebSocket.CLOSED);
expect(hub.openConnections()).toBe(0);
},
);
test("a transient socket response releases the failed upgrade and reports one connection loss", async () => {
const hub = await UpgradeRejectingHub.start([503]);
const outcomes = new SocketOutcomes();
const remote = new DirectHubRelationshipRemote();
const socket = remote.openSocket(
{
daemonId: "daemon-1",
webSocketUrl: hub.webSocketUrl,
credential: "credential",
},
outcomes.events,
) as HubSocketConnection & WebSocket;
expect(await outcomes.next()).toEqual({ type: "closed", code: 1006 });
await hub.expectAttemptReleased(1);
expect(await outcomes.afterCleanup()).toEqual([{ type: "closed", code: 1006 }]);
expect(socket.readyState).toBe(WebSocket.CLOSED);
expect(hub.openConnections()).toBe(0);
});
test("a stalled socket opening handshake times out and releases the connection", async () => {
const hub = await UpgradeRejectingHub.start(["stall"]);
const outcomes = new SocketOutcomes();
const remote = new DirectHubRelationshipRemote({ requestTimeoutMs: 25 });
const socket = remote.openSocket(
{
daemonId: "daemon-1",
webSocketUrl: hub.webSocketUrl,
credential: "credential",
},
outcomes.events,
) as HubSocketConnection & WebSocket;
expect(await outcomes.next()).toEqual({
type: "failed",
message: "Opening handshake has timed out",
});
expect(await outcomes.afterCleanup()).toEqual([
{ type: "failed", message: "Opening handshake has timed out" },
]);
expect(socket.readyState).toBe(WebSocket.CLOSED);
});
test.each([401, 403] as const)(
"controller treats socket authentication status %s as terminal without redialing",
async (status) => {
const hub = await UpgradeRejectingHub.start([status]);
const clock = new ManualRelationshipClock();
const controller = await connectController(hub, clock);
await hub.expectAttemptReleased(1);
expect(controller.status()).toMatchObject({
state: "revoked",
lastError: `Hub rejected socket authentication (${status})`,
});
expect(hub.attemptCount()).toBe(1);
expect(clock.pendingTasks()).toBe(0);
expect(hub.openConnections()).toBe(0);
},
);
test("controller redials after a transient socket response and releases both failed upgrades", async () => {
const hub = await UpgradeRejectingHub.start([503, 401]);
const clock = new ManualRelationshipClock();
const controller = await connectController(hub, clock);
await hub.expectAttemptReleased(1);
expect(controller.status().state).toBe("reconnecting");
expect(clock.pendingTasks()).toBe(1);
clock.runNext();
await hub.expectAttemptReleased(2);
expect(hub.attemptCount()).toBe(2);
expect(controller.status().state).toBe("revoked");
expect(clock.pendingTasks()).toBe(0);
expect(hub.openConnections()).toBe(0);
});
test("controller redials once after a failed upgrade without also handling its close", async () => {
const hub = await UpgradeRejectingHub.start([0, 401]);
const clock = new ManualRelationshipClock();
const controller = await connectController(hub, clock);
await hub.expectAttemptReleased(1);
expect(controller.status().state).toBe("reconnecting");
expect(clock.scheduledAttempts()).toEqual([0]);
expect(clock.pendingTasks()).toBe(1);
clock.runNext();
await hub.expectAttemptReleased(2);
expect(hub.attemptCount()).toBe(2);
expect(controller.status().state).toBe("revoked");
expect(clock.pendingTasks()).toBe(0);
});
async function startHubReturning(status: number): Promise<string> {
const server = createServer((_request, response) => {
response.writeHead(status).end();
});
openServers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
async function startEnrollmentHub(webSocketUrl: string): Promise<string> {
const server = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const enrollment = JSON.parse(body) as { daemonId: string };
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
scopes: ["hub.execution.*"],
webSocketUrl,
}),
);
});
openServers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
async function startStalledHub(): Promise<string> {
const server = createServer(() => undefined);
openServers.push(server);
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
type SocketOutcome =
| { type: "connected" }
| { type: "rejected"; status: 401 | 403 }
| { type: "closed"; code: number }
| { type: "failed"; message: string };
interface Deferred<T> {
promise: Promise<T>;
resolve(value: T): void;
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((accept) => {
resolve = accept;
});
return { promise, resolve };
}
class SocketOutcomes {
private readonly outcomes: SocketOutcome[] = [];
private nextOutcome = deferred<SocketOutcome>();
readonly events: HubSocketEvents = {
connected: () => this.record({ type: "connected" }),
rejected: (status) => this.record({ type: "rejected", status }),
closed: (code) => this.record({ type: "closed", code }),
failed: (error) => this.record({ type: "failed", message: error.message }),
};
next(): Promise<SocketOutcome> {
return withDeadline(this.nextOutcome.promise, "Hub socket produced no outcome");
}
async afterCleanup(): Promise<SocketOutcome[]> {
await new Promise<void>((resolve) => setImmediate(resolve));
return this.outcomes.slice();
}
private record(outcome: SocketOutcome): void {
this.outcomes.push(outcome);
this.nextOutcome.resolve(outcome);
this.nextOutcome = deferred<SocketOutcome>();
}
}
class UpgradeRejectingHub {
private readonly server = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const enrollment = JSON.parse(body) as { daemonId: string };
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
scopes: ["hub.execution.*"],
webSocketUrl: this.webSocketUrl,
}),
);
});
private readonly sockets = new Set<Socket>();
private readonly releasedAttempts = new Map<number, Deferred<void>>();
private attemptObserved = deferred<void>();
private attempts = 0;
private constructor(private readonly statuses: Array<number | "stall">) {
this.server.on("upgrade", (_request, socket) => {
const attempt = ++this.attempts;
this.attemptObserved.resolve();
this.attemptObserved = deferred<void>();
const released = deferred<void>();
this.releasedAttempts.set(attempt, released);
this.sockets.add(socket);
socket.once("close", () => {
this.sockets.delete(socket);
released.resolve();
});
const status = this.statuses[attempt - 1] ?? this.statuses.at(-1) ?? 503;
if (status === "stall") {
socket.resume();
return;
}
if (status === 0) {
socket.destroy();
return;
}
socket.end(
`HTTP/1.1 ${status} Rejected\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndeny`,
);
});
}
static async start(statuses: Array<number | "stall">): Promise<UpgradeRejectingHub> {
const hub = new UpgradeRejectingHub(statuses);
openUpgradeHubs.push(hub);
openServers.push(hub.server);
await new Promise<void>((resolve, reject) => {
hub.server.once("error", reject);
hub.server.listen(0, "127.0.0.1", resolve);
});
return hub;
}
get webSocketUrl(): string {
const address = this.server.address() as AddressInfo;
return `ws://127.0.0.1:${address.port}/daemon`;
}
get origin(): string {
const address = this.server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
attemptCount(): number {
return this.attempts;
}
openConnections(): number {
return this.sockets.size;
}
destroyConnections(): void {
for (const socket of this.sockets) socket.destroy();
}
async expectAttemptReleased(attempt: number): Promise<void> {
while (this.attempts < attempt) {
await withDeadline(
this.attemptObserved.promise,
`Hub did not receive socket attempt ${attempt}`,
);
}
const released = this.releasedAttempts.get(attempt);
if (!released) throw new Error(`Hub did not receive socket attempt ${attempt}`);
await withDeadline(released.promise, `Hub socket attempt ${attempt} remained open`);
await new Promise<void>((resolve) => setImmediate(resolve));
}
}
class ManualRelationshipClock implements HubRelationshipClock, HubRelationshipRetryPolicy {
private readonly tasks: Array<{ cancelled: boolean; task: () => void }> = [];
private readonly attempts: number[] = [];
now(): Date {
return new Date("2026-07-13T00:00:00.000Z");
}
delay(attempt: number): number {
this.attempts.push(attempt);
return 0;
}
schedule(_delayMs: number, task: () => void): ScheduledRelationshipTask {
const scheduled = { cancelled: false, task };
this.tasks.push(scheduled);
return { cancel: () => (scheduled.cancelled = true) };
}
pendingTasks(): number {
return this.tasks.filter((task) => !task.cancelled).length;
}
scheduledAttempts(): number[] {
return this.attempts.slice();
}
runNext(): void {
const scheduled = this.tasks.shift();
if (!scheduled || scheduled.cancelled) throw new Error("No Hub reconnect is scheduled");
scheduled.task();
}
}
const unusedExecutionAgents: HubExecutionAgents = {
create: async () => {
throw new Error("Unexpected Hub agent create");
},
subscribe: () => () => undefined,
invalidateAuthority: async () => undefined,
};
async function connectController(
hub: UpgradeRejectingHub,
clock: ManualRelationshipClock,
): Promise<HubRelationshipController> {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-hub-socket-"));
openPaseoHomes.push(paseoHome);
const controller = new HubRelationshipController({
paseoHome,
serverId: "server-1",
daemonPublicKey: "daemon-public-key",
logger: pino({ level: "silent" }),
remote: new DirectHubRelationshipRemote(),
clock,
retryPolicy: clock,
attachSocket: async () => undefined,
createExecutionAgents: () => unusedExecutionAgents,
});
await controller.connect({ hubUrl: hub.origin, token: "enrollment-token" });
return controller;
}
async function withDeadline<T>(promise: Promise<T>, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), 2_000);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
@@ -0,0 +1,187 @@
import { WebSocket } from "ws";
import { z } from "zod";
import type { WebSocketLike } from "../websocket-server.js";
export interface HubEnrollment {
daemonId: string;
idempotencyKey: string;
hubOrigin: string;
token: string;
serverId: string;
daemonPublicKey: string;
credentialVerifier: string;
scopes: string[];
}
export interface HubEnrollmentResult {
daemonId: string;
scopes: string[];
webSocketUrl: string;
}
export interface HubRevocation {
daemonId: string;
hubOrigin: string;
credential: string;
}
export interface HubSocketCredentials {
daemonId: string;
webSocketUrl: string;
credential: string;
}
export interface HubSocketEvents {
connected(socket: WebSocketLike): void;
rejected(statusCode: 401 | 403): void;
closed(code: number): void;
failed(error: Error): void;
}
export interface HubSocketConnection {
close(): void;
}
export interface HubRelationshipRemote {
enroll(input: HubEnrollment): Promise<HubEnrollmentResult>;
revoke(input: HubRevocation): Promise<void>;
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection;
}
export class HubEnrollmentRejectedError extends Error {
constructor(readonly statusCode: number) {
super(`Hub enrollment failed (${statusCode})`);
this.name = "HubEnrollmentRejectedError";
}
}
const EnrollmentResultSchema = z.object({
daemonId: z.string(),
scopes: z.array(z.string()),
webSocketUrl: z
.string()
.url()
.refine((value) => ["ws:", "wss:"].includes(new URL(value).protocol), {
message: "Hub WebSocket URL must use ws or wss",
})
.refine((value) => new URL(value).hash === "", {
message: "Hub WebSocket URL cannot include a fragment",
}),
});
function ensureWebSocketMatchesHubOrigin(hubOrigin: string, webSocketUrl: string): void {
const hub = new URL(hubOrigin);
const socket = new URL(webSocketUrl);
const expectedProtocol = hub.protocol === "https:" ? "wss:" : "ws:";
if (socket.protocol !== expectedProtocol || socket.host !== hub.host) {
throw new Error("Hub WebSocket URL must match the Hub origin");
}
}
export class DirectHubRelationshipRemote implements HubRelationshipRemote {
private readonly requestTimeoutMs: number;
constructor(options: { requestTimeoutMs?: number } = {}) {
this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
}
async enroll(input: HubEnrollment): Promise<HubEnrollmentResult> {
return this.withRequestTimeout(async (signal) => {
const response = await fetch(`${input.hubOrigin}/api/daemons/enroll`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${input.token}`,
},
body: JSON.stringify({
daemonId: input.daemonId,
idempotencyKey: input.idempotencyKey,
serverId: input.serverId,
daemonPublicKey: input.daemonPublicKey,
credentialVerifier: input.credentialVerifier,
scopes: input.scopes,
}),
signal,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new HubEnrollmentRejectedError(response.status);
}
throw new Error(`Hub enrollment failed (${response.status})`);
}
const enrollment = EnrollmentResultSchema.parse(await response.json());
ensureWebSocketMatchesHubOrigin(input.hubOrigin, enrollment.webSocketUrl);
return enrollment;
});
}
async revoke(input: HubRevocation): Promise<void> {
await this.withRequestTimeout(async (signal) => {
const response = await fetch(
`${input.hubOrigin}/api/daemons/${encodeURIComponent(input.daemonId)}`,
{
method: "DELETE",
headers: { authorization: `Bearer ${input.credential}` },
signal,
},
);
if (!response.ok && ![401, 403, 404].includes(response.status)) {
throw new Error(`Hub revocation failed (${response.status})`);
}
});
}
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
const socket = new WebSocket(input.webSocketUrl, {
handshakeTimeout: this.requestTimeoutMs,
headers: {
authorization: `Bearer ${input.credential}`,
"x-paseo-daemon-id": input.daemonId,
},
});
let settled = false;
socket.once("open", () => {
if (!settled) events.connected(socket as WebSocketLike);
});
socket.once("unexpected-response", (_request, response) => {
if (settled) {
response.destroy();
return;
}
settled = true;
response.destroy();
socket.terminate();
if (response.statusCode === 401 || response.statusCode === 403) {
events.rejected(response.statusCode);
return;
}
events.closed(1006);
});
socket.once("close", (code) => {
if (settled) return;
settled = true;
events.closed(code);
});
socket.once("error", (error) => {
if (settled) return;
settled = true;
socket.terminate();
events.failed(error);
});
return socket;
}
private async withRequestTimeout<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
timeout.unref?.();
try {
return await operation(controller.signal);
} catch (error) {
if (controller.signal.aborted) throw new Error("Hub request timed out", { cause: error });
throw error;
} finally {
clearTimeout(timeout);
}
}
}
@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js";
describe("Hub relationship retry policy", () => {
test("keeps jitter within the lower and upper bounds", () => {
const lower = new BoundedExponentialHubRetryPolicy(() => 0);
const upper = new BoundedExponentialHubRetryPolicy(() => 1);
expect(lower.delay(0)).toBe(375);
expect(upper.delay(0)).toBe(625);
});
test("grows exponentially and caps the base delay", () => {
const retry = new BoundedExponentialHubRetryPolicy(() => 0.5);
expect([0, 1, 2, 3, 4, 5, 6, 20].map((attempt) => retry.delay(attempt))).toEqual([
500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000,
]);
});
test("never schedules a zero-delay tight loop", () => {
const retry = new BoundedExponentialHubRetryPolicy(() => 0);
expect([0, 1, 2].map((attempt) => retry.delay(attempt))).toEqual([375, 750, 1_500]);
});
});
@@ -0,0 +1,16 @@
import type { HubRelationshipRetryPolicy } from "./relationship-controller.js";
const INITIAL_DELAY_MS = 500;
const MAX_BASE_DELAY_MS = 30_000;
const MIN_JITTER_FACTOR = 0.75;
const JITTER_RANGE = 0.5;
export class BoundedExponentialHubRetryPolicy implements HubRelationshipRetryPolicy {
constructor(private readonly random: () => number = Math.random) {}
delay(attempt: number): number {
const base = Math.min(MAX_BASE_DELAY_MS, INITIAL_DELAY_MS * 2 ** attempt);
const jitter = MIN_JITTER_FACTOR + this.random() * JITTER_RANGE;
return Math.max(1, Math.round(base * jitter));
}
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveDaemonVersion } from "./daemon-version.js";
import { resolveLogConfig } from "./logger.js";
import { loadConfig } from "./config.js";
import type { PersistedConfig } from "./persisted-config.js";
@@ -153,6 +154,24 @@ describe("loadConfig logger config", () => {
});
describe("createRootLogger", () => {
it("includes the daemon version in child logger entries", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-version-"));
const { stdout } = await runLoggerFixture(`
const logger = createRootLogger(undefined, { paseoHome: ${JSON.stringify(paseoHome)} });
logger.child({ name: "fixture" }).info("versioned child logger");
logger.flush();
`);
expect(JSON.parse(stdout)).toMatchObject({
pid: expect.any(Number),
hostname: expect.any(String),
daemonVersion: resolveDaemonVersion(),
name: "fixture",
msg: "versioned child logger",
});
});
it("writes JSON to stdout by default and does not initialize file logging", async () => {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-logger-default-"));
const missingLogDir = path.join(paseoHome, "logs");
+4 -1
View File
@@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs";
import path from "node:path";
import pino from "pino";
import pretty from "pino-pretty";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { PersistedConfig } from "./persisted-config.js";
import { resolvePaseoHome } from "./paseo-home.js";
@@ -180,13 +181,15 @@ export function createRootLogger(
})
: pino.destination({ dest: config.file?.path ?? 1, sync: false });
return pino(
const logger = pino(
{
level: config.file?.level ?? config.console.level,
redact: { paths: REDACT_PATHS, remove: true },
},
stream,
);
return logger.child({ daemonVersion: resolveDaemonVersion() });
}
export function createChildLogger(parent: pino.Logger, name: string): pino.Logger {
@@ -32,7 +32,7 @@ import { AgentStorage } from "./agent/agent-storage.js";
import { AgentManager } from "./agent/agent-manager.js";
import { createAgentCommand } from "./agent/create-agent/create.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { createLocalCheckoutWorkspace } from "./paseo-worktree-service.js";
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { LoopService } from "./loop-service.js";
@@ -108,12 +108,17 @@ async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{
await workspaceRegistry.initialize();
await projectRegistry.initialize();
const workspaceGitService = createNoopWorkspaceGitService();
const workspaceProvisioning = createWorkspaceProvisioningService({
projectRegistry,
workspaceRegistry,
workspaceGitService,
});
return {
workspaceRegistry,
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
const workspace = await createLocalCheckoutWorkspace(
{ cwd, title: firstAgentContext?.prompt ?? null },
{ projectRegistry, workspaceRegistry, workspaceGitService },
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
cwd,
firstAgentContext?.prompt ?? null,
);
return workspace.workspaceId;
},
+12 -3
View File
@@ -241,6 +241,7 @@ type LoopAgentManager = Pick<
| "archiveAgent"
| "cancelAgentRun"
| "closeAgent"
| "deleteAgentState"
| "getAgent"
| "runAgent"
| "subscribe"
@@ -570,7 +571,7 @@ export class LoopService {
await this.options.agentManager.archiveAgent(agentId);
return;
}
await this.options.agentManager.closeAgent(agentId);
await this.closeInternalAgent(agentId);
} catch (error) {
if (!isUnknownLoopAgentError(error, agentId)) {
throw error;
@@ -578,6 +579,14 @@ export class LoopService {
}
}
private async closeInternalAgent(agentId: string): Promise<void> {
try {
await this.options.agentManager.closeAgent(agentId);
} finally {
await this.options.agentManager.deleteAgentState(agentId);
}
}
private async executeLoop(loopId: string, signal: AbortSignal): Promise<void> {
const loop = this.requireLoop(loopId);
const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null;
@@ -775,7 +784,7 @@ export class LoopService {
if (loop.archive) {
await this.options.agentManager.archiveAgent(agent.id);
} else {
await this.options.agentManager.closeAgent(agent.id);
await this.closeInternalAgent(agent.id);
}
} catch {
// Ignore cleanup errors for internal loop workers.
@@ -917,7 +926,7 @@ export class LoopService {
if (loop.archive) {
await this.options.agentManager.archiveAgent(verifierAgent.id);
} else {
await this.options.agentManager.closeAgent(verifierAgent.id);
await this.closeInternalAgent(verifierAgent.id);
}
} catch {
// Ignore cleanup errors for internal loop verifiers.
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test, vi } from "vitest";
@@ -10,17 +10,19 @@ import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
import { createTestLogger } from "../test-utils/test-logger.js";
import {
attemptFirstAgentBranchAutoName,
createLocalCheckoutWorkspace,
createPaseoWorktree,
type CreatePaseoWorktreeDeps,
} from "./paseo-worktree-service.js";
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { createWorktree } from "../utils/worktree.js";
import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js";
import { isPlatform } from "../test-utils/platform.js";
import { existsSync } from "node:fs";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
const cleanupPaths: string[] = [];
@@ -55,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou
{
cwd: repoDir,
worktreeSlug: "feature-one",
title: "Feature One",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
@@ -68,11 +71,309 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(result.workspace.baseBranch).toBe("main");
expect(result.workspace.title).toBe("Feature One");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(events).toEqual([
"project:remote:github.com/acme/repo",
`workspace:${result.workspace.workspaceId}`,
]);
expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject);
expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]);
});
test("refreshes a source project that became Git while creating a worktree", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps();
const sourceProject = {
...createPersistedProjectRecordForTest({
projectId: "prj_existing-source",
rootPath: repoDir,
displayName: "Repository",
}),
kind: "non_git" as const,
customName: "My project",
};
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: "ws-main-checkout",
projectId: sourceProject.projectId,
cwd: repoDir,
kind: "local_checkout",
displayName: "main",
});
deps.projects.set(sourceProject.projectId, sourceProject);
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
const result = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "project-became-git",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(result.workspace.projectId).toBe(sourceProject.projectId);
expect(deps.projects.get(sourceProject.projectId)).toMatchObject({
projectId: sourceProject.projectId,
rootPath: repoDir,
kind: "git",
displayName: "Repository",
customName: "My project",
archivedAt: null,
});
});
test("repairs a legacy source workspace whose project record is missing", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps();
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: "ws-missing-project",
projectId: "project-missing",
cwd: repoDir,
kind: "local_checkout",
displayName: "main",
});
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
const result = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "repaired-source",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(result.workspace.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
expect(result.workspace.projectId).not.toBe(sourceWorkspace.projectId);
const repairedProject = deps.projects.get(result.workspace.projectId);
expect(repairedProject).toMatchObject({
projectId: result.workspace.projectId,
kind: "git",
archivedAt: null,
});
expect(createRealpathAwarePathMatcher(repoDir)(repairedProject?.rootPath ?? "")).toBe(true);
});
test("uses an equivalent source workspace path when creating a worktree", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const sourceDir = path.join(repoDir, "app");
mkdirSync(sourceDir);
writeFileSync(path.join(repoDir, "app", ".gitkeep"), "");
commitAll(repoDir, "add app");
const deps = createDeps();
const sourceProject = createPersistedProjectRecordForTest({
projectId: "prj_source-folder",
rootPath: sourceDir,
displayName: "app",
});
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: "ws-source-folder",
projectId: sourceProject.projectId,
cwd: `${sourceDir}${path.sep}`,
kind: "local_checkout",
displayName: "app",
});
deps.projects.set(sourceProject.projectId, sourceProject);
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
const result = await createPaseoWorktree(
{
cwd: sourceDir,
worktreeSlug: "equivalent-source",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(result.workspace.projectId).toBe(sourceProject.projectId);
});
test("creates a worktree workspace at the selected project subdirectory", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const sourceDir = path.join(repoDir, "packages", "app");
mkdirSync(sourceDir, { recursive: true });
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
commitAll(repoDir, "add subproject");
const deps = createDeps();
const project = createPersistedProjectRecordForTest({
projectId: "prj_selected-subdirectory",
rootPath: sourceDir,
displayName: "app",
});
deps.projects.set(project.projectId, project);
const result = await createPaseoWorktree(
{
cwd: sourceDir,
projectId: project.projectId,
worktreeSlug: "selected-subdirectory",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(result.workspace).toMatchObject({
projectId: project.projectId,
cwd: path.join(result.worktree.worktreePath, "packages", "app"),
worktreeRoot: result.worktree.worktreePath,
kind: "worktree",
});
});
test("seeds an uncommitted exact-project config into the mapped worktree directory", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const sourceDir = path.join(repoDir, "packages", "app");
mkdirSync(sourceDir, { recursive: true });
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
commitAll(repoDir, "add subproject");
const config = JSON.stringify({ worktree: { setup: ["npm install"] } });
writeFileSync(path.join(sourceDir, "paseo.json"), config);
const result = await createPaseoWorktree(
{
cwd: sourceDir,
worktreeSlug: "seed-nested-config",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
expect(readFileSync(path.join(result.workspace.cwd, "paseo.json"), "utf8")).toBe(config);
expect(existsSync(path.join(result.worktree.worktreePath, "paseo.json"))).toBe(false);
});
test("removes a new worktree when its ref does not contain the selected project directory", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
execFileSync("git", ["branch", "without-subproject"], { cwd: repoDir, stdio: "pipe" });
const sourceDir = path.join(repoDir, "packages", "app");
mkdirSync(sourceDir, { recursive: true });
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
commitAll(repoDir, "add subproject");
const deps = createDeps();
const paseoHome = path.join(tempDir, ".paseo");
const worktreePath = path.join(
await getPaseoWorktreesRoot(repoDir, paseoHome),
"missing-subproject",
);
await expect(
createPaseoWorktree(
{
cwd: sourceDir,
action: "checkout",
refName: "without-subproject",
worktreeSlug: "missing-subproject",
runSetup: false,
paseoHome,
},
deps,
),
).rejects.toThrow("Selected project directory is missing from the worktree");
expect(deps.workspaces.size).toBe(0);
expect(existsSync(worktreePath)).toBe(false);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" })
.toString()
.includes("missing-subproject"),
).toBe(false);
});
test("removes a new worktree when workspace persistence fails", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const worktreePath = path.join(
await getPaseoWorktreesRoot(repoDir, paseoHome),
"persistence-failure",
);
await expect(
createPaseoWorktree(
{
cwd: repoDir,
projectId: "missing-project",
worktreeSlug: "persistence-failure",
runSetup: false,
paseoHome,
},
createDeps(),
),
).rejects.toThrow("Unknown project: missing-project");
expect(existsSync(worktreePath)).toBe(false);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" })
.toString()
.includes("persistence-failure"),
).toBe(false);
});
test("maps a nested cwd from an existing Paseo worktree into the next worktree", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const projectDir = path.join(repoDir, "packages", "app");
mkdirSync(projectDir, { recursive: true });
writeFileSync(path.join(projectDir, "package.json"), "{}\n");
commitAll(repoDir, "add subproject");
const deps = createDeps();
const source = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "source-worktree",
runSetup: false,
paseoHome,
},
deps,
);
const sourceCwd = path.join(source.worktree.worktreePath, "packages", "app");
const created = await createPaseoWorktree(
{
cwd: sourceCwd,
worktreeSlug: "nested-worktree",
runSetup: false,
paseoHome,
},
deps,
);
expect(created.workspace.cwd).toBe(path.join(created.worktree.worktreePath, "packages", "app"));
expect(deps.workspaces.get(created.workspace.workspaceId)).toEqual(created.workspace);
});
test("rejects source checkout planning before creating a worktree", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const deps = createDeps();
deps.workspaceGitService.getCheckout = async () => {
throw new Error("source checkout unavailable");
};
await expect(
createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "must-not-create",
runSetup: false,
paseoHome,
},
deps,
),
).rejects.toThrow("source checkout unavailable");
expect(existsSync(path.join(paseoHome, "worktrees"))).toBe(false);
expect(Array.from(deps.workspaces.values())).toEqual([]);
});
test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => {
@@ -109,6 +410,35 @@ test("registers a new worktree in the existing root project after the main check
expect(Array.from(deps.projects.keys()).sort()).toEqual(["remote:github.com/acme/repo"]);
});
test("an explicit project FK remains unchanged when its worktree comes from another checkout", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps();
const project = {
...createPersistedProjectRecordForTest({
projectId: "prj_explicitproject",
rootPath: path.join(tempDir, "unrelated"),
displayName: "unrelated",
}),
kind: "non_git" as const,
};
deps.projects.set(project.projectId, project);
const result = await createPaseoWorktree(
{
cwd: repoDir,
projectId: project.projectId,
worktreeSlug: "attached-worktree",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
deps,
);
expect(result.workspace.projectId).toBe(project.projectId);
expect(deps.projects.get(project.projectId)).toEqual(project);
});
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
test.skipIf(isPlatform("win32"))(
"reuses an existing worktree and still upserts the workspace",
@@ -152,19 +482,6 @@ test.skipIf(isPlatform("win32"))(
},
);
test("creates a distinct local checkout workspace for the same cwd on every call", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const deps = createDeps();
const first = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
const second = await createLocalCheckoutWorkspace({ cwd: repoDir }, deps);
expect(first.cwd).toBe(second.cwd);
expect(first.workspaceId).not.toBe(second.workspaceId);
expect(deps.workspaces.size).toBe(2);
});
test("renames an eligible unnamed branch-off worktree once on first agent context", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
@@ -679,7 +996,6 @@ test.skipIf(isPlatform("win32"))(
);
interface TestDeps extends CreatePaseoWorktreeDeps {
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
projects: Map<string, PersistedProjectRecord>;
workspaces: Map<string, PersistedWorkspaceRecord>;
}
@@ -692,28 +1008,73 @@ function createDeps(options?: {
const events = options?.events ?? [];
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
const workspaces = options?.workspaces ?? new Map<string, PersistedWorkspaceRecord>();
const projectRegistry: ProjectRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (projectId) => projects.get(projectId) ?? null,
getOrCreateActiveByRoot: async (input) => {
const existing = Array.from(projects.values()).find(
(project) => !project.archivedAt && areEquivalentPaths(project.rootPath, input.rootPath),
);
if (existing) return existing;
const project = createPersistedProjectRecordForTest({
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
rootPath: input.rootPath,
displayName: input.displayName,
});
projects.set(project.projectId, project);
return project;
},
upsert: async (project) => {
projects.set(project.projectId, project);
},
archive: async (projectId, archivedAt) => {
const project = projects.get(projectId);
if (project) projects.set(projectId, { ...project, archivedAt });
},
remove: async (projectId) => {
projects.delete(projectId);
},
};
const workspaceRegistry: WorkspaceRegistry = {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
update: async (workspaceId, updater) => {
const workspace = workspaces.get(workspaceId);
if (!workspace) return null;
const updated = updater(workspace);
workspaces.set(workspaceId, updated);
return updated;
},
upsert: async (record) => {
events.push(`workspace:${record.workspaceId}`);
workspaces.set(record.workspaceId, record);
},
archive: async (workspaceId, archivedAt) => {
const workspace = workspaces.get(workspaceId);
if (workspace) workspaces.set(workspaceId, { ...workspace, archivedAt });
},
remove: async (workspaceId) => {
workspaces.delete(workspaceId);
},
};
const workspaceGitService = createWorkspaceGitServiceStub();
const workspaceProvisioning = createWorkspaceProvisioningService({
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger: createTestLogger(),
});
return {
github: createGitHubServiceStub(),
projects,
workspaces,
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
list: async () => Array.from(projects.values()),
upsert: async (record) => {
events.push(`project:${record.projectId}`);
projects.set(record.projectId, record);
},
},
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record) => {
events.push(`workspace:${record.workspaceId}`);
workspaces.set(record.workspaceId, record);
},
},
workspaceGitService: createWorkspaceGitServiceStub(),
workspaceGitService,
workspaceProvisioning,
};
}
@@ -808,15 +1169,30 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
unsubscribe: () => {},
}),
peekSnapshot: (cwd) => createWorkspaceGitSnapshot(cwd),
getCheckout: async (cwd) => ({
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
getCheckout: async (cwd) => {
try {
const snapshot = createWorkspaceGitSnapshot(cwd);
return {
cwd,
isGit: snapshot.git.isGit,
currentBranch: snapshot.git.currentBranch,
remoteUrl: snapshot.git.remoteUrl,
worktreeRoot: snapshot.git.repoRoot,
isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree,
mainRepoRoot: snapshot.git.mainRepoRoot,
};
} catch {
return {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
},
getSnapshot: async (cwd) => createWorkspaceGitSnapshot(cwd),
resolveForge: async () => null,
resolveRepoRoot: async (cwd) => {
@@ -902,6 +1278,11 @@ function createGitRepo(): { tempDir: string; repoDir: string } {
return { tempDir, repoDir };
}
function commitAll(repoDir: string, message: string): void {
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", message], { cwd: repoDir, stdio: "pipe" });
}
function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } {
const { tempDir, repoDir } = createGitRepo();
execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" });
@@ -1,23 +1,22 @@
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
import {
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import {
classifyDirectoryForProjectMembership,
deriveProjectGroupingName,
generateWorkspaceId,
} from "./workspace-registry-model.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { getRealpathAwareRelativePath } from "../utils/path.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { WorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
import {
createWorktreeCore,
type CreateWorktreeCoreDeps,
type CreateWorktreeCoreInput,
} from "./worktree-core.js";
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
import {
mapWorkspaceRelativeCwdToWorktree,
rollbackCreatedPaseoWorktree,
seedPaseoConfigFile,
validateBranchSlug,
type WorktreeConfig,
} from "../utils/worktree.js";
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
@@ -32,6 +31,7 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages";
export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
projectId?: string;
title?: string;
}
export interface CreatePaseoWorktreeResult {
@@ -56,36 +56,89 @@ export interface AttemptFirstAgentBranchAutoNameResult {
}
export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
workspaceGitService: WorkspaceGitService;
workspaceProvisioning: Pick<WorkspaceProvisioningService, "createWorkspaceForWorktree">;
}
export async function createPaseoWorktree(
input: CreatePaseoWorktreeInput,
deps: CreatePaseoWorktreeDeps,
): Promise<CreatePaseoWorktreeResult> {
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
const createdWorktree = await createWorktreeCore(input, deps);
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
const workspace = await upsertWorkspaceForWorktree({
inputCwd: input.cwd,
projectId: input.projectId,
repoRoot: createdWorktree.repoRoot,
worktree: createdWorktree.worktree,
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
deps,
});
try {
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
targetWorktreePath: createdWorktree.worktree.worktreePath,
});
if (!(await isDirectory(workspaceCwd))) {
throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`);
}
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
if (createdWorktree.created) {
await seedPaseoConfigFile({
sourceCwd: workspaceCwdPlan.inputCwd,
targetCwd: workspaceCwd,
});
}
const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({
sourceCwd: workspaceCwdPlan.inputCwd,
projectId: input.projectId,
repoRoot: createdWorktree.repoRoot,
cwd: workspaceCwd,
worktreeRoot: createdWorktree.worktree.worktreePath,
branch: createdWorktree.worktree.branchName || null,
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
});
return {
worktree: createdWorktree.worktree,
intent: createdWorktree.intent,
workspace,
repoRoot: createdWorktree.repoRoot,
created: createdWorktree.created,
};
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
return {
worktree: createdWorktree.worktree,
intent: createdWorktree.intent,
workspace,
repoRoot: createdWorktree.repoRoot,
created: createdWorktree.created,
};
} catch (error) {
if (!createdWorktree.created) {
throw error;
}
return rollbackCreatedPaseoWorktree(
{
cwd: createdWorktree.repoRoot,
worktreePath: createdWorktree.worktree.worktreePath,
...(input.runSetup === false ? { teardownCwds: [] } : {}),
paseoHome: input.paseoHome,
worktreesBaseRoot: input.worktreesRoot,
},
error,
);
}
}
async function isDirectory(targetPath: string): Promise<boolean> {
try {
return (await stat(targetPath)).isDirectory();
} catch {
return false;
}
}
async function planWorkspaceCwdForWorktree(
inputCwd: string,
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> {
const normalizedInputCwd = resolve(inputCwd);
const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd);
const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd;
const relativeWorkspaceCwd = getRealpathAwareRelativePath(sourceWorktreePath, normalizedInputCwd);
if (relativeWorkspaceCwd === null) {
throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`);
}
return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd };
}
export async function attemptFirstAgentBranchAutoName(options: {
@@ -213,257 +266,3 @@ function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null
return null;
}
}
async function upsertWorkspaceForWorktree(options: {
inputCwd: string;
projectId?: string;
repoRoot: string;
worktree: WorktreeConfig;
baseBranch?: string | null;
title?: string | null;
deps: Pick<
CreatePaseoWorktreeDeps,
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
>;
}): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = resolve(options.worktree.worktreePath);
const normalizedInputCwd = resolve(options.inputCwd);
const normalizedRepoRoot = resolve(options.repoRoot);
// Creation never deduplicates by directory: a worktree directory may back
// more than one workspace. We still resolve the source project from the
// originating checkout, but always mint a fresh workspace record.
const sourceProject = await resolveSourceProjectForWorktree({
inputCwd: normalizedInputCwd,
projectId: options.projectId,
repoRoot: normalizedRepoRoot,
existingWorkspace: null,
deps: options.deps,
});
const workspaceId = generateWorkspaceId();
const now = new Date().toISOString();
await options.deps.projectRegistry.upsert(
createPersistedProjectRecord({
projectId: sourceProject.projectId,
rootPath: sourceProject.rootPath,
kind: sourceProject.kind,
displayName: sourceProject.displayName,
customName: sourceProject.customName,
createdAt: sourceProject.createdAt ?? now,
updatedAt: now,
archivedAt: null,
}),
);
const workspace = createPersistedWorkspaceRecord({
workspaceId,
projectId: sourceProject.projectId,
cwd: normalizedCwd,
kind: "worktree",
displayName: options.worktree.branchName || normalizedCwd,
branch: options.worktree.branchName || null,
baseBranch: options.baseBranch ?? null,
title: options.title ?? null,
createdAt: now,
updatedAt: now,
archivedAt: null,
});
await options.deps.workspaceRegistry.upsert(workspace);
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
}
export interface CreateLocalCheckoutWorkspaceDeps {
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
workspaceRegistry: Pick<WorkspaceRegistry, "list" | "upsert">;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
}
// Always create a NEW workspace record backed by the existing directory `cwd`.
// Never reuses a same-cwd record: a directory may back any number of
// workspaces. Used by explicit user creation.
export async function createLocalCheckoutWorkspace(
options: { cwd: string; title?: string | null },
deps: CreateLocalCheckoutWorkspaceDeps,
): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = resolve(options.cwd);
const checkout = await deps.workspaceGitService.getCheckout(normalizedCwd);
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
const now = new Date().toISOString();
const projectRecord = await resolveProjectRecordForMembership({
membership,
timestamp: now,
projectRegistry: deps.projectRegistry,
});
await deps.projectRegistry.upsert(projectRecord);
const trimmedTitle = options.title?.trim();
// Persist the live git branch into the dedicated `branch` field so
// buildWorkspaceCheckout reports the real branch for directory/local_checkout
// workspaces too (it reads workspace.branch). Same source deriveWorkspaceDisplayName
// reads. HEAD/detached resolves to null — there is no branch to report.
const currentBranch = checkout.currentBranch?.trim() ?? null;
const branch = currentBranch && currentBranch.toUpperCase() !== "HEAD" ? currentBranch : null;
const workspace = createPersistedWorkspaceRecord({
workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId,
cwd: normalizedCwd,
kind: membership.workspaceKind,
displayName: membership.workspaceDisplayName,
branch,
title: trimmedTitle ? trimmedTitle : null,
createdAt: now,
updatedAt: now,
});
await deps.workspaceRegistry.upsert(workspace);
return workspace;
}
async function resolveProjectRecordForMembership(options: {
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
timestamp: string;
projectRegistry: Pick<ProjectRegistry, "get" | "list">;
}) {
const rootPath = options.membership.projectRootPath;
const projects = await options.projectRegistry.list();
const existingProject =
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
projects.find((project) => project.rootPath === rootPath) ??
null;
if (!existingProject) {
return createPersistedProjectRecord({
projectId: options.membership.projectKey,
rootPath,
kind: options.membership.projectKind,
displayName: options.membership.projectName,
createdAt: options.timestamp,
updatedAt: options.timestamp,
});
}
return {
...existingProject,
rootPath,
kind: options.membership.projectKind,
archivedAt: null,
updatedAt: options.timestamp,
};
}
interface SourceProjectForWorktree {
projectId: string;
rootPath: string;
kind: "git";
displayName: string;
customName: string | null;
createdAt: string | null;
}
function sourceProjectFromRecord(record: {
projectId: string;
rootPath: string;
displayName: string;
customName?: string | null;
createdAt?: string | null;
}): SourceProjectForWorktree {
return {
projectId: record.projectId,
rootPath: record.rootPath,
kind: "git",
displayName: record.displayName,
customName: record.customName ?? null,
createdAt: record.createdAt ?? null,
};
}
async function resolveExplicitProjectForWorktree(options: {
projectId: string;
projectRegistry: Pick<ProjectRegistry, "get">;
}): Promise<SourceProjectForWorktree> {
const project = await options.projectRegistry.get(options.projectId);
if (!project || project.archivedAt) {
throw new Error(`Project not found for worktree: ${options.projectId}`);
}
return sourceProjectFromRecord(project);
}
async function resolveWorkspaceProjectForWorktree(options: {
sourceWorkspace: PersistedWorkspaceRecord;
repoRoot: string;
projectRegistry: Pick<ProjectRegistry, "get">;
}): Promise<SourceProjectForWorktree> {
const sourceProject = await options.projectRegistry.get(options.sourceWorkspace.projectId);
return sourceProjectFromRecord({
projectId: options.sourceWorkspace.projectId,
rootPath: sourceProject?.rootPath ?? options.repoRoot,
displayName:
sourceProject?.displayName ?? deriveProjectGroupingName(options.sourceWorkspace.projectId),
customName: sourceProject?.customName ?? null,
createdAt: sourceProject?.createdAt ?? null,
});
}
async function resolveFallbackProjectForWorktree(options: {
repoRoot: string;
projectRegistry: Pick<ProjectRegistry, "get">;
}): Promise<SourceProjectForWorktree> {
const existingFallbackProject = await options.projectRegistry.get(options.repoRoot);
return sourceProjectFromRecord({
projectId: options.repoRoot,
rootPath: existingFallbackProject?.rootPath ?? options.repoRoot,
displayName:
existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot),
customName: existingFallbackProject?.customName ?? null,
createdAt: existingFallbackProject?.createdAt ?? null,
});
}
async function resolveSourceProjectForWorktree(options: {
inputCwd: string;
projectId?: string;
repoRoot: string;
existingWorkspace: PersistedWorkspaceRecord | null;
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
}): Promise<SourceProjectForWorktree> {
if (options.projectId) {
return resolveExplicitProjectForWorktree({
projectId: options.projectId,
projectRegistry: options.deps.projectRegistry,
});
}
const sourceWorkspace =
options.existingWorkspace ??
(await findWorkspaceForSource({
inputCwd: options.inputCwd,
repoRoot: options.repoRoot,
workspaceRegistry: options.deps.workspaceRegistry,
}));
if (sourceWorkspace) {
return resolveWorkspaceProjectForWorktree({
sourceWorkspace,
repoRoot: options.repoRoot,
projectRegistry: options.deps.projectRegistry,
});
}
return resolveFallbackProjectForWorktree({
repoRoot: options.repoRoot,
projectRegistry: options.deps.projectRegistry,
});
}
async function findWorkspaceForSource(options: {
inputCwd: string;
repoRoot: string;
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
}): Promise<PersistedWorkspaceRecord | null> {
const workspaces = await options.workspaceRegistry.list();
return (
workspaces.find((workspace) => workspace.cwd === options.inputCwd && !workspace.archivedAt) ??
workspaces.find((workspace) => workspace.cwd === options.repoRoot && !workspace.archivedAt) ??
null
);
}
@@ -110,6 +110,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
lastUserMessageAt: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
owner?: StoredAgentRecord["owner"];
} {
return {
createdAt: new Date(record.createdAt),
@@ -117,6 +118,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
};
}
@@ -87,6 +87,8 @@ export function validateScheduleCadence(cadence: ScheduleCadence): void {
export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
if (cadence.type === "every") {
// COMPAT(scheduleEveryMs): execute legacy persisted rolling intervals until the
// compatibility floor reaches v0.2.0. Added in v0.2.0; remove after 2027-01-17.
return new Date(after.getTime() + cadence.everyMs);
}
@@ -23,7 +23,7 @@ import type {
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js";
import { createLocalCheckoutWorkspace } from "../paseo-worktree-service.js";
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
import { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js";
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
import {
@@ -66,15 +66,12 @@ let workspaceArchiveInProgress = false;
type TestScheduleServiceOptions = Omit<
ScheduleServiceOptions,
| "createAgent"
| "createLocalCheckoutWorkspace"
| "createPaseoWorktreeWorkspace"
| "archiveWorkspace"
"createAgent" | "createDirectoryWorkspace" | "createPaseoWorktreeWorkspace" | "archiveWorkspace"
> & {
agentManager: AgentManager;
providerSnapshotManager: Pick<ProviderSnapshotManager, "resolveCreateConfig">;
createAgent?: ScheduleServiceOptions["createAgent"];
createLocalCheckoutWorkspace?: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
createDirectoryWorkspace?: ScheduleServiceOptions["createDirectoryWorkspace"];
createPaseoWorktreeWorkspace?: ScheduleServiceOptions["createPaseoWorktreeWorkspace"];
archiveWorkspace?: ScheduleServiceOptions["archiveWorkspace"];
};
@@ -83,7 +80,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
let workspaceCounter = 0;
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const workspaceGitService = createNoopWorkspaceGitService();
const createDefaultWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"] = async (
const createDefaultWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"] = async (
input,
) => {
const timestamp = new Date().toISOString();
@@ -141,7 +138,6 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
},
{
scope: { kind: "workspace", workspaceId },
repoRoot: null,
requestId: "schedule-service-test",
},
);
@@ -163,7 +159,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
},
input,
)),
createLocalCheckoutWorkspace: options.createLocalCheckoutWorkspace ?? createDefaultWorkspace,
createDirectoryWorkspace: options.createDirectoryWorkspace ?? createDefaultWorkspace,
createPaseoWorktreeWorkspace:
options.createPaseoWorktreeWorkspace ??
(async (input) => {
@@ -182,7 +178,7 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promise<{
workspaceRegistry: FileBackedWorkspaceRegistry;
createLocalCheckoutWorkspace: ScheduleServiceOptions["createLocalCheckoutWorkspace"];
createDirectoryWorkspace: ScheduleServiceOptions["createDirectoryWorkspace"];
createArchiveWorkspace: (input: {
agentManager: AgentManager;
agentStorage: AgentStorage;
@@ -200,12 +196,17 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
await workspaceRegistry.initialize();
await projectRegistry.initialize();
const workspaceGitService = createNoopWorkspaceGitService();
const workspaceProvisioning = createWorkspaceProvisioningService({
projectRegistry,
workspaceRegistry,
workspaceGitService,
});
return {
workspaceRegistry,
createLocalCheckoutWorkspace: async (input) => {
return createLocalCheckoutWorkspace(
{ cwd: input.cwd, title: input.firstAgentContext.prompt },
{ projectRegistry, workspaceRegistry, workspaceGitService },
createDirectoryWorkspace: async (input) => {
return workspaceProvisioning.createWorkspaceForDirectory(
input.cwd,
input.firstAgentContext.prompt,
);
},
createArchiveWorkspace:
@@ -240,7 +241,6 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
},
{
scope: { kind: "workspace", workspaceId },
repoRoot: null,
requestId: "schedule-service-test",
},
);
@@ -376,6 +376,47 @@ describe("ScheduleService", () => {
expect(resumed.nextRunAt).toBe("2026-01-01T00:04:00.000Z");
});
test("lists only active schedules that target existing agents", async () => {
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
const activeAgentId = "00000000-0000-4000-8000-000000000201";
const pausedAgentId = "00000000-0000-4000-8000-000000000202";
const completedAgentId = "00000000-0000-4000-8000-000000000203";
const cadence = { type: "every" as const, everyMs: 60_000 };
await service.create({
prompt: "Keep active agent resident",
cadence,
target: { type: "agent", agentId: activeAgentId },
});
const paused = await service.create({
prompt: "Paused heartbeat",
cadence,
target: { type: "agent", agentId: pausedAgentId },
});
await service.pause(paused.id);
await service.create({
prompt: "Completed heartbeat",
cadence,
target: { type: "agent", agentId: completedAgentId },
});
await service.completeForAgent(completedAgentId);
await service.create({
prompt: "Fresh agent each run",
cadence,
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
await expect(service.listActiveAgentTargetIds()).resolves.toEqual(new Set([activeAgentId]));
});
test("completes schedules when max runs is reached", async () => {
const service = createScheduleService({
paseoHome: tempDir,
@@ -493,7 +534,7 @@ describe("ScheduleService", () => {
});
test("new-agent schedule records create no workspace until run time", async () => {
const { workspaceRegistry, createLocalCheckoutWorkspace: createScheduleLocalWorkspace } =
const { workspaceRegistry, createDirectoryWorkspace: createScheduleDirectoryWorkspace } =
await createRegistryBackedScheduleWorkspaceDeps(tempDir);
const service = createScheduleService({
paseoHome: tempDir,
@@ -501,7 +542,7 @@ describe("ScheduleService", () => {
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
});
@@ -531,7 +572,7 @@ describe("ScheduleService", () => {
test("archiveOnFinish=false local runs create one active workspace per run", async () => {
const {
workspaceRegistry,
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
createArchiveWorkspace,
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
const manager = new AgentManager({
@@ -545,7 +586,7 @@ describe("ScheduleService", () => {
agentManager: manager,
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
archiveWorkspace: createArchiveWorkspace({
agentManager: manager,
agentStorage,
@@ -599,7 +640,7 @@ describe("ScheduleService", () => {
test("archiveOnFinish=true archives the run workspace through workspace archive", async () => {
const {
workspaceRegistry,
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
createArchiveWorkspace,
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
const manager = new AgentManager({
@@ -620,7 +661,7 @@ describe("ScheduleService", () => {
agentManager: manager,
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
archiveWorkspace: createArchiveWorkspace({
agentManager: manager,
agentStorage,
@@ -663,7 +704,7 @@ describe("ScheduleService", () => {
test("archives the run workspace when scheduled agent creation fails before archive opt-out can preserve an agent", async () => {
const {
workspaceRegistry,
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
createArchiveWorkspace,
} = await createRegistryBackedScheduleWorkspaceDeps(tempDir);
const manager = new AgentManager({
@@ -678,7 +719,7 @@ describe("ScheduleService", () => {
agentManager: manager,
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
createLocalCheckoutWorkspace: createScheduleLocalWorkspace,
createDirectoryWorkspace: createScheduleDirectoryWorkspace,
archiveWorkspace: createArchiveWorkspace({
agentManager: manager,
agentStorage,
@@ -1904,7 +1945,7 @@ describe("ScheduleService", () => {
],
}));
const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = [];
const archiveCalls: string[] = [];
now = new Date("2026-01-01T00:10:00.000Z");
const service2 = createScheduleService({
paseoHome: tempDir,
@@ -1914,13 +1955,13 @@ describe("ScheduleService", () => {
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
archiveWorkspace: async (archivedWorkspaceId) => {
archiveCalls.push(archivedWorkspaceId);
},
});
await service2.start();
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
expect(archiveCalls).toEqual([workspaceId]);
const inspected = await service2.inspect(created.id);
expect(inspected.runs[0]).toMatchObject({
status: "failed",
@@ -1972,7 +2013,7 @@ describe("ScheduleService", () => {
],
}));
const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = [];
const archiveCalls: string[] = [];
now = new Date("2026-01-01T00:10:00.000Z");
const service2 = createScheduleService({
paseoHome: tempDir,
@@ -1982,13 +2023,13 @@ describe("ScheduleService", () => {
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
archiveWorkspace: async (archivedWorkspaceId) => {
archiveCalls.push(archivedWorkspaceId);
},
});
await service2.start();
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
expect(archiveCalls).toEqual([workspaceId]);
const inspected = await service2.inspect(created.id);
expect(inspected.runs[0]).toMatchObject({
status: "failed",
+21 -10
View File
@@ -204,7 +204,9 @@ type ScheduleAgentManager = Pick<
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
| "runAgent"
| "touchAgentActivity"
| "waitForAgentEvent"
| "waitForAgentClose"
>;
interface ScheduleWorkspaceCreateInput {
@@ -218,13 +220,13 @@ export interface ScheduleServiceOptions {
agentManager: ScheduleAgentManager;
agentStorage: AgentStorage;
createAgent: BoundCreateAgentCommand;
createLocalCheckoutWorkspace: (
createDirectoryWorkspace: (
input: ScheduleWorkspaceCreateInput,
) => Promise<PersistedWorkspaceRecord>;
createPaseoWorktreeWorkspace: (
input: ScheduleWorkspaceCreateInput,
) => Promise<CreatePaseoWorktreeWorkflowResult>;
archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
archiveWorkspace: (workspaceId: string) => Promise<void>;
now?: () => Date;
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
}
@@ -235,13 +237,13 @@ export class ScheduleService {
private readonly agentManager: ScheduleAgentManager;
private readonly agentStorage: AgentStorage;
private readonly createAgent: BoundCreateAgentCommand;
private readonly createLocalCheckoutWorkspace: (
private readonly createDirectoryWorkspace: (
input: ScheduleWorkspaceCreateInput,
) => Promise<PersistedWorkspaceRecord>;
private readonly createPaseoWorktreeWorkspace: (
input: ScheduleWorkspaceCreateInput,
) => Promise<CreatePaseoWorktreeWorkflowResult>;
private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
private readonly archiveWorkspace: (workspaceId: string) => Promise<void>;
private readonly now: () => Date;
private readonly runner: (
schedule: StoredSchedule,
@@ -256,7 +258,7 @@ export class ScheduleService {
this.agentManager = options.agentManager;
this.agentStorage = options.agentStorage;
this.createAgent = options.createAgent;
this.createLocalCheckoutWorkspace = options.createLocalCheckoutWorkspace;
this.createDirectoryWorkspace = options.createDirectoryWorkspace;
this.createPaseoWorktreeWorkspace = options.createPaseoWorktreeWorkspace;
this.archiveWorkspace = options.archiveWorkspace;
this.now = options.now ?? (() => new Date());
@@ -368,6 +370,17 @@ export class ScheduleService {
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> {
const schedule = await this.store.get(id);
if (!schedule) {
@@ -579,7 +592,6 @@ export class ScheduleService {
private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise<void> {
const interruptedWorkspaces: Array<{
workspaceId: string;
repoRoot: string;
agentId: string | null;
runId: string;
}> = [];
@@ -601,7 +613,6 @@ export class ScheduleService {
) {
interruptedWorkspaces.push({
workspaceId: runningRun.workspaceId,
repoRoot: updated.target.config.cwd,
agentId: runningRun.agentId,
runId: runningRun.id,
});
@@ -639,7 +650,7 @@ export class ScheduleService {
return;
}
try {
await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot);
await this.archiveWorkspace(interruptedWorkspace.workspaceId);
} catch (error) {
this.logger.warn(
{
@@ -930,7 +941,7 @@ export class ScheduleService {
shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish })
) {
try {
await this.archiveWorkspace(workspace.workspaceId, config.cwd);
await this.archiveWorkspace(workspace.workspaceId);
} catch (error) {
this.logger.warn(
{
@@ -954,7 +965,7 @@ export class ScheduleService {
const firstAgentContext = { prompt };
switch (config.isolation ?? "local") {
case "local":
return this.createLocalCheckoutWorkspace({ cwd: config.cwd, firstAgentContext });
return this.createDirectoryWorkspace({ cwd: config.cwd, firstAgentContext });
case "worktree":
return (await this.createPaseoWorktreeWorkspace({ cwd: config.cwd, firstAgentContext }))
.workspace;
@@ -0,0 +1,287 @@
import { afterEach, beforeEach, expect, test } from "vitest";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { DaemonClient } from "./test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js";
interface MessageWaiter {
predicate(message: SessionOutboundMessage): boolean;
resolve(message: SessionOutboundMessage): void;
reject(error: Error): void;
timeout: ReturnType<typeof setTimeout>;
}
class ConnectedClient {
readonly messages: SessionOutboundMessage[] = [];
private readonly waiters: MessageWaiter[] = [];
private readonly unsubscribe: () => void;
constructor(readonly client: DaemonClient) {
this.unsubscribe = client.subscribeRawMessages((message) => {
this.messages.push(message);
for (let waiterIndex = this.waiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) {
const waiter = this.waiters[waiterIndex];
if (!waiter.predicate(message)) continue;
clearTimeout(waiter.timeout);
this.waiters.splice(waiterIndex, 1);
waiter.resolve(message);
}
});
}
clear(): void {
this.messages.length = 0;
}
next(
predicate: (message: SessionOutboundMessage) => boolean,
description: string,
): Promise<SessionOutboundMessage> {
const existing = this.messages.find(predicate);
if (existing) return Promise.resolve(existing);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve);
if (index >= 0) this.waiters.splice(index, 1);
reject(new Error(`Timed out waiting for ${description}`));
}, 5_000);
this.waiters.push({ predicate, resolve, reject, timeout });
});
}
hasTimeline(agentId: string): boolean {
return this.messages.some(
(message) => message.type === "agent_stream" && message.payload.agentId === agentId,
);
}
async barrier(label: string): Promise<void> {
await this.client.ping({ requestId: `barrier-${label}` });
}
close(): void {
this.unsubscribe();
for (const waiter of this.waiters) {
clearTimeout(waiter.timeout);
waiter.reject(new Error("Client boundary closed"));
}
this.waiters.length = 0;
}
}
function isAgentStream(agentId: string) {
return (message: SessionOutboundMessage): boolean =>
message.type === "agent_stream" && message.payload.agentId === agentId;
}
function isDedicatedAttention(agentId: string) {
return (message: SessionOutboundMessage): boolean =>
message.type === "agent_attention_required" && message.payload.agentId === agentId;
}
function isLegacyAttention(agentId: string) {
return (message: SessionOutboundMessage): boolean =>
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "attention_required";
}
function dedicatedAttentionResult(message: SessionOutboundMessage, timelineLeaked: boolean) {
if (message.type !== "agent_attention_required") {
throw new Error(`Expected agent_attention_required, received ${message.type}`);
}
return {
type: message.type,
shouldNotify: message.payload.shouldNotify,
timelineLeaked,
};
}
function legacyAttentionResult(message: SessionOutboundMessage) {
if (message.type !== "agent_stream" || message.payload.event.type !== "attention_required") {
throw new Error(`Expected legacy attention_required agent_stream, received ${message.type}`);
}
return {
type: message.type,
eventType: message.payload.event.type,
agentId: message.payload.agentId,
};
}
let daemon: TestPaseoDaemon;
const clients: ConnectedClient[] = [];
beforeEach(async () => {
daemon = await createTestPaseoDaemon();
});
afterEach(async () => {
for (const connected of clients) {
connected.close();
await connected.client.close().catch(() => undefined);
}
clients.length = 0;
await daemon.close();
}, 30_000);
async function connect(input: { clientId: string; selective: boolean }): Promise<ConnectedClient> {
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
clientId: input.clientId,
capabilities: { [CLIENT_CAPS.selectiveAgentTimeline]: input.selective },
reconnect: { enabled: false },
});
await client.connect();
const connected = new ConnectedClient(client);
clients.push(connected);
return connected;
}
test("subscription acknowledgements stay on the requesting socket of a retained session", async () => {
const legacy = await connect({ clientId: "shared-client", selective: false });
const capable = await connect({ clientId: "shared-client", selective: true });
legacy.clear();
capable.clear();
await capable.client.setAgentTimelineSubscription(["agent-a"]);
await capable.barrier("targeted-subscription-ack");
expect(
legacy.messages.some((message) => message.type === "agent.timeline.set_subscription.response"),
).toBe(false);
});
test("real WebSocket sessions enforce selective delivery, retained resets, downgrade, and dedicated attention", async () => {
const legacy = await connect({ clientId: "legacy-client", selective: false });
let capable = await connect({ clientId: "capable-client", selective: true });
const agents = await Promise.all(
["A", "B", "C"].map((title) =>
legacy.client.createAgent({
provider: "codex",
cwd: "/tmp",
title: `Selective ${title}`,
modeId: "full-access",
}),
),
);
const [agentA, agentB, agentC] = agents;
legacy.clear();
capable.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, {
type: "assistant_message",
text: "before membership",
});
await legacy.next(isAgentStream(agentC.id), "legacy global delivery before membership");
await capable.barrier("before-membership");
expect(capable.hasTimeline(agentC.id)).toBe(false);
await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]);
legacy.clear();
capable.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
type: "assistant_message",
text: "viewed A",
});
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
type: "assistant_message",
text: "viewed B",
});
await daemon.daemon.agentManager.emitLiveTimelineItem(agentC.id, {
type: "assistant_message",
text: "unviewed C",
});
await Promise.all([
capable.next(isAgentStream(agentA.id), "capable A delivery"),
capable.next(isAgentStream(agentB.id), "capable B delivery"),
legacy.next(isAgentStream(agentC.id), "legacy C delivery"),
]);
await capable.barrier("unviewed-c");
expect(capable.hasTimeline(agentC.id)).toBe(false);
await capable.client.setAgentTimelineSubscription([agentB.id]);
legacy.clear();
capable.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
type: "assistant_message",
text: "removed A",
});
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
type: "assistant_message",
text: "retained B",
});
await Promise.all([
legacy.next(isAgentStream(agentA.id), "legacy removed A delivery"),
capable.next(isAgentStream(agentB.id), "capable retained B delivery"),
]);
await capable.barrier("removed-a");
expect(capable.hasTimeline(agentA.id)).toBe(false);
capable.close();
await capable.client.close();
clients.splice(clients.indexOf(capable), 1);
capable = await connect({ clientId: "capable-client", selective: true });
legacy.clear();
capable.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentB.id, {
type: "assistant_message",
text: "after capable resume",
});
await legacy.next(isAgentStream(agentB.id), "legacy delivery after capable resume");
await capable.barrier("resumed-membership-reset");
expect(capable.hasTimeline(agentB.id)).toBe(false);
capable.client.sendHeartbeat({
deviceType: "mobile",
focusedAgentId: null,
lastActivityAt: new Date().toISOString(),
appVisible: true,
});
legacy.clear();
capable.clear();
const attention = capable.next(
isDedicatedAttention(agentC.id),
"capable dedicated attention notification",
);
const legacyAttention = legacy.next(
isLegacyAttention(agentC.id),
"legacy attention stream notification",
);
await legacy.client.sendMessage(agentC.id, "finish attention boundary test");
const [attentionMessage, legacyAttentionMessage] = await Promise.all([
attention,
legacyAttention,
]);
await capable.barrier("attention-delivery");
expect({
capable: dedicatedAttentionResult(attentionMessage, capable.hasTimeline(agentC.id)),
legacy: legacyAttentionResult(legacyAttentionMessage),
}).toEqual({
capable: {
type: "agent_attention_required",
shouldNotify: true,
timelineLeaked: false,
},
legacy: {
type: "agent_stream",
eventType: "attention_required",
agentId: agentC.id,
},
});
capable.close();
await capable.client.close();
clients.splice(clients.indexOf(capable), 1);
const downgraded = await connect({ clientId: "capable-client", selective: false });
downgraded.clear();
await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, {
type: "assistant_message",
text: "after downgrade",
});
const downgradedDelivery = await downgraded.next(
isAgentStream(agentA.id),
"legacy global delivery after capability downgrade",
);
expect(downgradedDelivery.type).toBe("agent_stream");
}, 30_000);
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
@@ -8,6 +8,7 @@ import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
import type { CreateAgentOptions } from "./test-utils/index.js";
import type { CreateAgentWorktreeTarget } from "./messages.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
let ctx: DaemonTestContext;
const tempRoots: string[] = [];
@@ -42,6 +43,18 @@ function createGitRepo(): string {
return repoDir;
}
function createGitRepoWithNestedDirectory(): string {
const repoDir = createGitRepo();
mkdirSync(path.join(repoDir, "packages", "app"), { recursive: true });
writeFileSync(path.join(repoDir, "packages", "app", ".gitkeep"), "");
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested app"], {
cwd: repoDir,
stdio: "pipe",
});
return repoDir;
}
async function expectAgentAbsentFromActiveList(agentId: string): Promise<void> {
await expect
.poll(
@@ -84,8 +97,9 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
async function createAgentInBranchOffWorktree(options?: {
autoArchive?: boolean;
branchName?: string;
repoDir?: string;
}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> {
const repoDir = createGitRepo();
const repoDir = options?.repoDir ?? createGitRepo();
const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`;
const created = await ctx.client.createAgent({
config: {
@@ -142,6 +156,109 @@ test("create_agent_request creates a worktree and auto-archives both after the f
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
}, 30000);
test("create_agent_request auto-archives a nested workspace from an existing Paseo worktree", async () => {
const repoDir = createGitRepoWithNestedDirectory();
const source = await createAgentInBranchOffWorktree({ branchName: "nested-source", repoDir });
await ctx.client.waitForFinish(source.agentId, 10000);
const nestedCwd = path.join(source.worktreePath, "packages", "app");
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: nestedCwd,
},
worktree: {
mode: "branch-off",
newBranch: "nested-auto-archive",
base: "main",
},
autoArchive: true,
initialPrompt: "Say done.",
});
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: created.cwd,
stdio: "pipe",
})
.toString()
.trim();
expect(
createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(created.cwd),
).toBe(true);
await ctx.client.waitForFinish(created.id, 10000);
await expectAgentAbsentFromActiveList(created.id);
await expect
.poll(
async () => {
const workspaces = await ctx.client.fetchWorkspaces();
const matchesCreatedWorkspace = createRealpathAwarePathMatcher(created.cwd);
return workspaces.entries.some((workspace) =>
matchesCreatedWorkspace(workspace.workspaceDirectory),
);
},
{ timeout: 10000, interval: 100 },
)
.toBe(false);
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
expect(existsSync(source.worktreePath)).toBe(true);
await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath });
}, 30000);
test("failed nested worktree creation cleans up the created workspace and backing directory", async () => {
const repoDir = createGitRepoWithNestedDirectory();
const source = await createAgentInBranchOffWorktree({
branchName: "nested-failure-source",
repoDir,
});
await ctx.client.waitForFinish(source.agentId, 10000);
const nestedCwd = path.join(source.worktreePath, "packages", "app");
await expect(
ctx.client.createAgent({
config: { provider: "unknown-provider", cwd: nestedCwd },
worktree: {
mode: "branch-off",
newBranch: "nested-failure-cleanup",
base: "main",
},
initialPrompt: "This agent cannot be created.",
}),
).rejects.toThrow();
await expect
.poll(
async () => {
const listed = await ctx.client.getPaseoWorktreeList({ cwd: source.repoDir });
return (
listed.worktrees.length === 1 &&
createRealpathAwarePathMatcher(source.worktreePath)(
listed.worktrees[0]?.worktreePath ?? "",
)
);
},
{ timeout: 10000, interval: 100 },
)
.toBe(true);
await expect
.poll(
async () => {
const workspaces = await ctx.client.fetchWorkspaces();
return (
workspaces.entries.length === 1 &&
createRealpathAwarePathMatcher(source.worktreePath)(
workspaces.entries[0]?.workspaceDirectory ?? "",
)
);
},
{ timeout: 10000, interval: 100 },
)
.toBe(true);
await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath });
}, 30000);
test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => {
const repoDir = createGitRepo();
const created = await ctx.client.createAgent({
+394 -31
View File
@@ -10,6 +10,7 @@ import {
assertPullRequestAutoMergeEnableReady,
} from "../services/github-service.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
decodeFileTransferFrame,
@@ -17,11 +18,13 @@ import {
FileTransferOpcode,
type FileTransferFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { Session } from "./session.js";
import { isSessionRpcAllowed, Session } from "./session.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentManagerEvent } from "./agent/agent-manager.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { createPersistedProjectRecord } from "./workspace-registry.js";
import type { SessionOptions } from "./session.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js";
import {
@@ -275,6 +278,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
});
interface SessionForTestOptions {
scopes?: readonly string[];
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
github?: Partial<ForgeService & GitHubService>;
@@ -292,6 +296,7 @@ interface SessionForTestOptions {
resolveRepoRoot?: ReturnType<typeof vi.fn>;
resolveForge?: ReturnType<typeof vi.fn>;
getWorkspaceGitMetadata?: ReturnType<typeof vi.fn>;
getProjectSlug?: ReturnType<typeof vi.fn>;
};
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
@@ -309,6 +314,7 @@ interface SessionForTestOptions {
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
downloadTokenStore?: SessionOptions["downloadTokenStore"];
messages?: unknown[];
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
binaryMessages?: Uint8Array[];
}
@@ -339,13 +345,20 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
// Mirror production: invalidateForge resolves the forge and busts the
// adapter's cache. The resolved forge here is github, so delegate to it.
invalidateForge: vi.fn((cwd: string) => github.invalidate({ cwd })),
getProjectSlug: vi.fn(),
...options.workspaceGitService,
};
const messages = options.messages ?? [];
return new Session({
const sessionOptions: SessionOptions = {
clientId: "test-client",
onMessage: (message) => messages.push(message),
...(options.targetedMessages
? {
onMessageToSource: (source: object, message: SessionOutboundMessage) =>
options.targetedMessages?.push({ source, message }),
}
: {}),
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
logger,
downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(),
@@ -357,17 +370,20 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
...options.agentManager,
}),
agentStorage: asAgentStorage({
get: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([]),
...options.agentStorage,
}),
projectRegistry: options.projectRegistry ?? {
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
get: vi.fn(),
getOrCreateActiveByRoot: vi.fn(),
upsert: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
initialize: vi.fn(),
existsOnDisk: vi.fn(),
...options.projectRegistry,
},
workspaceRegistry: options.workspaceRegistry ?? {
get: vi.fn(),
@@ -399,9 +415,85 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
serverId: options.serverId,
daemonVersion: options.daemonVersion,
daemonRuntimeConfig: options.daemonRuntimeConfig,
});
scopes: options.scopes ?? ["*"],
};
return new Session(sessionOptions);
}
describe("session authorization scopes", () => {
test("rejects an RPC outside an exact grant with the generic RPC error", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
scopes: ["hub.execution.agent.create.request"],
messages,
});
await session.handleMessage({ type: "ping", requestId: "restricted-ping", clientSentAt: 42 });
expect(messages).toEqual([
{
type: "rpc_error",
payload: {
requestId: "restricted-ping",
requestType: "ping",
error: "Session is not authorized for ping",
code: "access_denied",
},
},
]);
});
test.each([
["*", "ping"],
["hub.execution.*", "hub.execution.agent.create.request"],
["hub.execution.agent.create.request", "hub.execution.agent.create.request"],
])("scope %s authorizes %s", (scope, requestType) => {
expect(isSessionRpcAllowed([scope], requestType)).toBe(true);
});
test.each([
["hub.execution.*", "hub.management.daemon.get_status.request"],
["hub.execution.agent.create.request", "hub.execution.agent.update"],
["hub.execution.*", "hub.executions.agent.create.request"],
])("scope %s rejects %s", (scope, requestType) => {
expect(isSessionRpcAllowed([scope], requestType)).toBe(false);
});
test("replaces a session's scopes without reconstructing the session", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ scopes: ["hub.execution.*"], messages });
await session.handleMessage({
type: "ping",
requestId: "before-scope-change",
clientSentAt: 1,
});
session.setScopes(["*"]);
await session.handleMessage({ type: "ping", requestId: "after-scope-change", clientSentAt: 2 });
expect(messages).toEqual([
{
type: "rpc_error",
payload: {
requestId: "before-scope-change",
requestType: "ping",
error: "Session is not authorized for ping",
code: "access_denied",
},
},
{
type: "pong",
payload: {
requestId: "after-scope-change",
clientSentAt: 2,
serverReceivedAt: expect.any(Number),
serverSentAt: expect.any(Number),
},
},
]);
});
});
describe("project command-center RPCs", () => {
test("returns normalized repositories from the host GitHub service", async () => {
const messages: SessionOutboundMessage[] = [];
@@ -516,12 +608,20 @@ describe("project command-center RPCs", () => {
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
const directoryPath = join(parentDirectory, "new-project");
const messages: SessionOutboundMessage[] = [];
const projectUpsert = vi.fn().mockResolvedValue(undefined);
const projectAllocation = vi.fn(async (input) =>
createPersistedProjectRecord({
projectId: "prj_created_directory",
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
}),
);
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
upsert: projectUpsert,
getOrCreateActiveByRoot: projectAllocation,
},
workspaceGitService: {
getCheckout: vi.fn(async (cwd: string) => ({
@@ -545,7 +645,12 @@ describe("project command-center RPCs", () => {
});
expect(existsSync(directoryPath)).toBe(true);
expect(projectUpsert).toHaveBeenCalledOnce();
expect(projectAllocation).toHaveBeenCalledWith({
rootPath: directoryPath,
kind: "non_git",
displayName: "new-project",
timestamp: expect.any(String),
});
expect(messages).toEqual([
{
type: "project.create_directory.response",
@@ -553,7 +658,7 @@ describe("project command-center RPCs", () => {
requestId: "req-create-directory",
directoryPath,
project: {
projectId: directoryPath,
projectId: "prj_created_directory",
projectDisplayName: "new-project",
projectCustomName: null,
projectRootPath: directoryPath,
@@ -576,8 +681,7 @@ describe("project command-center RPCs", () => {
const session = createSessionForTest({
messages,
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")),
getOrCreateActiveByRoot: vi.fn().mockRejectedValue(new Error("registry unavailable")),
},
workspaceGitService: {
getCheckout: vi.fn(async (cwd: string) => ({
@@ -4032,17 +4136,17 @@ describe("session paseo worktree creation handling", () => {
});
describe("session workspace script handling", () => {
test("passes service-owned git metadata into workspace script spawning", async () => {
test("passes the project slug and cached branch into workspace script spawning", async () => {
const messages: unknown[] = [];
const workspaceGitService = {
peekSnapshot: vi.fn(() => null),
getWorkspaceGitMetadata: vi.fn().mockResolvedValue({
projectKind: "git",
projectDisplayName: "getpaseo/paseo",
workspaceDisplayName: "feature/service-scripts",
projectSlug: "paseo",
const snapshot = createWorkspaceGitSnapshot("/tmp/repo", {
git: {
currentBranch: "feature/service-scripts",
}),
remoteUrl: "https://github.com/getpaseo/paseo.git",
},
});
const workspaceGitService = {
peekSnapshot: vi.fn(() => snapshot),
getProjectSlug: vi.fn().mockResolvedValue("paseo"),
};
const workspaceRegistry = {
get: vi.fn().mockResolvedValue({
@@ -4075,8 +4179,6 @@ describe("session workspace script handling", () => {
requestId: "request-script",
});
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledTimes(1);
expect(workspaceGitService.getWorkspaceGitMetadata).toHaveBeenCalledWith("/tmp/repo");
expect(spawnMocks.spawnWorkspaceScript).toHaveBeenCalledWith(
expect.objectContaining({
repoRoot: "/tmp/repo",
@@ -4554,13 +4656,266 @@ describe("chat/schedule/loop dispatch routing (behavior preservation)", () => {
});
});
test("replaces a capable session's complete viewed timeline set", async () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ messages });
session.updateClientCapabilities({ selective_agent_timeline: true });
await session.handleMessage({
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-b", "agent-a", "agent-a"],
requestId: "timeline-subscription-1",
});
expect(messages).toEqual([
{
type: "agent.timeline.set_subscription.response",
payload: {
agentIds: ["agent-a", "agent-b"],
requestId: "timeline-subscription-1",
},
},
]);
});
test("acknowledges a timeline subscription only to its socket source", async () => {
const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const session = createSessionForTest({ messages, targetedMessages });
const capableSocket = {};
session.updateClientCapabilities({ selective_agent_timeline: true }, capableSocket);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-a"],
requestId: "timeline-subscription-targeted",
},
capableSocket,
);
expect(messages).toEqual([]);
expect(targetedMessages).toEqual([
{
source: capableSocket,
message: {
type: "agent.timeline.set_subscription.response",
payload: {
agentIds: ["agent-a"],
requestId: "timeline-subscription-targeted",
},
},
},
]);
});
test("unions viewed timelines across socket sources and removes detached sources", async () => {
const messages: SessionOutboundMessage[] = [];
const agentEventListeners: Array<(event: AgentManagerEvent) => void> = [];
const session = createSessionForTest({
messages,
agentManager: {
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
agentEventListeners.push(listener);
return () => {};
}),
},
});
session.updateClientCapabilities({ selective_agent_timeline: true });
const firstSocket = {};
const secondSocket = {};
session.updateClientCapabilities({ selective_agent_timeline: true }, firstSocket);
session.updateClientCapabilities({ selective_agent_timeline: true }, secondSocket);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-a"],
requestId: "timeline-subscription-a",
},
firstSocket,
);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["agent-b"],
requestId: "timeline-subscription-b",
},
secondSocket,
);
messages.length = 0;
if (agentEventListeners.length === 0) throw new Error("Agent event listener was not installed");
const forward = (event: AgentManagerEvent) => {
for (const listener of agentEventListeners) listener(event);
};
forward({
type: "agent_stream",
agentId: "agent-a",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-a", text: "A" },
},
});
forward({
type: "agent_stream",
agentId: "agent-b",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-b", text: "B" },
},
});
expect(messages.filter((message) => message.type === "agent_stream")).toHaveLength(2);
const legacySocket = {};
session.updateClientCapabilities(null, legacySocket);
expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, legacySocket)).toBe(false);
expect(session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, firstSocket)).toBe(true);
messages.length = 0;
forward({
type: "agent_stream",
agentId: "agent-not-viewed",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-legacy", text: "legacy" },
},
});
expect(messages.some((message) => message.type === "agent_stream")).toBe(true);
session.clearAgentTimelineSubscription(legacySocket);
session.clearAgentTimelineSubscription(firstSocket);
messages.length = 0;
forward({
type: "agent_stream",
agentId: "agent-a",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-a-2", text: "detached A" },
},
});
forward({
type: "agent_stream",
agentId: "agent-b",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-b-2", text: "retained B" },
},
});
expect(
messages.flatMap((message) =>
message.type === "agent_stream" ? [message.payload.agentId] : [],
),
).toEqual(["agent-b"]);
});
test("keeps selective delivery scoped per socket when a retained session also has a legacy socket", async () => {
const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const agentEventListeners: Array<(event: AgentManagerEvent) => void> = [];
const session = createSessionForTest({
messages,
targetedMessages,
agentManager: {
subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => {
agentEventListeners.push(listener);
return () => {};
}),
},
});
const legacySocket = {};
const selectiveSocket = {};
session.updateClientCapabilities(null, legacySocket);
session.updateClientCapabilities({ selective_agent_timeline: true }, selectiveSocket);
await session.handleMessage(
{
type: "agent.timeline.set_subscription.request",
agentIds: ["viewed-agent"],
requestId: "timeline-subscription-selective",
},
selectiveSocket,
);
targetedMessages.length = 0;
const listener = agentEventListeners[0];
if (!listener) throw new Error("Agent event listener was not installed");
listener({
type: "agent_stream",
agentId: "not-viewed-agent",
event: {
type: "timeline",
provider: "mock",
item: { type: "assistant_message", messageId: "message-global", text: "global" },
},
});
expect(messages).toEqual([]);
expect(targetedMessages).toEqual([
{
source: legacySocket,
message: expect.objectContaining({
type: "agent_stream",
payload: expect.objectContaining({ agentId: "not-viewed-agent" }),
}),
},
]);
});
test("sends project updates only to capable sockets in a retained session", () => {
const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const session = createSessionForTest({ messages, targetedMessages });
const legacySocket = {};
const capableSocket = {};
session.updateClientCapabilities(null, legacySocket);
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket);
session.emitProjectUpdate({
kind: "upsert",
project: createPersistedProjectRecord({
projectId: "project-capable-socket",
rootPath: "/tmp/project-capable-socket",
kind: "git",
displayName: "project-capable-socket",
createdAt: "2026-07-17T00:00:00.000Z",
updatedAt: "2026-07-17T00:00:00.000Z",
}),
});
expect(messages).toEqual([]);
expect(targetedMessages).toEqual([
{
source: capableSocket,
message: expect.objectContaining({
type: "project.update",
payload: expect.objectContaining({ kind: "upsert" }),
}),
},
]);
});
describe("agent config setters", () => {
function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): {
[K in keyof SessionOptions["agentManager"]]?: unknown;
} {
return {
waitForAgentClose: vi.fn().mockResolvedValue(undefined),
touchAgentActivity: vi.fn(() => ({ id: "agent-1" })),
...overrides,
};
}
test("set_agent_mode_request: success emits accepted response carrying the notice", async () => {
const messages: SessionOutboundMessage[] = [];
const notice = { type: "info", message: "Switched to plan mode" } as const;
const session = createSessionForTest({
messages,
agentManager: { setAgentMode: vi.fn().mockResolvedValue(notice) },
agentManager: liveAgentManager({ setAgentMode: vi.fn().mockResolvedValue(notice) }),
});
await session.handleMessage({
@@ -4588,7 +4943,9 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentMode: vi.fn().mockRejectedValue(new Error("mode boom")) },
agentManager: liveAgentManager({
setAgentMode: vi.fn().mockRejectedValue(new Error("mode boom")),
}),
});
await session.handleMessage({
@@ -4623,7 +4980,7 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentModel: vi.fn().mockResolvedValue(undefined) },
agentManager: liveAgentManager({ setAgentModel: vi.fn().mockResolvedValue(undefined) }),
});
await session.handleMessage({
@@ -4645,7 +5002,9 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentModel: vi.fn().mockRejectedValue(new Error("model boom")) },
agentManager: liveAgentManager({
setAgentModel: vi.fn().mockRejectedValue(new Error("model boom")),
}),
});
await session.handleMessage({
@@ -4680,7 +5039,7 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentFeature: vi.fn().mockResolvedValue(undefined) },
agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }),
});
await session.handleMessage({
@@ -4703,7 +5062,9 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: { setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")) },
agentManager: liveAgentManager({
setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")),
}),
});
await session.handleMessage({
@@ -4740,7 +5101,9 @@ describe("agent config setters", () => {
const notice = { type: "warning", message: "Thinking budget reduced" } as const;
const session = createSessionForTest({
messages,
agentManager: { setAgentThinkingOption: vi.fn().mockResolvedValue(notice) },
agentManager: liveAgentManager({
setAgentThinkingOption: vi.fn().mockResolvedValue(notice),
}),
});
await session.handleMessage({
@@ -4768,9 +5131,9 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: {
agentManager: liveAgentManager({
setAgentThinkingOption: vi.fn().mockRejectedValue(new Error("thinking boom")),
},
}),
});
await session.handleMessage({
File diff suppressed because it is too large Load Diff
@@ -182,6 +182,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
const session = new Session({
clientId: "test-client",
scopes: ["*"],
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
logger: createStub<pino.Logger>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
@@ -472,7 +473,6 @@ describe("workspace git watch targets", () => {
onBranchChanged: handleBranchChange,
},
);
const sessionAny = session as unknown as SessionInternals;
seedGitWorkspace({
projects,
workspaces,
@@ -482,7 +482,7 @@ describe("workspace git watch targets", () => {
name: "old-branch",
});
syncGitObserver(session, "/tmp/repo", "ws-10");
await session.syncWorkspaceGitObserversForExternalWorkspaceIds(["ws-10"]);
subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", {
@@ -498,16 +498,6 @@ describe("workspace git watch targets", () => {
scriptName: "app",
}),
]);
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
expect.objectContaining({
scriptName: "app",
hostname: "app--new-branch--paseo.localhost",
localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
publicProxyUrl: null,
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
}),
]);
await session.cleanup();
});
@@ -88,6 +88,7 @@ function createHarness(input: {
const session = new Session({
clientId: "test",
scopes: ["*"],
appVersion: null,
onMessage: (m) => emitted.push(m),
logger: createStub<SessionOptions["logger"]>(logger),
@@ -112,6 +113,22 @@ function createHarness(input: {
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: string) => projects.get(id) ?? null,
getOrCreateActiveByRoot: async (allocation) => {
const existing = Array.from(projects.values()).find(
(project) => !project.archivedAt && project.rootPath === allocation.rootPath,
);
if (existing) return existing;
const project = createPersistedProjectRecord({
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
rootPath: allocation.rootPath,
kind: allocation.kind,
displayName: allocation.displayName,
createdAt: allocation.timestamp,
updatedAt: allocation.timestamp,
});
projects.set(project.projectId, project);
return project;
},
upsert: async (record: PersistedProjectRecord) => {
projects.set(record.projectId, record);
},
@@ -310,10 +327,9 @@ test("S3: re-open active workspace by exact path returns the same record", async
});
// ─────────────────────────────────────────────────────────────────────────────
// S4. Open a subdir of an active git workspace: canonicalizes UP to the repo
// root, returns the existing workspace. (Per "always go to the nearest git".)
// S4. Every selected path is an exact lexical root, even inside a Git checkout.
// ─────────────────────────────────────────────────────────────────────────────
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
test("S4: open subdir of active git workspace creates an independent exact-root workspace", async () => {
const h = createHarness({
workspaces: [gitWorkspace(FOO)],
projects: [gitProject(FOO)],
@@ -321,8 +337,9 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
});
await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
expect(h.workspaces.size).toBe(1);
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
expect(resp?.workspace?.projectId).not.toBe(workspaceByCwd(h.workspaces, FOO)?.projectId);
expect(h.workspaces.size).toBe(2);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -343,10 +360,10 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
});
// ─────────────────────────────────────────────────────────────────────────────
// S6. Open the EXACT path of an archived git workspace: this IS explicit user
// intent to re-open what they archived. Unarchive is correct here.
// S6. Explicit project opening allocates a fresh identity when only archived
// records exist. Agent restore is the separate path that restores ownership.
// ─────────────────────────────────────────────────────────────────────────────
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
test("S6: re-opening an archived git workspace by exact path creates a fresh project and workspace", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
@@ -354,8 +371,12 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
gitRoots: [TOOLBOX],
});
await openProject(h.session, TOOLBOX);
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
const fresh = Array.from(h.workspaces.values()).find(
(workspace) => workspace.cwd === TOOLBOX && !workspace.archivedAt,
);
expect(fresh?.workspaceId).not.toBe("ws-toolbox");
expect(fresh?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -433,11 +454,10 @@ test("S10: opening a git repo nested inside an archived non-git directory create
});
// ─────────────────────────────────────────────────────────────────────────────
// S11. Archive then re-add round-trip (project-level): opening the exact path
// of an archived project unarchives both the project and its workspace,
// reusing the same path-derived ids.
// S11. Archive then re-add produces a fresh opaque identity; it never reuses
// archived compatibility IDs.
// ─────────────────────────────────────────────────────────────────────────────
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
test("S11: re-opening an archived project by exact path keeps archived records and allocates fresh ids", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
@@ -447,12 +467,11 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
await openProject(h.session, TOOLBOX);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId);
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
expect(h.workspaces.size).toBe(1);
expect(h.projects.size).toBe(1);
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
expect(resp?.workspace?.id).not.toBe("ws-toolbox");
expect(resp?.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
expect(h.workspaces.size).toBe(2);
expect(h.projects.size).toBe(2);
expect(h.projects.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -482,7 +501,7 @@ test("S12: resolveWorkspaceIdForPath does not return archived ancestor via prefi
// being explicit. To get git features back the user unarchives the parent
// (S6/S11).
// ─────────────────────────────────────────────────────────────────────────────
test("S13: subfolder of an archived git repo opens as a directory workspace", async () => {
test("S13: subfolder of an archived git repo opens as its own git-backed workspace", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace(TOOLBOX, archivedAt)],
@@ -492,5 +511,5 @@ test("S13: subfolder of an archived git repo opens as a directory workspace", as
await openProject(h.session, TOOLBOX_FLOMO);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceKind).toBe("directory");
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
});
File diff suppressed because it is too large Load Diff
@@ -9,14 +9,21 @@ import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../../messages.js";
class FakeAgentConfigOperations implements AgentConfigOperations {
readonly loadedAgentIds: string[] = [];
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
modeNotice: AgentProviderNotice | null = null;
thinkingNotice: AgentProviderNotice | null = null;
loadFailure: Error | null = null;
failWith: Error | null = null;
async ensureLoaded(agentId: string): Promise<void> {
this.loadedAgentIds.push(agentId);
if (this.loadFailure) throw this.loadFailure;
}
async setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
this.modeCalls.push({ agentId, modeId });
if (this.failWith) throw this.failWith;
@@ -68,6 +75,7 @@ describe("AgentConfigSession", () => {
});
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
expect(emitted).toEqual([
{
type: "set_agent_mode_response",
@@ -109,6 +117,43 @@ describe("AgentConfigSession", () => {
});
});
test("set mode: a failed load rejects without mutating the collected agent", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.loadFailure = new Error("agent is archived");
await subsystem.handleSetAgentModeRequest({
type: "set_agent_mode_request",
agentId: "agent-1",
modeId: "plan",
requestId: "req-1",
});
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
expect(operations.modeCalls).toEqual([]);
expect(emitted.map((message) => message.type)).toEqual([
"activity_log",
"set_agent_mode_response",
]);
expect(emitted[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to set agent mode: agent is archived",
},
});
expect(emitted[1]).toEqual({
type: "set_agent_mode_response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: false,
error: "agent is archived",
},
});
});
test("set model: emits an accepted response with no notice", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
@@ -19,11 +19,12 @@ export interface AgentConfigSessionHost {
/**
* The per-agent config mutations this subsystem drives. The shell adapts these
* onto the live AgentManager (mode still routes through setAgentModeCommand);
* tests wire an in-memory fake. Mode and thinking yield a provider notice; model
* and feature do not.
* onto the AgentManager and loads a collected agent before mutation (mode still
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
* thinking yield a provider notice; model and feature do not.
*/
export interface AgentConfigOperations {
ensureLoaded(agentId: string): Promise<void>;
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
setModel(agentId: string, modelId: string | null): Promise<void>;
setFeature(agentId: string, featureId: string, value: unknown): Promise<void>;
@@ -138,6 +139,7 @@ export class AgentConfigSession {
this.logger.info(logFields, `session: ${logLabel}`);
try {
await this.operations.ensureLoaded(agentId);
const notice = await run();
this.logger.info(logFields, `session: ${logLabel} success`);
emitResponse({ requestId, agentId, accepted: true, error: null, notice });
@@ -1220,7 +1220,8 @@ export class CheckoutSession {
try {
// The payload schema keeps checkRunId and workflowRunId optional (a Gitea
// Actions run has no check-run id; GitLab routes by changeRequestNumber),
// Actions run has no check-run id; forge adapters may also route by
// changeRequestNumber),
// but a request that addresses no check at all is not actionable — reject
// it here with a clear message instead of failing deep in an adapter. The
// schema itself cannot enforce this: it is a discriminated-union member, so
@@ -10,6 +10,7 @@ import {
} from "./daemon-session.js";
import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./diagnostics.js";
import type { ProviderAvailability } from "../../agent/agent-manager.js";
import type { HubRelationshipManagement } from "../../hub/relationship-controller.js";
import type { SessionOutboundMessage } from "../../messages.js";
const tempDirs: string[] = [];
@@ -40,6 +41,7 @@ function makeSubsystem(overrides: {
daemonRuntimeConfig?: DaemonRuntimeConfig;
listProviderAvailability?: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
hubRelationships?: HubRelationshipManagement;
}) {
const emitted: SessionOutboundMessage[] = [];
const restartIntents: Parameters<DaemonSessionHost["emitLifecycleIntent"]>[0][] = [];
@@ -60,12 +62,67 @@ function makeSubsystem(overrides: {
listWorkspaces: async () => [],
listProviderAvailability: overrides.listProviderAvailability ?? (async () => []),
getWebSocketRuntimeMetrics: overrides.getWebSocketRuntimeMetrics,
hubRelationships: overrides.hubRelationships,
logger: pino({ level: "silent" }),
});
return { subsystem, emitted, paseoHome, restartIntents };
}
describe("DaemonSession", () => {
test("Hub relationship command failures return correlated RPC errors", async () => {
const { subsystem, emitted } = makeSubsystem({
hubRelationships: {
connect: async () => {
throw new Error("Hub rejected enrollment (401)");
},
status: () => ({
state: "not_connected",
daemonId: null,
hubOrigin: null,
scopes: [],
connectedAt: null,
lastError: null,
}),
disconnect: async () => {
throw new Error("Hub revocation failed (503)");
},
},
});
await subsystem.handleHubRelationshipRequest({
type: "hub.management.daemon.connect.request",
requestId: "connect-1",
hubUrl: "https://hub.test",
token: "token",
});
await subsystem.handleHubRelationshipRequest({
type: "hub.management.daemon.disconnect.request",
requestId: "disconnect-1",
force: false,
});
expect(emitted).toEqual([
{
type: "rpc_error",
payload: {
requestId: "connect-1",
requestType: "hub.management.daemon.connect.request",
error: "Hub rejected enrollment (401)",
code: "handler_error",
},
},
{
type: "rpc_error",
payload: {
requestId: "disconnect-1",
requestType: "hub.management.daemon.disconnect.request",
error: "Hub revocation failed (503)",
code: "handler_error",
},
},
]);
});
test("status reports identity, runtime config, and providers with errors normalized to null", async () => {
const { subsystem, emitted } = makeSubsystem({
serverId: "srv-1",
@@ -10,6 +10,7 @@ import {
import { DaemonSelfUpdateSessionController } from "./daemon-self-update-session-controller.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../../workspace-registry.js";
import type { HubRelationshipManagement } from "../../hub/relationship-controller.js";
export interface DaemonRuntimeConfig {
listen: string | null;
@@ -48,6 +49,7 @@ export interface DaemonSessionOptions {
listProviderAvailability: () => Promise<ProviderAvailability[]>;
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
logger: pino.Logger;
hubRelationships?: HubRelationshipManagement;
}
/**
@@ -71,6 +73,7 @@ export class DaemonSession {
private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
private readonly logger: pino.Logger;
private readonly selfUpdate: DaemonSelfUpdateSessionController;
private readonly hubRelationships: HubRelationshipManagement | null;
constructor(options: DaemonSessionOptions) {
this.host = options.host;
@@ -85,6 +88,7 @@ export class DaemonSession {
this.listProviderAvailability = options.listProviderAvailability;
this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null);
this.logger = options.logger;
this.hubRelationships = options.hubRelationships ?? null;
this.selfUpdate = new DaemonSelfUpdateSessionController({
clientId: this.clientId,
daemonVersion: this.daemonVersion ?? null,
@@ -95,6 +99,56 @@ export class DaemonSession {
});
}
async handleHubRelationshipRequest(
msg: Extract<
SessionInboundMessage,
{
type:
| "hub.management.daemon.connect.request"
| "hub.management.daemon.get_status.request"
| "hub.management.daemon.disconnect.request";
}
>,
): Promise<void> {
try {
if (!this.hubRelationships) throw new Error("Hub relationship management is unavailable");
if (msg.type === "hub.management.daemon.connect.request") {
const status = await this.hubRelationships.connect({
hubUrl: msg.hubUrl,
token: msg.token,
});
this.host.emit({
type: "hub.management.daemon.connect.response",
payload: { requestId: msg.requestId, status },
});
return;
}
if (msg.type === "hub.management.daemon.disconnect.request") {
const result = await this.hubRelationships.disconnect({ force: msg.force ?? false });
this.host.emit({
type: "hub.management.daemon.disconnect.response",
payload: { requestId: msg.requestId, ...result },
});
return;
}
this.host.emit({
type: "hub.management.daemon.get_status.response",
payload: { requestId: msg.requestId, status: this.hubRelationships.status() },
});
} catch (error) {
this.logger.error({ err: error }, "Failed to handle Hub relationship request");
this.host.emit({
type: "rpc_error",
payload: {
requestId: msg.requestId,
requestType: msg.type,
error: error instanceof Error ? error.message : String(error),
code: "handler_error",
},
});
}
}
async handleGetStatusRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.get_status.request" }>,
): Promise<void> {
@@ -24,6 +24,7 @@ function makeDescriptor(overrides: {
id: string;
workspaceDirectory: string;
projectKind?: string;
workspaceKind?: "directory" | "local_checkout" | "worktree";
name?: string | null;
diffStat?: { additions: number; deletions: number } | null;
}): WorkspaceDescriptorPayload {
@@ -31,6 +32,7 @@ function makeDescriptor(overrides: {
id: overrides.id,
workspaceDirectory: overrides.workspaceDirectory,
projectKind: overrides.projectKind ?? "git",
workspaceKind: overrides.workspaceKind ?? "local_checkout",
name: overrides.name ?? null,
diffStat: overrides.diffStat ?? null,
} as unknown as WorkspaceDescriptorPayload;
@@ -135,11 +137,24 @@ describe("syncObservers", () => {
test("does not register a non-git workspace", () => {
const h = buildHarness();
h.service.syncObservers([
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
makeDescriptor({
id: "ws1",
workspaceDirectory: WS1,
projectKind: "directory",
workspaceKind: "directory",
}),
]);
expect(h.registerCalls).toEqual([]);
});
test("registers a Git workspace even when its owning project is non-Git", () => {
const h = buildHarness();
h.service.syncObservers([
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "non_git" }),
]);
expect(h.registerCalls).toEqual([WS1]);
});
test("is idempotent — re-syncing the same git workspace does not re-register", () => {
const h = buildHarness();
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1 });
@@ -148,14 +163,46 @@ describe("syncObservers", () => {
expect(h.registerCalls).toEqual([WS1]);
});
test("shares one cwd subscription across distinct workspace identities", () => {
const h = buildHarness();
h.service.syncObservers([
makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }),
makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }),
]);
expect(h.registerCalls).toEqual([WS1]);
});
test("tears down the subscription when a git workspace becomes non-git", () => {
const h = buildHarness();
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
h.service.syncObservers([
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
makeDescriptor({
id: "ws1",
workspaceDirectory: WS1,
projectKind: "directory",
workspaceKind: "directory",
}),
]);
expect(h.unsubscribeCalls).toEqual([WS1]);
});
test("keeps a shared subscription when one sibling becomes non-git", () => {
const h = buildHarness();
h.service.syncObservers([
makeDescriptor({ id: "ws1", workspaceDirectory: WS1 }),
makeDescriptor({ id: "ws2", workspaceDirectory: WS1 }),
]);
h.service.syncObservers([
makeDescriptor({
id: "ws1",
workspaceDirectory: WS1,
workspaceKind: "directory",
}),
]);
expect(h.unsubscribeCalls).toEqual([]);
h.emitSnapshot(WS1, "feature");
expect(h.branchChanges).toEqual([["ws2", null, "feature"]]);
});
});
describe("git snapshot listener", () => {
@@ -202,6 +249,13 @@ describe("shouldSkipUpdate", () => {
expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true);
expect(h.service.shouldSkipUpdate("ws1", b)).toBe(false);
});
test("starts from the descriptor state recorded during observer sync", () => {
const h = buildHarness();
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" });
h.service.syncObservers([descriptor]);
expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(true);
});
});
describe("recordDescriptorState", () => {
@@ -239,10 +293,25 @@ describe("teardown", () => {
expect(h.unsubscribeCalls).toEqual([]);
});
test("removeForCwd unsubscribes and stops the observer", () => {
test("keeps a shared cwd subscription until its last workspace is removed", () => {
const h = buildHarness();
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
h.service.removeForCwd(WS1);
h.service.syncObservers([
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }),
makeDescriptor({ id: "ws2", workspaceDirectory: WS1, name: "main" }),
]);
h.emitSnapshot(WS1, "feature");
h.service.removeForWorkspaceId("ws1");
expect(h.unsubscribeCalls).toEqual([]);
h.emitSnapshot(WS1, "next");
expect(h.branchChanges).toEqual([
["ws1", "main", "feature"],
["ws2", "main", "feature"],
["ws2", "feature", "next"],
]);
h.service.removeForWorkspaceId("ws2");
expect(h.unsubscribeCalls).toEqual([WS1]);
expect(() => h.emitSnapshot(WS1, "x")).toThrow();
});
@@ -10,8 +10,11 @@ import type { PersistedWorkspaceRecord } from "../../workspace-registry.js";
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
interface WorkspaceGitWatchTarget {
workspaceIds: Set<string>;
}
interface WorkspaceGitWatchState {
cwd: string;
workspaceId: string;
latestDescriptorStateKey: string | null;
lastBranchName: string | null;
}
@@ -20,8 +23,9 @@ interface WorkspaceGitWatchTarget {
* Observes a workspace's git state on disk (via WorkspaceGitService) and drives the
* live update fan-out: branch-change notifications, workspace-card refreshes, and
* checkout status updates. It owns the per-cwd watch targets and the WorkspaceGitService
* subscription handles, so the registration / dedupe / teardown lifecycle lives in one
* module instead of being smeared across the client session.
* subscription handles. Filesystem subscriptions are keyed by cwd while descriptor and
* branch state remain keyed by workspace id, so same-directory workspace records share one
* watch without sharing identity or teardown lifetime.
*
* Branch changes reach `onBranchChanged` from two paths that share `lastBranchName`: the
* on-disk snapshot listener (handleBranchSnapshot) and the workspace-emit loop
@@ -37,7 +41,6 @@ export interface WorkspaceGitObserverService {
recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void;
handleBranchSnapshot(cwd: string, branchName: string | null): void;
removeForWorkspaceId(workspaceId: string): void;
removeForCwd(cwd: string): void;
dispose(): void;
}
@@ -67,6 +70,7 @@ export function createWorkspaceGitObserverService(deps: {
} = deps;
const watchTargets = new Map<string, WorkspaceGitWatchTarget>();
const workspaceStates = new Map<string, WorkspaceGitWatchState>();
const subscriptions = new Map<string, () => void>();
function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
@@ -79,71 +83,93 @@ export function createWorkspaceGitObserverService(deps: {
]);
}
function resolveTargetByWorkspaceId(workspaceId: string): WorkspaceGitWatchTarget | null {
for (const target of watchTargets.values()) {
if (target.workspaceId === workspaceId) {
return target;
}
}
return null;
}
function rememberDescriptorState(
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): void {
const target = resolveTargetByWorkspaceId(workspaceId);
if (!target) {
const state = workspaceStates.get(workspaceId);
if (!state) {
return;
}
target.latestDescriptorStateKey = descriptorStateKey(workspace);
target.lastBranchName = workspace?.name ?? null;
state.latestDescriptorStateKey = descriptorStateKey(workspace);
state.lastBranchName = workspace?.name ?? null;
}
function removeForCwd(cwd: string): void {
const normalizedCwd = resolve(cwd);
const target = watchTargets.get(normalizedCwd);
for (const workspaceId of target?.workspaceIds ?? []) {
workspaceStates.delete(workspaceId);
}
watchTargets.delete(normalizedCwd);
subscriptions.get(normalizedCwd)?.();
subscriptions.delete(normalizedCwd);
}
function removeForWorkspaceId(workspaceId: string): void {
const state = workspaceStates.get(workspaceId);
if (!state) {
return;
}
workspaceStates.delete(workspaceId);
const target = watchTargets.get(state.cwd);
target?.workspaceIds.delete(workspaceId);
if (target?.workspaceIds.size === 0) {
removeForCwd(state.cwd);
}
}
function handleBranchSnapshot(cwd: string, branchName: string | null): void {
const target = watchTargets.get(resolve(cwd));
if (!target) {
return;
}
const previousBranchName = target.lastBranchName;
if (branchName === previousBranchName) {
return;
for (const workspaceId of target.workspaceIds) {
const state = workspaceStates.get(workspaceId);
if (!state) {
continue;
}
const previousBranchName = state.lastBranchName;
if (branchName === previousBranchName) {
continue;
}
state.lastBranchName = branchName;
onBranchChanged?.(workspaceId, previousBranchName, branchName);
}
target.lastBranchName = branchName;
onBranchChanged?.(target.workspaceId, previousBranchName, branchName);
}
function syncObserver(cwd: string, options: { isGit: boolean; workspaceId: string }): void {
const normalizedCwd = resolve(cwd);
const currentState = workspaceStates.get(options.workspaceId);
if (currentState && currentState.cwd !== normalizedCwd) {
removeForWorkspaceId(options.workspaceId);
}
if (!options.isGit) {
removeForCwd(normalizedCwd);
removeForWorkspaceId(options.workspaceId);
return;
}
const target = watchTargets.get(normalizedCwd) ?? {
workspaceIds: new Set<string>(),
};
watchTargets.set(normalizedCwd, target);
target.workspaceIds.add(options.workspaceId);
if (!workspaceStates.has(options.workspaceId)) {
workspaceStates.set(options.workspaceId, {
cwd: normalizedCwd,
latestDescriptorStateKey: null,
lastBranchName: null,
});
}
if (subscriptions.has(normalizedCwd)) {
return;
}
const target: WorkspaceGitWatchTarget = {
cwd: normalizedCwd,
workspaceId: options.workspaceId,
latestDescriptorStateKey: null,
lastBranchName: null,
};
watchTargets.set(normalizedCwd, target);
const subscription = workspaceGitService.registerWorkspace(
{ cwd: normalizedCwd },
(snapshot) => {
let subscription: ReturnType<WorkspaceGitService["registerWorkspace"]>;
try {
subscription = workspaceGitService.registerWorkspace({ cwd: normalizedCwd }, (snapshot) => {
handleBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
void emitWorkspaceUpdateForCwd(normalizedCwd).catch((error) => {
logger.warn(
@@ -152,18 +178,21 @@ export function createWorkspaceGitObserverService(deps: {
);
});
emitStatusUpdate(normalizedCwd, snapshot);
},
);
});
} catch (error) {
removeForWorkspaceId(options.workspaceId);
throw error;
}
subscriptions.set(normalizedCwd, subscription.unsubscribe);
}
function syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
for (const workspace of workspaces) {
syncObserver(workspace.workspaceDirectory, {
isGit: workspace.projectKind === "git",
isGit: workspace.workspaceKind !== "directory",
workspaceId: workspace.id,
});
rememberDescriptorState(workspace.workspaceDirectory, workspace);
rememberDescriptorState(workspace.id, workspace);
}
}
@@ -182,24 +211,24 @@ export function createWorkspaceGitObserverService(deps: {
},
shouldSkipUpdate(workspaceId, workspace) {
const target = resolveTargetByWorkspaceId(workspaceId);
if (!target) {
const state = workspaceStates.get(workspaceId);
if (!state) {
return false;
}
const nextStateKey = descriptorStateKey(workspace);
if (target.latestDescriptorStateKey === nextStateKey) {
if (state.latestDescriptorStateKey === nextStateKey) {
return true;
}
target.latestDescriptorStateKey = nextStateKey;
state.latestDescriptorStateKey = nextStateKey;
return false;
},
recordDescriptorState(workspaceId, nextWorkspace) {
const target = resolveTargetByWorkspaceId(workspaceId);
if (target && onBranchChanged) {
const state = workspaceStates.get(workspaceId);
if (state && onBranchChanged) {
const newBranchName = nextWorkspace?.name ?? null;
if (newBranchName !== target.lastBranchName) {
onBranchChanged(workspaceId, target.lastBranchName, newBranchName);
if (newBranchName !== state.lastBranchName) {
onBranchChanged(workspaceId, state.lastBranchName, newBranchName);
}
}
rememberDescriptorState(workspaceId, nextWorkspace);
@@ -207,14 +236,7 @@ export function createWorkspaceGitObserverService(deps: {
handleBranchSnapshot,
removeForWorkspaceId(workspaceId) {
const target = resolveTargetByWorkspaceId(workspaceId);
if (target) {
removeForCwd(target.cwd);
}
},
removeForCwd,
removeForWorkspaceId,
dispose() {
for (const unsubscribe of subscriptions.values()) {
@@ -222,6 +244,7 @@ export function createWorkspaceGitObserverService(deps: {
}
subscriptions.clear();
watchTargets.clear();
workspaceStates.clear();
},
};
}
@@ -10,10 +10,13 @@ import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
type PersistedProjectRecord,
createPersistedWorkspaceRecord,
type WorkspaceRegistry,
} from "../../workspace-registry.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import {
createWorkspaceProvisioningService,
WorkspaceProvisioningError,
type WorkspaceProvisioningService,
} from "./workspace-provisioning-service.js";
@@ -27,6 +30,8 @@ const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
let tmpDir: string;
let gitRoots: Set<string>;
let gitBranches: Map<string, string | null>;
let checkoutFailure: Error | null;
let workspaceRegistry: FileBackedWorkspaceRegistry;
let projectRegistry: FileBackedProjectRegistry;
let provisioning: WorkspaceProvisioningService;
@@ -35,6 +40,7 @@ function gitService() {
return createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async (cwd: string) => {
if (checkoutFailure) throw checkoutFailure;
let worktreeRoot: string | null = null;
for (const root of gitRoots) {
if (
@@ -47,7 +53,7 @@ function gitService() {
return {
cwd,
isGit: worktreeRoot !== null,
currentBranch: worktreeRoot ? "main" : null,
currentBranch: worktreeRoot ? (gitBranches.get(worktreeRoot) ?? "main") : null,
remoteUrl: null,
worktreeRoot,
isPaseoOwnedWorktree: false,
@@ -60,6 +66,8 @@ function gitService() {
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-provisioning-"));
gitRoots = new Set();
gitBranches = new Map();
checkoutFailure = null;
workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(tmpDir, "projects", "workspaces.json"),
logger,
@@ -112,6 +120,72 @@ test("re-opening an active workspace by exact path returns the same record witho
expect(await workspaceRegistry.list()).toHaveLength(1);
});
test("re-opening Windows-equivalent workspace cwd spellings reuses the active and archived record", async () => {
const cwd = path.join(tmpDir, "workspace");
const created = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
await workspaceRegistry.upsert({ ...created, cwd: `${cwd}${path.sep}` });
const active = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
expect(active.workspaceId).toBe(created.workspaceId);
expect(await workspaceRegistry.list()).toHaveLength(1);
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(cwd);
expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null });
expect(await workspaceRegistry.list()).toHaveLength(1);
});
test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => {
const repo = path.join(tmpDir, "repo");
const first = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.upsert({ ...first, title: "Pinned work" });
gitRoots.add(repo);
gitBranches.set(repo, "feature/refresh");
const refreshed = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(refreshed).toMatchObject({
workspaceId: first.workspaceId,
kind: "local_checkout",
branch: "feature/refresh",
displayName: first.displayName,
title: "Pinned work",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
});
expect(await workspaceRegistry.get(first.workspaceId)).toEqual(refreshed);
expect((await projectRegistry.get(first.projectId))?.kind).toBe("git");
});
test("persists manual worktree ownership separately from its workspace kind", async () => {
const cwd = path.join(tmpDir, "manual-worktree");
const mainRepoRoot = path.join(tmpDir, "main-repo");
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async () => ({
cwd,
isGit: true,
currentBranch: "feature/manual",
remoteUrl: null,
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot,
}),
}),
});
const workspace = await manualWorktreeProvisioning.findOrCreateWorkspaceForDirectory(cwd);
expect(workspace).toMatchObject({
kind: "worktree",
isPaseoOwnedWorktree: false,
mainRepoRoot,
});
});
test("re-opening an archived workspace by its exact path unarchives it and keeps the id", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
@@ -124,6 +198,110 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps
expect(reopened.archivedAt).toBeNull();
});
test("reopening archived exact-root records restores the fresh Git project", async () => {
const cwd = path.join(tmpDir, "repo");
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: cwd,
kind: "non_git",
displayName: "repo",
timestamp: ARCHIVED_AT,
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-archived-root",
projectId: project.projectId,
cwd,
kind: "directory",
displayName: "repo",
createdAt: ARCHIVED_AT,
updatedAt: ARCHIVED_AT,
archivedAt: ARCHIVED_AT,
});
await workspaceRegistry.upsert(workspace);
await projectRegistry.archive(project.projectId, ARCHIVED_AT);
const archivedProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async () => ({
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
}),
});
const reopened = await archivedProvisioning.ensureWorkspaceRecordUnarchived(workspace);
expect(reopened).toMatchObject({
workspaceId: workspace.workspaceId,
kind: "local_checkout",
archivedAt: null,
});
expect(await projectRegistry.get(project.projectId)).toMatchObject({
kind: "git",
archivedAt: null,
});
});
test("uses one workspace snapshot when reopening an archived workspace", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
const archived = (await workspaceRegistry.list()).filter(
(workspace) => workspace.workspaceId === created.workspaceId,
);
let reads = 0;
const snapshotRegistry: WorkspaceRegistry = {
initialize: () => workspaceRegistry.initialize(),
existsOnDisk: () => workspaceRegistry.existsOnDisk(),
list: async () => (reads++ === 0 ? archived : []),
get: (workspaceId) => workspaceRegistry.get(workspaceId),
upsert: (workspace) => workspaceRegistry.upsert(workspace),
archive: (workspaceId, archivedAt) => workspaceRegistry.archive(workspaceId, archivedAt),
remove: (workspaceId) => workspaceRegistry.remove(workspaceId),
};
const snapshotProvisioning = createWorkspaceProvisioningService({
workspaceRegistry: snapshotRegistry,
projectRegistry,
workspaceGitService: gitService(),
});
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
expect(reopened).toMatchObject({ workspaceId: created.workspaceId, archivedAt: null });
expect(await workspaceRegistry.list()).toHaveLength(1);
});
test("reopening an archived workspace refreshes placement without renaming it", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" });
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
gitRoots.delete(repo);
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(reopened).toMatchObject({
workspaceId: created.workspaceId,
projectId: created.projectId,
title: "Pinned archived work",
kind: "directory",
branch: null,
displayName: created.displayName,
archivedAt: null,
});
expect(reopened.updatedAt).toEqual(expect.any(String));
expect(await workspaceRegistry.get(created.workspaceId)).toEqual(reopened);
});
test("opening a subpath of an archived git workspace mints a fresh workspace at the exact subpath", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
@@ -138,7 +316,7 @@ test("opening a subpath of an archived git workspace mints a fresh workspace at
expect((await workspaceRegistry.get(canonical.workspaceId))?.archivedAt).toBe(ARCHIVED_AT);
});
test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its project", async () => {
test("ensureWorkspaceRecordUnarchived restores the owning archived project with the workspace", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
@@ -154,6 +332,24 @@ test("ensureWorkspaceRecordUnarchived clears archivedAt on the workspace and its
expect((await projectRegistry.get(created.projectId))?.archivedAt).toBeNull();
});
test("does not unarchive either record when checkout refresh fails", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await projectRegistry.archive(created.projectId, ARCHIVED_AT);
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
const archivedProject = await projectRegistry.get(created.projectId);
const archivedWorkspace = await workspaceRegistry.get(created.workspaceId);
checkoutFailure = new Error("Git read failed");
await expect(provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!)).rejects.toThrow(
"Git read failed",
);
expect(await projectRegistry.get(created.projectId)).toEqual(archivedProject);
expect(await workspaceRegistry.get(created.workspaceId)).toEqual(archivedWorkspace);
});
test("resolveOrCreateWorkspaceIdForCreateAgent returns a created worktree's id without touching the registry", async () => {
// The branch only reads workspace.workspaceId off the worktree result.
const createdWorktree = {
@@ -207,15 +403,90 @@ test("createWorkspaceForDirectory always mints a fresh workspace even when one a
expect(await workspaceRegistry.list()).toHaveLength(2);
});
test("findOrCreateProjectForDirectory reuses the active project for the same root", async () => {
test("directory creation persists the live branch and a trimmed title", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
const workspace = await provisioning.createWorkspaceForDirectory(repo, " Focused work ");
expect(workspace).toMatchObject({ branch: "main", title: "Focused work" });
});
test("createWorkspaceForDirectory honors an explicit active project without cwd containment", async () => {
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: path.join(tmpDir, "elsewhere"),
kind: "non_git",
displayName: "elsewhere",
timestamp: "2026-03-01T00:00:00.000Z",
});
const workspace = await provisioning.createWorkspaceForDirectory(
path.join(tmpDir, "directory"),
null,
project.projectId,
);
expect(workspace.projectId).toBe(project.projectId);
});
test("createWorkspaceForDirectory refreshes an explicit project's stale Git kind", async () => {
const rootPath = path.join(tmpDir, "repo");
gitRoots.add(rootPath);
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath,
kind: "non_git",
displayName: "Saved project name",
timestamp: ARCHIVED_AT,
});
await projectRegistry.upsert({ ...project, customName: "Pinned project name" });
const workspace = await provisioning.createWorkspaceForDirectory(
rootPath,
null,
project.projectId,
);
expect(workspace.projectId).toBe(project.projectId);
expect(await projectRegistry.get(project.projectId)).toMatchObject({
projectId: project.projectId,
rootPath,
kind: "git",
displayName: "Saved project name",
customName: "Pinned project name",
});
});
test("createWorkspaceForDirectory classifies unknown and archived explicit projects", async () => {
await expect(
provisioning.createWorkspaceForDirectory(path.join(tmpDir, "directory"), null, "missing"),
).rejects.toMatchObject({
code: "unknown_project",
} satisfies Partial<WorkspaceProvisioningError>);
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: path.join(tmpDir, "archived"),
kind: "non_git",
displayName: "archived",
timestamp: "2026-03-01T00:00:00.000Z",
});
await projectRegistry.archive(project.projectId, "2026-03-02T00:00:00.000Z");
await expect(
provisioning.createWorkspaceForDirectory(
path.join(tmpDir, "directory"),
null,
project.projectId,
),
).rejects.toMatchObject({
code: "archived_project",
} satisfies Partial<WorkspaceProvisioningError>);
});
test("findOrCreateProjectForDirectory keeps nested selected roots independent", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
const first = await provisioning.findOrCreateProjectForDirectory(repo);
const second = await provisioning.findOrCreateProjectForDirectory(path.join(repo, "sub"));
expect(second.projectId).toBe(first.projectId);
expect(await projectRegistry.list()).toHaveLength(1);
expect(second.projectId).not.toBe(first.projectId);
expect(first.rootPath).toBe(repo);
expect(second.rootPath).toBe(path.join(repo, "sub"));
expect(await projectRegistry.list()).toHaveLength(2);
});
test("runInImportWorkspace uses an active requested workspace without creating another", async () => {
@@ -1,12 +1,11 @@
import { resolve } from "node:path";
import { basename, resolve } from "node:path";
import type { Logger } from "pino";
import {
checkoutLiteFromGitSnapshot,
classifyDirectoryForProjectMembership,
generateWorkspaceId,
initialWorkspacePlacement,
reconcileWorkspacePlacement,
} from "../../workspace-registry-model.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
@@ -15,20 +14,8 @@ import {
} from "../../workspace-registry.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
/**
* Resolves which workspace and project records a directory belongs to, creating,
* reclassifying, or unarchiving them as needed. Every path that needs a workspace
* for a cwd — opening a project, importing an agent, creating an agent, restoring
* an archived worktree — funnels through this one module, so the
* classify → resolve-project → persist → unarchive sequence (and the
* archived-reopen-at-a-different-path and reclassify-vs-unarchive special cases)
* lives in a single place instead of being smeared across the session.
*
* Read-only path resolution (no create/persist) lives in resolve-workspace-id-for-path.ts;
* this module owns the create-and-persist side.
*/
export interface ResolveOrCreateWorkspaceIdInput {
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
requestedWorkspaceId?: string;
@@ -46,6 +33,17 @@ export interface ImportWorkspaceResult<T> {
createdWorkspace: PersistedWorkspaceRecord | null;
}
export interface CreateWorktreeWorkspaceInput {
sourceCwd: string;
projectId?: string;
repoRoot: string;
cwd: string;
worktreeRoot: string;
branch: string | null;
baseBranch: string | null;
title: string | null;
}
export interface WorkspaceProvisioningService {
runInImportWorkspace<T>(
input: ImportWorkspaceInput,
@@ -56,6 +54,10 @@ export interface WorkspaceProvisioningService {
createWorkspaceForDirectory(
cwd: string,
title?: string | null,
projectId?: string,
): Promise<PersistedWorkspaceRecord>;
createWorkspaceForWorktree(
input: CreateWorktreeWorkspaceInput,
): Promise<PersistedWorkspaceRecord>;
findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord>;
ensureWorkspaceRecordUnarchived(
@@ -63,6 +65,22 @@ export interface WorkspaceProvisioningService {
): Promise<PersistedWorkspaceRecord>;
}
export type WorkspaceProvisioningErrorCode = "unknown_project" | "archived_project";
export class WorkspaceProvisioningError extends Error {
constructor(
readonly code: WorkspaceProvisioningErrorCode,
projectId: string,
) {
super(
code === "unknown_project"
? `Unknown project: ${projectId}`
: `Archived project: ${projectId}`,
);
this.name = "WorkspaceProvisioningError";
}
}
export function createWorkspaceProvisioningService(deps: {
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
@@ -134,224 +152,220 @@ export function createWorkspaceProvisioningService(deps: {
}
}
async function resolveWorkspaceDirectory(
cwd: string,
options?: { refreshGit?: boolean },
): Promise<string> {
const normalizedCwd = resolve(cwd);
if (options?.refreshGit === false) {
const snapshot = workspaceGitService.peekSnapshot(normalizedCwd);
return resolve(snapshot?.git.repoRoot ?? normalizedCwd);
}
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
return resolve(checkout.worktreeRoot ?? normalizedCwd);
}
async function findExactWorkspaceByDirectory(
cwd: string,
options?: { refreshGit?: boolean },
): Promise<PersistedWorkspaceRecord | null> {
const normalizedCwd = await resolveWorkspaceDirectory(cwd, options);
const workspaces = await workspaceRegistry.list();
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
}
async function resolveProjectRecordForPlacement(input: {
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
timestamp: string;
}): Promise<PersistedProjectRecord> {
const rootPath = input.membership.projectRootPath;
const kind = input.membership.projectKind;
const projects = await projectRegistry.list();
const existingProject =
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
projects.find((project) => project.rootPath === rootPath) ??
null;
if (!existingProject) {
return createPersistedProjectRecord({
projectId: input.membership.projectKey,
rootPath,
kind,
displayName: input.membership.projectName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
});
}
return {
...existingProject,
rootPath,
kind,
archivedAt: null,
updatedAt: input.timestamp,
};
}
async function reclassifyOrUnarchiveWorkspaceForDirectory(input: {
workspace: PersistedWorkspaceRecord;
project: PersistedProjectRecord | null;
cwd: string;
}): Promise<PersistedWorkspaceRecord> {
const checkout = await workspaceGitService.getCheckout(input.cwd);
const membership = classifyDirectoryForProjectMembership({ cwd: input.cwd, checkout });
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
const rootPath = resolve(cwd);
const checkout = await workspaceGitService.getCheckout(rootPath);
const timestamp = new Date().toISOString();
const projectRecord = await resolveProjectRecordForPlacement({
membership,
return projectRegistry.getOrCreateActiveByRoot({
rootPath,
kind: checkout.isGit ? "git" : "non_git",
displayName: basename(rootPath) || rootPath,
timestamp,
});
const projectId = projectRecord.projectId;
const kind = membership.workspaceKind;
const displayName = membership.workspaceDisplayName;
}
if (
input.workspace.projectId === projectId &&
input.workspace.kind === kind &&
input.workspace.displayName === displayName
) {
if (!input.project) {
await projectRegistry.upsert(projectRecord);
}
return ensureWorkspaceRecordUnarchived(input.workspace);
async function requireActiveProject(projectId: string): Promise<PersistedProjectRecord> {
const project = await projectRegistry.get(projectId);
if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId);
if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId);
return project;
}
async function createWorkspaceForDirectory(
cwd: string,
title?: string | null,
projectId?: string,
): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = resolve(cwd);
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
const project = projectId
? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout)
: // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15.
await findOrCreateProjectForDirectory(normalizedCwd);
const timestamp = new Date().toISOString();
const workspace = createPersistedWorkspaceRecord({
workspaceId: generateWorkspaceId(),
projectId: project.projectId,
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
title: title?.trim() || null,
createdAt: timestamp,
updatedAt: timestamp,
});
await workspaceRegistry.upsert(workspace);
return workspace;
}
async function createWorkspaceForWorktree(
input: CreateWorktreeWorkspaceInput,
): Promise<PersistedWorkspaceRecord> {
const sourceCwd = resolve(input.sourceCwd);
const repoRoot = resolve(input.repoRoot);
const cwd = resolve(input.cwd);
const worktreeRoot = resolve(input.worktreeRoot);
const project = await resolveSourceProjectForWorktree({
sourceCwd,
projectId: input.projectId,
repoRoot,
});
const timestamp = new Date().toISOString();
const workspace = createPersistedWorkspaceRecord({
workspaceId: generateWorkspaceId(),
projectId: project.projectId,
...initialWorkspacePlacement({
source: "created_worktree",
cwd,
worktreeRoot,
branch: input.branch,
baseBranch: input.baseBranch,
mainRepoRoot: repoRoot,
}),
title: input.title,
createdAt: timestamp,
updatedAt: timestamp,
});
await workspaceRegistry.upsert(workspace);
return workspace;
}
async function resolveSourceProjectForWorktree(input: {
sourceCwd: string;
projectId?: string;
repoRoot: string;
}): Promise<PersistedProjectRecord> {
if (input.projectId) {
return refreshProjectKind(await requireActiveProject(input.projectId));
}
await projectRegistry.upsert(projectRecord);
const workspaces = await workspaceRegistry.list();
const sourceWorkspace =
workspaces.find(
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.sourceCwd),
) ??
workspaces.find(
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, input.repoRoot),
);
if (sourceWorkspace) {
const project = await projectRegistry.get(sourceWorkspace.projectId);
if (project) return refreshProjectKind(project);
// COMPAT(worktreeMissingSourceProject): added in v0.1.107, remove after 2027-01-15.
// Orphaned legacy workspace FKs fall through to exact-root allocation.
}
const nextWorkspace = {
...input.workspace,
projectId,
cwd: input.cwd,
kind,
displayName,
archivedAt: null,
updatedAt: timestamp,
};
await workspaceRegistry.upsert(nextWorkspace);
return nextWorkspace;
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: input.repoRoot,
kind: "git",
displayName: basename(input.repoRoot) || input.repoRoot,
timestamp: new Date().toISOString(),
});
return refreshProjectKind(project);
}
async function findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const inputCwd = resolve(cwd);
const normalizedCwd = await resolveWorkspaceDirectory(cwd);
const existingWorkspace = await findExactWorkspaceByDirectory(normalizedCwd, {
refreshGit: false,
});
if (existingWorkspace) {
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
const timestamp = new Date().toISOString();
const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
isGit: false,
currentBranch: null,
remoteUrl: null,
repoRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
});
const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
const projectRecord = await resolveProjectRecordForPlacement({
membership,
timestamp,
});
await projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId,
cwd: inputCwd,
kind: membership.workspaceKind,
displayName: membership.workspaceDisplayName,
createdAt: timestamp,
updatedAt: timestamp,
});
await workspaceRegistry.upsert(workspaceRecord);
return workspaceRecord;
}
return reclassifyOrUnarchiveWorkspaceForDirectory({
workspace: existingWorkspace,
project: await projectRegistry.get(existingWorkspace.projectId),
cwd: normalizedCwd,
});
const normalizedCwd = resolve(cwd);
const workspaces = await workspaceRegistry.list();
const active = workspaces
.filter(
(workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
)
.sort(
(left, right) =>
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
left.workspaceId.localeCompare(right.workspaceId),
)[0];
if (active) return refreshWorkspaceRecord(active);
const archived = workspaces
.filter(
(workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd),
)
.sort(
(left, right) =>
Date.parse(left.createdAt) - Date.parse(right.createdAt) ||
left.workspaceId.localeCompare(right.workspaceId),
)[0];
if (archived) {
const project = await projectRegistry.get(archived.projectId);
if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived);
}
return createWorkspaceForDirectory(normalizedCwd);
}
async function resolveOrCreateWorkspaceIdForCreateAgent(
input: ResolveOrCreateWorkspaceIdInput,
): Promise<string> {
if (input.createdWorktree) {
return input.createdWorktree.workspace.workspaceId;
}
if (input.requestedWorkspaceId) {
return input.requestedWorkspaceId;
}
if (input.createdWorktree) return input.createdWorktree.workspace.workspaceId;
if (input.requestedWorkspaceId) return input.requestedWorkspaceId;
return (await createWorkspaceForDirectory(input.cwd, input.initialTitle)).workspaceId;
}
async function createWorkspaceForDirectory(
cwd: string,
title?: string | null,
): Promise<PersistedWorkspaceRecord> {
const checkout = await workspaceGitService.getCheckout(cwd);
const membership = classifyDirectoryForProjectMembership({ cwd, checkout });
const timestamp = new Date().toISOString();
const projectRecord = await resolveProjectRecordForPlacement({
membership,
timestamp,
});
await projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId,
cwd,
kind: membership.workspaceKind,
displayName: membership.workspaceDisplayName,
title: title ?? null,
createdAt: timestamp,
updatedAt: timestamp,
});
await workspaceRegistry.upsert(workspaceRecord);
return workspaceRecord;
}
async function findOrCreateProjectForDirectory(cwd: string): Promise<PersistedProjectRecord> {
const normalizedCwd = resolve(cwd);
const checkout = await workspaceGitService.getCheckout(normalizedCwd);
const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout });
const projectRecord = await resolveProjectRecordForPlacement({
membership,
timestamp: new Date().toISOString(),
});
await projectRegistry.upsert(projectRecord);
return projectRecord;
}
async function ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
const project = await projectRegistry.get(workspace.projectId);
if (!workspace.archivedAt && (!project || !project.archivedAt)) {
return workspace;
}
if (!project) throw new Error(`Unknown project: ${workspace.projectId}`);
const timestamp = new Date().toISOString();
let unarchivedWorkspace = workspace;
if (workspace.archivedAt) {
unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp };
await workspaceRegistry.upsert(unarchivedWorkspace);
}
if (project?.archivedAt) {
await projectRegistry.upsert({
...project,
archivedAt: null,
const checkout =
workspace.archivedAt || project.archivedAt
? await workspaceGitService.getCheckout(workspace.cwd)
: null;
let next: PersistedWorkspaceRecord | null = null;
if (workspace.archivedAt && checkout) {
const placementUpdate = reconcileWorkspacePlacement({
workspace,
checkout,
updatedAt: timestamp,
});
next = {
...(placementUpdate?.workspace ?? workspace),
archivedAt: null,
updatedAt: timestamp,
};
}
return unarchivedWorkspace;
if (checkout && (project.archivedAt || workspace.archivedAt)) {
const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd)
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
const kind = projectCheckout.isGit ? "git" : "non_git";
if (project.archivedAt || project.kind !== kind) {
await projectRegistry.upsert({ ...project, kind, archivedAt: null, updatedAt: timestamp });
}
}
if (!next) return workspace;
await workspaceRegistry.upsert(next);
return next;
}
async function refreshWorkspaceRecord(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
const checkout = await workspaceGitService.getCheckout(workspace.cwd);
const project = await projectRegistry.get(workspace.projectId);
if (project && !project.archivedAt) {
await refreshProjectKind(project, workspace.cwd, checkout);
}
const update = reconcileWorkspacePlacement({
workspace,
checkout,
updatedAt: new Date().toISOString(),
});
if (!update) return workspace;
await workspaceRegistry.upsert(update.workspace);
return update.workspace;
}
async function refreshProjectKind(
project: PersistedProjectRecord,
workspaceCwd?: string,
workspaceCheckout?: Awaited<ReturnType<WorkspaceGitService["getCheckout"]>>,
): Promise<PersistedProjectRecord> {
const projectCheckout =
workspaceCwd && workspaceCheckout && areEquivalentPaths(project.rootPath, workspaceCwd)
? workspaceCheckout
: await workspaceGitService.getCheckout(project.rootPath);
const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git";
if (project.kind === kind) return project;
const refreshed = { ...project, kind, updatedAt: new Date().toISOString() };
await projectRegistry.upsert(refreshed);
return refreshed;
}
return {
@@ -359,6 +373,7 @@ export function createWorkspaceProvisioningService(deps: {
findOrCreateWorkspaceForDirectory,
resolveOrCreateWorkspaceIdForCreateAgent,
createWorkspaceForDirectory,
createWorkspaceForWorktree,
findOrCreateProjectForDirectory,
ensureWorkspaceRecordUnarchived,
};
@@ -1,4 +1,19 @@
import { describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createWorktree } from "../../../utils/worktree.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -8,15 +23,23 @@ import {
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
const NOW = "2026-07-11T10:12:30.752Z";
const tempDirectories: string[] = [];
function createProject(): PersistedProjectRecord {
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createProject(overrides: Partial<PersistedProjectRecord> = {}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: "/repo",
rootPath: "/repo",
projectId: "/project",
rootPath: "/project",
kind: "git",
displayName: "repo",
displayName: "project",
createdAt: NOW,
updatedAt: NOW,
...overrides,
});
}
@@ -25,12 +48,15 @@ function createWorkspace(
): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: "wks_15a1b5630ebaab33",
projectId: "/repo",
projectId: "/project",
cwd: "/worktrees/trigger-1525443412986298439",
kind: "worktree",
displayName: "diagnose-repro-tdd",
title: "Codex TDD reproduction",
title: "TDD reproduction",
branch: "diagnose-repro-tdd",
worktreeRoot: "/worktrees/trigger-1525443412986298439",
isPaseoOwnedWorktree: true,
mainRepoRoot: "/repo",
createdAt: NOW,
updatedAt: NOW,
archivedAt: NOW,
@@ -42,55 +68,53 @@ function createHarness(input?: {
workspace?: PersistedWorkspaceRecord | null;
project?: PersistedProjectRecord | null;
directories?: string[];
recreate?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
paseoHome?: string;
worktreesRoot?: string;
}) {
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
const project = input?.project === undefined ? createProject() : input.project;
const directories = new Set(input?.directories ?? ["/repo"]);
const unarchived: string[] = [];
const recreated: string[] = [];
const service = createWorkspaceRecoveryService({
paseoHome: input?.paseoHome ?? "/paseo-home",
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
getWorkspace: async (workspaceId) =>
workspace?.workspaceId === workspaceId ? workspace : null,
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
isDirectory: async (path) => directories.has(path),
recreateWorktree: async (record) => {
recreated.push(record.workspaceId);
await input?.recreate?.(record);
},
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
return { service, recreated, unarchived };
return { service, unarchived };
}
describe("workspace recovery", () => {
test("authoritatively describes the archived missing worktree from the failed cloud run", async () => {
const { service, recreated, unarchived } = createHarness();
test("describes a missing archived worktree from persisted placement", async () => {
const { service, unarchived } = createHarness();
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
kind: "recoverable",
workspaceId: "wks_15a1b5630ebaab33",
workspaceName: "Codex TDD reproduction",
workspaceName: "TDD reproduction",
action: "restore",
branch: "diagnose-repro-tdd",
});
expect(recreated).toEqual([]);
expect(unarchived).toEqual([]);
});
test("describes an archived workspace whose directory remains as unarchivable", async () => {
test("unarchives an archived workspace whose exact directory remains", async () => {
const workspace = createWorkspace({ kind: "directory", branch: null });
const { service } = createHarness({
const { service, unarchived } = createHarness({
workspace,
directories: ["/repo", workspace.cwd],
directories: [workspace.cwd],
});
await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({
kind: "recoverable",
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
workspaceId: workspace.workspaceId,
action: "unarchive",
});
expect(unarchived).toEqual([workspace.workspaceId]);
});
test("does not offer recovery for a missing non-worktree directory", async () => {
@@ -105,27 +129,160 @@ describe("workspace recovery", () => {
});
});
test("keeps the workspace archived when recreation fails so restore can be retried", async () => {
let attempts = 0;
const { service, recreated, unarchived } = createHarness({
recreate: async () => {
attempts += 1;
if (attempts === 1) {
throw new Error("git branch diagnose-repro-tdd is unavailable");
}
test("uses the persisted source repository instead of the owning project to restore an exact subdirectory", async () => {
const { tempDir, repoDir } = createGitRepository();
const branch = "feature/mixed-project";
const sourceSubdirectory = join(repoDir, "packages", "app");
mkdirSync(sourceSubdirectory, { recursive: true });
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
const paseoHome = join(tempDir, "paseo-home");
const worktreesRoot = join(tempDir, "worktrees");
const created = await createWorktree({
cwd: repoDir,
worktreeSlug: "mixed-project",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
const worktreeRoot = realpathSync(created.worktreePath);
const workspaceCwd = join(worktreeRoot, "packages", "app");
rmSync(worktreeRoot, { recursive: true, force: true });
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
const projectRoot = join(tempDir, "explicit-non-git-project");
mkdirSync(projectRoot);
const project = createProject({
projectId: "explicit-non-git-project",
rootPath: projectRoot,
kind: "non_git",
});
const workspace = createWorkspace({
workspaceId: "ws-mixed-project-recreate",
projectId: project.projectId,
cwd: workspaceCwd,
branch,
worktreeRoot,
mainRepoRoot: repoDir,
});
const unarchived: string[] = [];
const service = createWorkspaceRecoveryService({
paseoHome,
worktreesRoot,
getWorkspace: async (workspaceId) =>
workspaceId === workspace.workspaceId ? workspace : null,
getProject: async (projectId) => (projectId === project.projectId ? project : null),
isDirectory: async (path) => existsSync(path) && statSync(path).isDirectory(),
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow(
"git branch diagnose-repro-tdd is unavailable",
);
expect(unarchived).toEqual([]);
await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({
workspaceId: "wks_15a1b5630ebaab33",
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
workspaceId: workspace.workspaceId,
action: "restore",
});
expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]);
expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]);
expect(existsSync(worktreeRoot)).toBe(true);
expect(existsSync(workspaceCwd)).toBe(true);
expect(unarchived).toEqual([workspace.workspaceId]);
});
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
const { tempDir, repoDir } = createGitRepository();
const branch = "feature/without-subproject";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
const paseoHome = join(tempDir, "paseo-home");
const worktreesRoot = join(tempDir, "worktrees");
const created = await createWorktree({
cwd: repoDir,
worktreeSlug: "without-subproject",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
const worktreeRoot = realpathSync(created.worktreePath);
const workspaceCwd = join(worktreeRoot, "packages", "app");
rmSync(worktreeRoot, { recursive: true, force: true });
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
const project = createProject({ rootPath: repoDir });
const workspace = createWorkspace({
workspaceId: "ws-missing-restored-subdirectory",
cwd: workspaceCwd,
branch,
worktreeRoot,
mainRepoRoot: repoDir,
});
const unarchived: string[] = [];
const service = createWorkspaceRecoveryService({
paseoHome,
worktreesRoot,
getWorkspace: async (workspaceId) =>
workspaceId === workspace.workspaceId ? workspace : null,
getProject: async (projectId) => (projectId === project.projectId ? project : null),
isDirectory: async (targetPath) =>
existsSync(targetPath) && statSync(targetPath).isDirectory(),
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
await expect(service.restore(workspace.workspaceId)).rejects.toThrow(
"Selected project directory is missing from the restored worktree",
);
expect(unarchived).toEqual([]);
expect(existsSync(worktreeRoot)).toBe(false);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: repoDir,
stdio: "pipe",
})
.toString()
.includes("without-subproject"),
).toBe(false);
});
test("keeps the workspace archived when its persisted source repository is missing", async () => {
const workspace = createWorkspace({ mainRepoRoot: "/missing-source" });
const { service, unarchived } = createHarness({
workspace,
directories: ["/project"],
});
await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({
kind: "unavailable",
workspaceId: workspace.workspaceId,
reason: "project_directory_missing",
message: "The source repository needed to restore this worktree no longer exists.",
});
await expect(service.restore(workspace.workspaceId)).rejects.toThrow(
"The source repository needed to restore this worktree no longer exists.",
);
expect(unarchived).toEqual([]);
});
});
function createGitRepository(): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(join(tmpdir(), "paseo-workspace-recovery-"));
tempDirectories.push(tempDir);
const repoDir = join(tempDir, "repo");
mkdirSync(repoDir);
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: repoDir,
stdio: "pipe",
});
writeFileSync(join(repoDir, "README.md"), "initial\n");
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
}
@@ -1,3 +1,14 @@
import { basename } from "node:path";
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
import {
createWorktree,
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
rollbackCreatedPaseoWorktree,
} from "../../../utils/worktree.js";
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
import {
resolveWorkspaceDisplayName,
type PersistedProjectRecord,
@@ -32,14 +43,32 @@ export interface WorkspaceRecoveryService {
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
}
type RecoveryPlan =
| {
kind: "unarchive";
state: Extract<WorkspaceRecoveryState, { kind: "recoverable" }>;
workspace: PersistedWorkspaceRecord;
}
| {
kind: "restore";
state: Extract<WorkspaceRecoveryState, { kind: "recoverable" }>;
workspace: PersistedWorkspaceRecord;
sourceRepoRoot: string;
};
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
export function createWorkspaceRecoveryService(deps: {
paseoHome: string;
worktreesRoot?: string;
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
isDirectory: (path: string) => Promise<boolean>;
recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise<void>;
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}): WorkspaceRecoveryService {
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
async function resolveRecovery(
workspaceId: string,
): Promise<UnavailableRecoveryState | RecoveryPlan> {
const workspace = await deps.getWorkspace(workspaceId);
if (!workspace) {
return {
@@ -69,13 +98,7 @@ export function createWorkspaceRecoveryService(deps: {
}
if (await deps.isDirectory(workspace.cwd)) {
return {
kind: "recoverable",
workspaceId,
workspaceName: resolveWorkspaceDisplayName(workspace),
action: "unarchive",
branch: workspace.branch,
};
return createRecoveryPlan({ action: "unarchive", workspace });
}
if (workspace.kind !== "worktree") {
@@ -94,42 +117,148 @@ export function createWorkspaceRecoveryService(deps: {
message: "The archived worktree has no branch recorded, so it cannot be restored.",
};
}
if (!(await deps.isDirectory(project.rootPath))) {
// COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110
// lack placement ownership; remove the project-root fallback after 2027-01-17.
const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath;
if (!(await deps.isDirectory(sourceRepoRoot))) {
return {
kind: "unavailable",
workspaceId,
reason: "project_directory_missing",
message: "The project directory needed to restore this worktree no longer exists.",
message: "The source repository needed to restore this worktree no longer exists.",
};
}
return {
kind: "recoverable",
workspaceId,
workspaceName: resolveWorkspaceDisplayName(workspace),
action: "restore",
branch: workspace.branch,
};
return createRecoveryPlan({ action: "restore", workspace, sourceRepoRoot });
}
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
const resolved = await resolveRecovery(workspaceId);
return resolved.kind === "unavailable" ? resolved : resolved.state;
}
async function restore(
workspaceId: string,
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
const state = await inspect(workspaceId);
if (state.kind === "unavailable") {
throw new Error(state.message);
const resolved = await resolveRecovery(workspaceId);
if (resolved.kind === "unavailable") {
throw new Error(resolved.message);
}
const workspace = await deps.getWorkspace(workspaceId);
if (!workspace?.archivedAt) {
throw new Error("The archived workspace changed before it could be recovered.");
if (resolved.kind === "restore") {
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot);
}
if (state.action === "restore") {
await deps.recreateWorktree(workspace);
await deps.unarchiveWorkspace(resolved.workspace);
return { workspaceId, action: resolved.kind };
}
async function recreateArchivedWorktree(
workspace: PersistedWorkspaceRecord,
sourceRepoRoot: string,
): Promise<void> {
const branch = workspace.branch;
if (!branch) {
throw new WorktreeRequestError({
code: "unknown",
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
});
}
try {
await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 });
} catch {
// A stale worktree registration is not guaranteed; creation reports any real conflict.
}
let previousWorktreePath = workspace.worktreeRoot;
if (!previousWorktreePath) {
// COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110
// lack durable backing placement; remove filesystem discovery after 2027-01-17.
const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, {
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
});
previousWorktreePath = ownership.allowed
? (ownership.worktreePath ?? workspace.cwd)
: workspace.cwd;
}
let recreatedWorktreePath: string;
try {
const result = await createWorktree({
cwd: sourceRepoRoot,
worktreeSlug: basename(previousWorktreePath),
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
});
recreatedWorktreePath = result.worktreePath;
} catch (error) {
throw toWorktreeRequestError(error);
}
try {
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
sourceWorktreePath: previousWorktreePath,
workspaceCwd: workspace.cwd,
targetWorktreePath: recreatedWorktreePath,
});
if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) {
throw new WorktreeRequestError({
code: "unknown",
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,
});
}
if (!(await deps.isDirectory(recreatedWorkspacePath))) {
throw new WorktreeRequestError({
code: "unknown",
message: `Selected project directory is missing from the restored worktree: ${recreatedWorkspacePath}`,
});
}
} catch (error) {
return rollbackCreatedPaseoWorktree(
{
cwd: sourceRepoRoot,
worktreePath: recreatedWorktreePath,
teardownCwds: [],
paseoHome: deps.paseoHome,
worktreesBaseRoot: deps.worktreesRoot,
},
error,
);
}
await deps.unarchiveWorkspace(workspace);
return { workspaceId, action: state.action };
}
return { inspect, restore };
}
function createRecoveryPlan(
input:
| { action: "unarchive"; workspace: PersistedWorkspaceRecord }
| { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string },
): RecoveryPlan {
const state = {
kind: "recoverable" as const,
workspaceId: input.workspace.workspaceId,
workspaceName: resolveWorkspaceDisplayName(input.workspace),
branch: input.workspace.branch,
};
if (input.action === "restore") {
return {
kind: input.action,
state: { ...state, action: input.action },
workspace: input.workspace,
sourceRepoRoot: input.sourceRepoRoot,
};
}
return {
kind: input.action,
state: {
...state,
action: input.action,
},
workspace: input.workspace,
};
}
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pino } from "pino";
@@ -6,34 +6,29 @@ import { afterEach, describe, expect, test } from "vitest";
import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../../workspace-registry.js";
import type { WorkspaceGitMetadata } from "../../workspace-git-metadata.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "../../workspace-registry.js";
import { createNoGitWorkspaceRuntimeSnapshot } from "../../test-utils/workspace-git-service-stub.js";
import { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
import type {
SpawnWorkspaceScriptOptions,
WorktreeScriptResult,
} from "../../worktree-bootstrap.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js";
// The production module reads only WorkspaceGitService.{peekSnapshot,getWorkspaceGitMetadata},
// The production module reads only WorkspaceGitService.{peekSnapshot,getProjectSlug},
// WorkspaceRegistry.get, and forwards the launcher + opaque managers to the injected
// spawnWorkspaceScript port. The fakes below implement exactly that slice; the service proxy and
// runtime store are the real in-memory implementations, and spawning is injected so no process runs.
const logger = pino({ level: "silent" });
const gitMetadata: WorkspaceGitMetadata = {
projectKind: "git",
projectDisplayName: "repo",
workspaceDisplayName: "repo",
gitRemote: null,
isWorktree: false,
projectSlug: "paseo",
repoRoot: "/tmp/repo",
currentBranch: "feature/scripts",
remoteUrl: null,
};
function fakeWorkspaceRegistry(
record: PersistedWorkspaceRecord | null,
): Pick<WorkspaceRegistry, "get"> {
@@ -44,13 +39,28 @@ function fakeWorkspaceRegistry(
};
}
function fakeGitService(metadata: WorkspaceGitMetadata = gitMetadata) {
function fakeProjectRegistry(record: PersistedProjectRecord | null): Pick<ProjectRegistry, "get"> {
return {
async get() {
return record;
},
};
}
function fakeGitService() {
const snapshot = createNoGitWorkspaceRuntimeSnapshot("/tmp/repo");
snapshot.git = {
...snapshot.git,
isGit: true,
repoRoot: "/tmp/repo",
currentBranch: "feature/scripts",
remoteUrl: "https://github.com/getpaseo/paseo.git",
hasRemote: true,
};
return {
peekSnapshot() {
return null;
},
async getWorkspaceGitMetadata() {
return metadata;
return snapshot;
},
};
}
@@ -64,7 +74,9 @@ interface BuildOptions {
scriptRuntimeStore?: WorkspaceScriptRuntimeStore | null;
terminalManager?: TerminalManager | null;
workspace?: PersistedWorkspaceRecord | null;
project?: PersistedProjectRecord | null;
spawnThrows?: string;
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
}
function buildService(options: BuildOptions = {}) {
@@ -87,7 +99,8 @@ function buildService(options: BuildOptions = {}) {
terminalManager:
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
workspaceRegistry: fakeWorkspaceRegistry(workspace),
workspaceGitService: fakeGitService(),
projectRegistry: fakeProjectRegistry(options.project ?? null),
workspaceGitService: options.gitService ?? fakeGitService(),
getDaemonTcpPort: () => 6767,
getDaemonTcpHost: () => "127.0.0.1",
serviceProxyPublicBaseUrl: null,
@@ -130,28 +143,77 @@ afterEach(() => {
});
describe("buildSnapshot", () => {
test("returns no scripts when the service proxy is unavailable", () => {
test("returns no scripts when the service proxy is unavailable", async () => {
const { service } = buildService({ serviceProxy: null });
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
).toEqual([]);
});
test("returns no scripts when the runtime store is unavailable", () => {
test("returns no scripts when the runtime store is unavailable", async () => {
const { service } = buildService({ scriptRuntimeStore: null });
expect(service.buildSnapshot("ws-1", "/tmp/repo")).toEqual([]);
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
).toEqual([]);
});
test("returns no scripts for a workspace without a paseo.json", () => {
test("returns no scripts for a workspace without a paseo.json", async () => {
const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
tempDirs.push(dir);
const { service } = buildService();
expect(service.buildSnapshot("ws-1", dir)).toEqual([]);
expect(
service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
).toEqual([]);
});
test("projects service hostnames without a Git snapshot", async () => {
const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
tempDirs.push(directory);
writeFileSync(
join(directory, "paseo.json"),
JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }),
);
const project = {
projectId: "prj_no_snapshot",
rootPath: directory,
kind: "git",
displayName: "app",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
} as PersistedProjectRecord;
const workspace = {
workspaceId: "ws-no-snapshot",
projectId: project.projectId,
cwd: directory,
branch: "feature/persisted",
} as PersistedWorkspaceRecord;
const serviceProxy = createServiceProxySubsystem({ logger });
const { service, spawnCalls } = buildService({
workspace,
project,
serviceProxy,
gitService: { peekSnapshot: () => undefined },
});
expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe(
serviceProxy.projectWorkspaceService({
projectSlug: deriveProjectServiceSlug(project),
branchName: workspace.branch,
scriptName: "app",
daemonPort: 6767,
}).hostname,
);
await service.start({ ...request, workspaceId: workspace.workspaceId });
expect(spawnCalls[0]?.branchName).toBe(workspace.branch);
});
});
describe("emitStatusUpdate", () => {
test("emits one script_status_update carrying the snapshot", () => {
test("emits one script_status_update carrying the snapshot", async () => {
const { service, emitted } = buildService();
service.emitStatusUpdate("ws-1", "/tmp/repo");
await service.emitStatusUpdate("ws-1", "/tmp/repo");
expect(emitted).toEqual([
{ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } },
]);
@@ -225,6 +287,101 @@ describe("start", () => {
});
});
test("uses the exact project root for a service hostname", async () => {
const workspace = {
workspaceId: "ws-app",
projectId: "prj-app",
cwd: "/repo/apps/app",
} as PersistedWorkspaceRecord;
const project = {
projectId: "prj-app",
rootPath: "/repo/apps/app",
kind: "git",
displayName: "app",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
} as PersistedProjectRecord;
const { service, spawnCalls } = buildService({ workspace, project });
await service.start({ ...request, workspaceId: workspace.workspaceId });
expect(spawnCalls[0]).toMatchObject({
projectSlug: deriveProjectServiceSlug(project),
});
});
test("keeps same-named service projects distinct", async () => {
const projectA = {
projectId: "prj-app-a",
rootPath: "/repo-a/app",
kind: "git",
displayName: "app",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
} as PersistedProjectRecord;
const projectB = { ...projectA, projectId: "prj-app-b", rootPath: "/repo-b/app" };
const workspaceA = {
workspaceId: "ws-app-a",
projectId: projectA.projectId,
cwd: projectA.rootPath,
} as PersistedWorkspaceRecord;
const workspaceB = {
workspaceId: "ws-app-b",
projectId: projectB.projectId,
cwd: projectB.rootPath,
} as PersistedWorkspaceRecord;
const first = buildService({ workspace: workspaceA, project: projectA });
const second = buildService({ workspace: workspaceB, project: projectB });
await first.service.start({ ...request, workspaceId: workspaceA.workspaceId });
await second.service.start({ ...request, workspaceId: workspaceB.workspaceId });
expect(first.spawnCalls[0]?.projectSlug).not.toBe(second.spawnCalls[0]?.projectSlug);
});
test("predicts the same service hostname that start registers", async () => {
const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
tempDirs.push(directory);
writeFileSync(
join(directory, "paseo.json"),
JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }),
);
const project = {
projectId: "prj_hostname",
rootPath: directory,
kind: "git",
displayName: "app",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
} as PersistedProjectRecord;
const workspace = {
workspaceId: "ws-hostname",
projectId: project.projectId,
cwd: directory,
} as PersistedWorkspaceRecord;
const serviceProxy = createServiceProxySubsystem({ logger });
const { service, spawnCalls } = buildService({ workspace, project, serviceProxy });
const snapshot = service.buildSnapshot(workspace, project);
await service.start({ ...request, workspaceId: workspace.workspaceId });
const started = spawnCalls[0]!;
expect(snapshot[0]?.hostname).toBe(
serviceProxy.projectWorkspaceService({
projectSlug: started.projectSlug,
branchName: started.branchName,
scriptName: started.scriptName,
daemonPort: started.daemonPort,
}).hostname,
);
});
test("reports the launcher error when spawning fails", async () => {
const { service, emitted } = buildService({ spawnThrows: "boom" });
await service.start(request);
@@ -9,7 +9,12 @@ import type { ServiceProxySubsystem } from "../../service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "../../workspace-script-runtime-store.js";
import type { ScriptHealthState } from "../../script-health-monitor.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { WorkspaceRegistry } from "../../workspace-registry.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "../../workspace-registry.js";
import type {
SpawnWorkspaceScriptOptions,
WorktreeScriptResult,
@@ -18,15 +23,10 @@ import {
buildWorkspaceScriptPayloads,
readPaseoConfigForProjection,
} from "../../script-status-projection.js";
import { deriveProjectSlug } from "../../workspace-git-metadata.js";
import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js";
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
interface WorkspaceScriptGitMetadata {
projectSlug: string;
currentBranch: string | null;
}
/**
* The service-proxy-backed scripts a workspace exposes: build the scripts payload
* snapshot, emit a script_status_update to clients, and start a script.
@@ -37,21 +37,22 @@ interface WorkspaceScriptGitMetadata {
* that assembly and guard across the session.
*/
export interface WorkspaceScriptsService {
buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload;
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void;
buildSnapshot(
workspace: PersistedWorkspaceRecord,
project?: PersistedProjectRecord | null,
): WorkspaceScriptsPayload;
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
start(request: StartWorkspaceScriptRequest): Promise<void>;
}
type WorkspaceScriptsGitSource = Pick<
WorkspaceGitService,
"peekSnapshot" | "getWorkspaceGitMetadata"
>;
type WorkspaceScriptsGitSource = Pick<WorkspaceGitService, "peekSnapshot">;
export function createWorkspaceScriptsService(deps: {
serviceProxy: ServiceProxySubsystem | null;
scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
terminalManager: TerminalManager | null;
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
projectRegistry: Pick<ProjectRegistry, "get">;
workspaceGitService: WorkspaceScriptsGitSource;
getDaemonTcpPort: (() => number | null) | null;
getDaemonTcpHost: (() => string | null) | null;
@@ -66,6 +67,7 @@ export function createWorkspaceScriptsService(deps: {
scriptRuntimeStore,
terminalManager,
workspaceRegistry,
projectRegistry,
workspaceGitService,
getDaemonTcpPort,
getDaemonTcpHost,
@@ -76,45 +78,60 @@ export function createWorkspaceScriptsService(deps: {
spawnWorkspaceScript,
} = deps;
function resolveGitMetadata(workspaceDirectory: string): WorkspaceScriptGitMetadata | undefined {
const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory);
if (!snapshot) {
return undefined;
function resolveGitMetadata(
workspace: PersistedWorkspaceRecord,
project: { projectId: string; rootPath: string } | null,
) {
const snapshot = workspaceGitService.peekSnapshot(workspace.cwd);
const currentBranch = snapshot?.git.currentBranch ?? workspace.branch ?? null;
if (project) {
return {
projectSlug: deriveProjectServiceSlug(project),
currentBranch,
};
}
if (!snapshot) return undefined;
return {
projectSlug: deriveProjectSlug(
workspaceDirectory,
workspace.cwd,
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
),
currentBranch: snapshot.git.currentBranch,
currentBranch,
};
}
function buildSnapshot(workspaceId: string, workspaceDirectory: string): WorkspaceScriptsPayload {
function buildSnapshot(
workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord | null = null,
): WorkspaceScriptsPayload {
if (!serviceProxy || !scriptRuntimeStore) {
return [];
}
return buildWorkspaceScriptPayloads({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger),
serviceProxy,
runtimeStore: scriptRuntimeStore,
daemonPort: getDaemonTcpPort?.() ?? null,
serviceProxyPublicBaseUrl,
gitMetadata: resolveGitMetadata(workspaceDirectory),
gitMetadata: resolveGitMetadata(workspace, project),
resolveHealth: resolveScriptHealth ?? undefined,
});
}
function emitStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
emit({
type: "script_status_update",
payload: {
workspaceId,
scripts: buildSnapshot(workspaceId, workspaceDirectory),
},
});
async function emitStatusUpdate(workspaceId: string, _workspaceDirectory: string): Promise<void> {
try {
const workspace = await workspaceRegistry.get(workspaceId);
if (!workspace) return;
const project = await projectRegistry.get(workspace.projectId);
emit({
type: "script_status_update",
payload: { workspaceId, scripts: buildSnapshot(workspace, project) },
});
} catch (error) {
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
}
}
async function start(request: StartWorkspaceScriptRequest): Promise<void> {
@@ -127,13 +144,23 @@ export function createWorkspaceScriptsService(deps: {
if (!workspace) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
const gitMetadata = await workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
const project = await projectRegistry.get(workspace.projectId);
const projectSlug = project
? deriveProjectServiceSlug(project)
: deriveProjectSlug(
workspace.cwd,
workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null,
);
const branchName =
workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ??
workspace.branch ??
null;
const serviceResult = await spawnWorkspaceScript({
repoRoot: workspace.cwd,
workspaceId: workspace.workspaceId,
projectSlug: gitMetadata.projectSlug,
branchName: gitMetadata.currentBranch,
projectSlug,
branchName,
scriptName: request.scriptName,
daemonPort: getDaemonTcpPort?.() ?? null,
daemonListenHost: getDaemonTcpHost?.() ?? null,
@@ -143,11 +170,11 @@ export function createWorkspaceScriptsService(deps: {
terminalManager,
logger,
onLifecycleChanged: () => {
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
},
});
emitStatusUpdate(workspace.workspaceId, workspace.cwd);
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
emit({
type: "start_workspace_script_response",
payload: {
@@ -99,6 +99,7 @@ describe("snapshot mutation ownership boundary", () => {
const session = asInternals<SessionInternals>(
new Session({
clientId: "test-client",
scopes: ["*"],
onMessage,
logger: createStub<SessionOptions["logger"]>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
@@ -57,10 +57,12 @@ interface FakeAgentSessionOptions {
sessionId?: string;
memoryMarker?: string | null;
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput) => void;
}
export interface TestAgentClientOptions {
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput) => void;
}
function createDeferred<T>(): Deferred<T> {
@@ -329,6 +331,7 @@ class FakeAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null;
private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined;
constructor(options: FakeAgentSessionOptions) {
this.providerName = options.providerName;
@@ -336,6 +339,7 @@ class FakeAgentSession implements AgentSession {
this.id = options.sessionId ?? randomUUID();
this.memoryMarker = options.memoryMarker ?? null;
this.closeSession = options.closeSession;
this.onStartTurn = options.onStartTurn;
this.historyPath = path.join(
tmpdir(),
"paseo-fake-provider-history",
@@ -426,6 +430,7 @@ class FakeAgentSession implements AgentSession {
const turnId = `fake-turn-${this.nextTurnOrdinal++}`;
this.activeForegroundTurnId = turnId;
this.onStartTurn?.(prompt);
void this.emitTurnEvents(prompt);
@@ -1180,6 +1185,7 @@ class FakeAgentClient implements AgentClient {
providerName: this.provider,
config: { ...config },
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
});
}
@@ -1203,6 +1209,7 @@ class FakeAgentClient implements AgentClient {
sessionId: handle.sessionId,
memoryMarker: typeof marker === "string" ? marker : null,
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
});
}
@@ -0,0 +1,4 @@
import { createHubCommand } from "../../../../cli/src/commands/hub/index.js";
const argv = [process.argv[0] ?? "node", process.argv[1] ?? "paseo", ...process.argv.slice(3)];
await createHubCommand().parseAsync(argv, { from: "node" });
@@ -1,9 +1,5 @@
import { basename } from "node:path";
import type { CheckoutDiffResult } from "../../utils/checkout-git.js";
import {
buildWorkspaceGitMetadataFromSnapshot,
type WorkspaceGitMetadata,
} from "../workspace-git-metadata.js";
import { deriveProjectSlug } from "../workspace-git-metadata.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "../workspace-git-service.js";
export function createNoGitWorkspaceRuntimeSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
@@ -39,6 +35,9 @@ export function createNoopWorkspaceGitService(
registerWorkspace: () => ({
unsubscribe: () => {},
}),
onSnapshotUpdated: () => ({
unsubscribe: () => {},
}),
peekSnapshot: () => null,
getCheckout: async (cwd: string) => ({
cwd,
@@ -56,17 +55,9 @@ export function createNoopWorkspaceGitService(
suggestBranchesForCwd: async () => [],
listStashes: async () => [],
listWorktrees: async () => [],
getWorkspaceGitMetadata: async (cwd: string, options): Promise<WorkspaceGitMetadata> => {
getProjectSlug: async (cwd: string) => {
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
return buildWorkspaceGitMetadataFromSnapshot({
cwd,
directoryName: options?.directoryName ?? basename(cwd),
isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot,
mainRepoRoot: snapshot.git.mainRepoRoot,
currentBranch: snapshot.git.currentBranch,
remoteUrl: snapshot.git.remoteUrl,
});
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
},
resolveForge: async () => null,
resolveRepoRoot: async (cwd: string) => cwd,

Some files were not shown because too many files have changed in this diff Show More