feat(quick-pane): promote active runtime

This commit is contained in:
2026-08-28 12:07:50 +02:00
parent 0a318f5ab6
commit b44b1be4b7
19 changed files with 1298 additions and 126 deletions
+257 -16
View File
@@ -5,8 +5,11 @@ import {
readdir,
readFile,
realpath,
rename,
rm,
stat,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
@@ -74,6 +77,69 @@ async function persistSessionReference(sessionDir, sessionPath, worktreePath) {
);
}
export async function promoteQuickSessionDirectory(
quickDir,
sessionDir,
{ renameFile = rename, makeSymlink = symlink } = {},
) {
const stagingDir = `${quickDir}.promoting-${randomUUID()}`;
const moved = [];
let quickStaged = false;
let quickLinked = false;
async function rollback() {
if (quickLinked) await unlink(quickDir).catch(() => {});
for (const entry of moved.reverse())
await renameFile(path.join(sessionDir, entry), path.join(stagingDir, entry));
if (quickStaged) await renameFile(stagingDir, quickDir);
}
try {
await mkdir(sessionDir, { recursive: true, mode: SESSION_DIRECTORY_MODE });
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
await renameFile(quickDir, stagingDir);
quickStaged = true;
await makeSymlink(sessionDir, quickDir, "dir");
quickLinked = true;
for (const entry of await readdir(stagingDir)) {
const destination = path.join(sessionDir, entry);
try {
await stat(destination);
throw new Error("quick session conflicts with an existing session file");
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
await renameFile(path.join(stagingDir, entry), destination);
moved.push(entry);
}
} catch (error) {
try {
await rollback();
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
"could not promote quick session directory; staging retained",
);
}
throw error;
}
return {
async rollback() {
try {
await rollback();
} catch (error) {
throw new Error(
`could not roll back quick session promotion; staging retained: ${error.message}`,
);
}
},
async finalize() {
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
},
};
}
function sessionPreview(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
@@ -245,6 +311,7 @@ export function createAgentRegistry({
homeWorktree,
sessionRoot,
startAdapter = startPiRpcAdapter,
workspaceStoreFactory = createWorkspaceStore,
eventLimit = DEFAULT_EVENT_LIMIT,
maxRecoveryAttempts,
recoveryDelayForAttempt,
@@ -257,9 +324,11 @@ export function createAgentRegistry({
throw new TypeError("sessionRoot must be an absolute path");
if (typeof startAdapter !== "function")
throw new TypeError("startAdapter must be a function");
if (typeof workspaceStoreFactory !== "function")
throw new TypeError("workspaceStoreFactory must be a function");
const bridgeInstanceId = randomUUID();
const workspaceStore = createWorkspaceStore(sessionRoot);
const workspaceStore = workspaceStoreFactory(sessionRoot);
const runtimesByRuntimeId = new Map();
const runtimesByAgentId = new Map();
const runtimesByWorktreePath = new Map();
@@ -272,6 +341,7 @@ export function createAgentRegistry({
const workspaceEvents = [];
let workspaceSequence = 0;
let manifestIssue;
let manifestWriteQueue = Promise.resolve();
let canonicalHomeWorktree;
let lastSuccessfulModelCatalog;
let stopping = false;
@@ -318,9 +388,15 @@ export function createAgentRegistry({
};
}
async function persistWorkspace() {
await workspaceStore.save(manifest());
manifestIssue = undefined;
function persistWorkspace() {
const operation = manifestWriteQueue
.catch(() => {})
.then(async () => {
await workspaceStore.save(manifest());
manifestIssue = undefined;
});
manifestWriteQueue = operation.catch(() => {});
return operation;
}
function addWorktreeRuntime(runtime) {
@@ -430,10 +506,23 @@ export function createAgentRegistry({
return resolvedSessionPath;
}
async function projectQuickSessionPath(quickDir, sessionDir, sessionPath) {
const activePath = await ownedSessionPath(quickDir, sessionPath);
const relativePath = path.relative(await realpath(quickDir), activePath);
return ownedSessionPath(sessionDir, path.join(sessionDir, relativePath), {
allowMissing: true,
});
}
async function updateRuntimeIdentity(
runtime,
stateResponse,
{ expectedSessionPath, adapter = runtime.adapter } = {},
{
expectedSessionPath,
adapter = runtime.adapter,
persist = true,
indexSession = true,
} = {},
) {
if (
runtime.stopped ||
@@ -477,15 +566,17 @@ export function createAgentRegistry({
runtime.sessionName = state.sessionName;
if (typeof state.sessionId === "string") runtime.sessionId = state.sessionId;
if (resolved) {
if (
runtime.sessionPath &&
runtime.sessionPath !== resolved &&
runtimesBySessionPath.get(runtime.sessionPath) === runtime
)
runtimesBySessionPath.delete(runtime.sessionPath);
if (indexSession) {
if (
runtime.sessionPath &&
runtime.sessionPath !== resolved &&
runtimesBySessionPath.get(runtime.sessionPath) === runtime
)
runtimesBySessionPath.delete(runtime.sessionPath);
runtimesBySessionPath.set(resolved, runtime);
releaseSessionReservation(resolved, runtime);
}
runtime.sessionPath = resolved;
runtimesBySessionPath.set(resolved, runtime);
releaseSessionReservation(resolved, runtime);
if (runtime.legacyDefault)
await persistSessionReference(
runtime.sessionDir,
@@ -493,7 +584,16 @@ export function createAgentRegistry({
runtime.worktreePath,
);
}
await persistWorkspace();
if (persist) await persistWorkspace();
}
async function restoreSessionReference(sessionDir, previous) {
const referencePath = path.join(sessionDir, SESSION_REFERENCE_FILE);
if (previous === undefined) {
await rm(referencePath, { force: true });
return;
}
await writeFile(referencePath, previous, { encoding: "utf8", mode: 0o600 });
}
function publishAgent(runtime, type, data) {
@@ -587,6 +687,8 @@ export function createAgentRegistry({
adapter: undefined,
supervisor: undefined,
stopped: false,
promoting: false,
promotionPromise: undefined,
recoveryPromise: undefined,
};
}
@@ -1000,6 +1102,8 @@ export function createAgentRegistry({
function closeRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (runtime.promoting)
return runtime.promotionPromise.then(() => closeRuntime(runtimeId));
if (runtime.state === "closing" || runtime.state === "stopped")
return Promise.reject(new Error("session runtime is closing"));
const wasWorkingAtRequest =
@@ -1018,6 +1122,136 @@ export function createAgentRegistry({
return result;
}
function promoteQuickRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (!runtime.ephemeral) return Promise.resolve(publicRuntime(runtime));
if (runtime.state === "closing" || runtime.state === "stopped")
return Promise.reject(new Error("session runtime is closing"));
if (runtime.promotionPromise) return runtime.promotionPromise;
runtime.promoting = true;
const promotion = runtime.operationQueue.then(async () => {
if (!runtime.ephemeral) return publicRuntime(runtime);
if (runtime.state === "closing" || runtime.state === "stopped")
throw new Error("session runtime is closing");
const adapter = runtime.adapter;
if (!adapter) throw new Error("Pi is not currently running");
const quickDir = runtime.sessionDir;
const sessionDir = sessionDirectoryFor(sessionRoot, runtime.worktreePath);
const previous = {
sessionDir: runtime.sessionDir,
sessionPath: runtime.sessionPath,
sessionId: runtime.sessionId,
sessionName: runtime.sessionName,
sessionLease: runtime.sessionPath
? runtimesBySessionPath.get(runtime.sessionPath) === runtime
: false,
managed: managedWorktrees.has(runtime.worktreePath),
};
let directoryPromotion;
let previousReference;
let reservedSessionPath;
let manifestStateChanged = false;
try {
await mkdir(sessionDir, {
recursive: true,
mode: SESSION_DIRECTORY_MODE,
});
await chmod(sessionDir, SESSION_DIRECTORY_MODE);
if (runtime.sessionPath) {
reservedSessionPath = await projectQuickSessionPath(
quickDir,
sessionDir,
runtime.sessionPath,
);
reserveSessionPath(reservedSessionPath, runtime);
}
directoryPromotion = await promoteQuickSessionDirectory(
quickDir,
sessionDir,
);
runtime.sessionDir = sessionDir;
if (reservedSessionPath) runtime.sessionPath = reservedSessionPath;
const state = await adapter.send({ type: "get_state" });
await updateRuntimeIdentity(runtime, state, {
adapter,
persist: false,
indexSession: false,
});
// A changed identity still needs a lease through manifest commit.
if (runtime.sessionPath && runtime.sessionPath !== reservedSessionPath)
reserveSessionPath(runtime.sessionPath, runtime);
if (
previous.sessionPath &&
previous.sessionPath !== runtime.sessionPath &&
runtimesBySessionPath.get(previous.sessionPath) === runtime
)
runtimesBySessionPath.delete(previous.sessionPath);
runtime.ephemeral = false;
managedWorktrees.add(runtime.worktreePath);
manifestStateChanged = true;
try {
previousReference = await readFile(
path.join(sessionDir, SESSION_REFERENCE_FILE),
"utf8",
);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
await persistSessionReference(
sessionDir,
runtime.sessionPath,
runtime.worktreePath,
);
await persistWorkspace();
if (runtime.sessionPath)
runtimesBySessionPath.set(runtime.sessionPath, runtime);
if (reservedSessionPath)
releaseSessionReservation(reservedSessionPath, runtime);
if (runtime.sessionPath !== reservedSessionPath)
releaseSessionReservation(runtime.sessionPath, runtime);
await directoryPromotion.finalize();
publishWorkspace("runtime_promoted", runtime, {
runtime: publicRuntime(runtime),
});
return publicRuntime(runtime);
} catch (error) {
if (directoryPromotion)
await restoreSessionReference(sessionDir, previousReference).catch(
() => {},
);
if (reservedSessionPath)
releaseSessionReservation(reservedSessionPath, runtime);
if (runtime.sessionPath !== reservedSessionPath)
releaseSessionReservation(runtime.sessionPath, runtime);
if (
runtime.sessionPath &&
runtimesBySessionPath.get(runtime.sessionPath) === runtime
)
runtimesBySessionPath.delete(runtime.sessionPath);
runtime.sessionDir = previous.sessionDir;
runtime.sessionPath = previous.sessionPath;
runtime.sessionId = previous.sessionId;
runtime.sessionName = previous.sessionName;
runtime.ephemeral = true;
if (previous.sessionLease && previous.sessionPath)
runtimesBySessionPath.set(previous.sessionPath, runtime);
if (previous.managed) managedWorktrees.add(runtime.worktreePath);
else managedWorktrees.delete(runtime.worktreePath);
if (manifestStateChanged) await persistWorkspace().catch(() => {});
if (directoryPromotion) await directoryPromotion.rollback();
throw error;
}
});
const tracked = promotion.finally(() => {
runtime.promoting = false;
runtime.promotionPromise = undefined;
});
runtime.operationQueue = tracked.catch(() => {});
runtime.promotionPromise = tracked;
return tracked;
}
async function refreshAfterLegacySessionChange(
runtime,
response,
@@ -1208,13 +1442,19 @@ export function createAgentRegistry({
async createQuickRuntime(worktreePath) {
return openRuntime({ worktreePath, ephemeral: true });
},
promoteQuickRuntime,
async openSessionRuntime(worktreePath, sessionPath) {
return openRuntime({ worktreePath, sessionPath });
},
closeSessionRuntime: closeRuntime,
async closeQuickRuntime(runtimeId) {
const runtime = getRuntime(runtimeId);
if (!runtime.ephemeral) throw new Error("runtime is not a quick runtime");
if (runtime.promoting) {
await runtime.promotionPromise;
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
}
if (!runtime.ephemeral)
return { runtimeId: runtime.runtimeId, sessionPath: runtime.sessionPath };
return closeRuntime(runtimeId);
},
async getModelCatalog() {
@@ -1478,7 +1718,8 @@ export function createAgentRegistry({
},
async route(agentId, operation, payload = {}) {
const runtime = getAgent(agentId);
if (operation === "abort") return runtime.adapter.send({ type: "abort" });
if (operation === "abort" && !runtime.promoting)
return runtime.adapter.send({ type: "abort" });
return enqueue(runtime, async () => {
if (operation === "switch_session") {
if (runtime.state === "streaming")