feat(quick-pane): promote active runtime
This commit is contained in:
+257
-16
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user