Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions electron/devservers.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ async function probeOnce(probe, ctx) {
/**
* @typedef {{
* pid: number,
* root?: string,
* keepLog?: boolean,
* script: string,
* startedAt: number,
* url: string | null,
Expand Down Expand Up @@ -421,6 +423,7 @@ function killProcessGroup(pid, platform = process.platform) {
* env?: NodeJS.ProcessEnv,
* project?: { remoteHost?: string, remotePath?: string, path?: string } | null,
* probeIntervalMs?: number,
* onExit?: () => void,
* }} [opts]
*/
function start(threadId, root, script, opts = {}) {
Expand Down Expand Up @@ -500,6 +503,7 @@ function start(threadId, root, script, opts = {}) {
/** @type {DevServerRecord} */
const rec = {
pid,
root,
script,
startedAt: Date.now(),
url: null,
Expand All @@ -526,6 +530,7 @@ function start(threadId, root, script, opts = {}) {
child.on("exit", () => {
const current = records.get(threadId);
if (current === rec) markDead(rec);
if (opts.onExit) opts.onExit();
});
if (typeof child.unref === "function") child.unref();

Expand Down Expand Up @@ -553,10 +558,16 @@ function start(threadId, root, script, opts = {}) {
*
* @param {string} threadId
*/
function stop(threadId) {
function stop(threadId, opts = {}) {
const rec = records.get(threadId);
if (!rec) return { running: false };
if (rec.pid) killProcessGroup(rec.pid, rec.platform);
if (rec.pid && !rec.dead && isAlive(rec.pid)) killProcessGroup(rec.pid, rec.platform);
if (opts.preserveLog) {
if (!rec.dead) appendLog(rec, "\n[settle] stopped managed dev server\n");
markDead(rec);
rec.keepLog = true;
return toState(rec);
}
records.delete(threadId);
return { running: false };
}
Expand All @@ -570,7 +581,7 @@ function status(threadId) {
const rec = records.get(threadId);
if (!rec) return { running: false };
const state = toState(rec);
if (rec.dead && rec.deadAt && Date.now() - rec.deadAt > DEAD_TTL_MS) {
if (!rec.keepLog && rec.dead && rec.deadAt && Date.now() - rec.deadAt > DEAD_TTL_MS) {
records.delete(threadId);
}
return state;
Expand Down Expand Up @@ -603,4 +614,5 @@ module.exports = {
status,
killAll,
listLive,
rootFor: (threadId) => records.get(threadId)?.root,
};
5 changes: 3 additions & 2 deletions electron/fm-suggest.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ async function maybeSuggestNextPrompt(store, threadId, opts = {}) {
}
if (inFlight.has(threadId)) return null;

const msgs = store.getMessages(threadId) || [];
const boundary = store.getThread(threadId)?.contextBoundaryId;
const msgs = store.getContextMessages(threadId) || [];
const lastUser = [...msgs]
.reverse()
.find((m) => m && m.role === "user" && String(m.text || "").trim());
Expand All @@ -116,7 +117,7 @@ async function maybeSuggestNextPrompt(store, threadId, opts = {}) {
const suggestion = sanitizeSuggestion(raw);
// A newer turn may have landed while fm ran; its own suggestion (or
// the turn-start clear) wins over this now-stale one.
if ((store.getMessages(threadId) || []).length !== msgs.length) {
if (store.getThread(threadId)?.contextBoundaryId !== boundary || (store.getContextMessages(threadId) || []).length !== msgs.length) {
return null;
}
if (!store.getThread(threadId)) return null;
Expand Down
9 changes: 7 additions & 2 deletions electron/ipc-devserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,20 +162,25 @@ module.exports = {
lane: n,
});
}
return devservers.start(threadId, startRoot, script, {
const state = devservers.start(threadId, startRoot, script, {
project,
onExit: () => require("./projectCommands.js").stopManagedWatcherIfIdle(ctx.store),
env: spawnEnvForDevServer({
project,
thread,
userDataPath: ctx.userDataPath,
extra: spotlightOn ? spotlightEnv(portBase) : laneEnvExtra(thread),
}),
});
require("./projectCommands.js").watchManagedOnSettle(ctx.store);
return state;
},
"devserver:stop": async (ctx, input) => {
const threadId = input && input.threadId;
resolveDevServerRoot(ctx, threadId);
return devservers.stop(threadId);
const state = devservers.stop(threadId);
require("./projectCommands.js").stopManagedWatcherIfIdle(ctx.store);
return state;
},
"devserver:status": async (ctx, input) => {
const threadId = input && input.threadId;
Expand Down
7 changes: 7 additions & 0 deletions electron/ipc-threads.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ module.exports = {
// ⌘K "Restart agent session" (#1762): kill the warm/idle provider process
// so the next turn spawns fresh and reloads skills/plugins/MCP servers.
// Pushes its own detail.
"threads:freshAgentContext": async (ctx, input) => ctx.runner.freshAgentContext(input),
"threads:restartAgentSession": async (ctx, input) =>
ctx.runner.restartAgentSession(input),
"threads:setPermissionMode": async (ctx, input) => {
Expand Down Expand Up @@ -738,6 +739,12 @@ module.exports = {
ctx.broadcast("threads:changed", services.listThreads(ctx.store));
return updated;
},
"threads:setUltracode": async (ctx, input) => {
const updated = leaveProfile(ctx, input.threadId, services.setUltracode(ctx.store, input));
services.recordLastUsedDefaults(ctx.store, input.threadId);
ctx.broadcast("threads:changed", services.listThreads(ctx.store));
return updated;
},
"threads:setFast": async (ctx, input) => {
const updated = services.setFast(ctx.store, input);
ctx.broadcast("threads:changed", services.listThreads(ctx.store));
Expand Down
2 changes: 2 additions & 0 deletions electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ const IPC_CHANNELS = Object.freeze([
{ ns: "threads", method: "pendingApprovals" },
{ ns: "threads", method: "respondPermission" },
{ ns: "threads", method: "restartAgentSession" },
{ ns: "threads", method: "freshAgentContext" },
{ ns: "threads", method: "clearQuestion" },
{ ns: "threads", method: "answerSecret" },
{ ns: "threads", method: "setArchived" },
Expand Down Expand Up @@ -282,6 +283,7 @@ const IPC_CHANNELS = Object.freeze([
{ ns: "threads", method: "setAgent" },
{ ns: "threads", method: "listAgents" },
{ ns: "threads", method: "setFast" },
{ ns: "threads", method: "setUltracode" },
{ ns: "threads", method: "setRoute" },
{ ns: "threads", method: "setVerifyCommand" },
{ ns: "threads", method: "runVerify" },
Expand Down
120 changes: 115 additions & 5 deletions electron/projectCommands.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const SETUP_TIMEOUT_MS = 30 * 60_000;
const inflight = new Map();
/** Inflight promises that are a new worktree's setup, not a quick action. */
const setupJobs = new WeakSet();
// Only our managed quick actions, never provider tools or terminal shells.
const runningActions = new Map();
/** Thrown when a solenta.json command has not been approved (#1506). */
const REPO_CONFIG_UNTRUSTED = "REPO_CONFIG_UNTRUSTED";
const SUBMODULE_COMMAND = "git submodule update --init --recursive";
Expand Down Expand Up @@ -87,7 +89,7 @@ function normalizeQuickActions(raw) {
id = randomUUID();
}
seen.add(id);
out.push({ id, name, command });
out.push({ id, name, command, ...(typeof row.stopOnSettle === "boolean" ? { stopOnSettle: row.stopOnSettle } : {}) });
if (out.length >= QUICK_ACTION_MAX) break;
}
return out.length ? out : null;
Expand All @@ -101,7 +103,7 @@ function normalizeQuickActions(raw) {
* null so the key can be deleted from the stored project.
*
* @param {unknown} raw
* @returns {{ provider?: string, model?: string, reasoningEffort?: string, permissionMode?: string, lastUsed?: true } | null}
* @returns {{ provider?: string, model?: string, reasoningEffort?: string, ultracode?: boolean, permissionMode?: string, lastUsed?: true } | null}
*/
function normalizeThreadDefaults(raw) {
if (!raw || typeof raw !== "object") return null;
Expand All @@ -111,7 +113,7 @@ function normalizeThreadDefaults(raw) {
typeof v === "string" && v.trim() && v.trim().length <= max
? v.trim()
: "";
/** @type {{ provider?: string, model?: string, reasoningEffort?: string, permissionMode?: string, lastUsed?: true }} */
/** @type {{ provider?: string, model?: string, reasoningEffort?: string, ultracode?: boolean, permissionMode?: string, lastUsed?: true }} */
const out = {};
const r = /** @type {Record<string, unknown>} */ (raw);
const provider = str(r.provider, 64);
Expand All @@ -121,7 +123,17 @@ function normalizeThreadDefaults(raw) {
if (model) out.model = model;
}
const effort = str(r.reasoningEffort, 32);
if (effort) out.reasoningEffort = effort;
if (effort === "ultracode") {
// Legacy project/last-used defaults stored the session mode as effort.
const { getProvider, modelSupportsUltracode } = require("./providers.js");
if (modelSupportsUltracode(getProvider(provider.split(":")[0]), out.model)) {
out.reasoningEffort = "xhigh";
out.ultracode = true;
}
} else {
if (effort) out.reasoningEffort = effort;
if (r.ultracode === true) out.ultracode = true;
}
if (typeof r.permissionMode === "string" && PERMISSION_MODES.has(r.permissionMode)) {
out.permissionMode = r.permissionMode;
}
Expand Down Expand Up @@ -278,6 +290,7 @@ function resolveCommand(project, actionId) {
command,
timeoutMs: VERIFY_TIMEOUT_MS,
fromRepo: row.fromRepo,
stopOnSettle: row.stopOnSettle,
...trust,
};
}
Expand Down Expand Up @@ -383,6 +396,7 @@ function kickWorktreeSetup(opts) {
function runOnSettle(opts) {
try {
const { store, threadId, broadcast } = opts;
stopManagedOnSettle(store, threadId);
const thread = store.getThread(threadId);
if (!thread || !thread.worktreePath || thread.onSettleAt != null) {
return Promise.resolve(null);
Expand Down Expand Up @@ -416,6 +430,86 @@ function runOnSettle(opts) {
}
}

/** Settlement check for managed process owners, including inactivity. */
function managedOwnerSettled(store, thread, now = Date.now()) {
if (!thread || thread.status === "working" || thread.status === "quota-wait") return false;
if (thread.archived) return true;
if (thread.pinnedAt != null && Number.isFinite(thread.pinnedAt)) return false;
if (thread.settledOverride === "settled") return true;
if (thread.settledOverride === "active") return false;
const settings = store.getSettings();
const links = require("./prLinks.js").normalizePrLinks(thread);
const states = links.length ? links.map((p) => p.state) : [thread.prState];
if (states.every((s) => s === "CLOSED" || (s === "MERGED" && settings.autoSettleOnMerge !== false))) return true;
if (states.includes("OPEN") || thread.prState === "OPEN") return false;
const days = settings.autoSettleAfterDays === undefined ? require("./store-normalize.js").DEFAULT_AUTO_SETTLE_AFTER_DAYS : settings.autoSettleAfterDays;
return days != null && Number.isFinite(days) && days >= 0 && Number.isFinite(thread.updatedAt) && thread.updatedAt < now - days * 86400000;
}

/** Shared checkout processes stay alive while another project thread needs them. */
function canStopManaged(store, threadId, cwd) {
const thread = store.getThread(threadId);
const project = thread && store.getProject(thread.projectId);
if (!thread || !project) return false;
// The captured launch root survives merge cleanup clearing worktreePath.
if (project.path && path.resolve(cwd) !== path.resolve(project.path)) return true;
return !store.getThreads().some((other) => other.id !== threadId && other.projectId === thread.projectId && !managedOwnerSettled(store, other));
}

function stopManagedOnSettle(store, threadId) {
const thread = store.getThread(threadId);
const project = thread && store.getProject(thread.projectId);
if (!project) return;
const eff = effectiveCommands(project);
const action = runningActions.get(threadId);
if (action && !action.controller.signal.aborted && (typeof action.stopOnSettle === "boolean" ? action.stopOnSettle : project.stopOnSettle === true) && canStopManaged(store, threadId, action.cwd)) action.controller.abort();
const servers = require("./devservers.js");
const root = servers.rootFor(threadId);
const enabled = typeof project.stopOnSettle === "boolean" ? project.stopOnSettle : eff.trusted && eff.devServerStopOnSettle === true;
if (enabled && root && canStopManaged(store, threadId, root)) servers.stop(threadId, { preserveLog: true });
}

/** Scan only managed owners, so idle ticks never walk the whole transcript store. */
const managedSettleTimers = new WeakMap();

function optedInManagedOwners(store) {
const servers = require("./devservers.js");
const liveServers = new Set(servers.listLive());
const ids = new Set([...runningActions.keys(), ...liveServers]);
return [...ids].filter((id) => {
const thread = store.getThread(id);
const project = thread && store.getProject(thread.projectId);
if (!project) return false;
const action = runningActions.get(id);
const eff = effectiveCommands(project);
const actionEnabled = action && !action.controller.signal.aborted && (typeof action.stopOnSettle === "boolean" ? action.stopOnSettle : project.stopOnSettle === true);
const serverEnabled = liveServers.has(id) && (typeof project.stopOnSettle === "boolean" ? project.stopOnSettle : eff.trusted && eff.devServerStopOnSettle === true);
return actionEnabled || serverEnabled;
});
}

function stopManagedWatcherIfIdle(store) {
if (optedInManagedOwners(store).length) return;
clearInterval(managedSettleTimers.get(store));
managedSettleTimers.delete(store);
}

function watchManagedOnSettle(store) {
if (managedSettleTimers.has(store) || !optedInManagedOwners(store).length) return;
const timer = setInterval(() => sweepManagedOnSettle(store), 30_000);
timer.unref();
managedSettleTimers.set(store, timer);
}

function sweepManagedOnSettle(store) {
for (const id of optedInManagedOwners(store)) {
if (managedOwnerSettled(store, store.getThread(id))) stopManagedOnSettle(store, id);
}
const remaining = optedInManagedOwners(store).length;
if (!remaining) stopManagedWatcherIfIdle(store);
return remaining;
}

/** First one present in the worktree is the one compared (#396). */
const LOCKFILES = [
"package-lock.json",
Expand Down Expand Up @@ -603,13 +697,21 @@ async function runOne(store, args) {
}
: undefined;

const controller = new AbortController();
const managed = resolved.id !== SETUP_ID && resolved.id !== ON_SETTLE_ID && resolved.id !== SHELL_ID;
const owner = { controller, cwd, projectId: project && project.id, stopOnSettle: resolved.stopOnSettle };
if (managed) {
runningActions.set(threadId, owner);
watchManagedOnSettle(store);
}
let ran;
try {
ran = await runFn({
command: resolved.command,
cwd,
project,
timeoutMs: resolved.timeoutMs,
signal: controller.signal,
...(env ? { env } : {}),
...(onOutput ? { onOutput } : {}),
});
Expand All @@ -623,6 +725,8 @@ async function runOne(store, args) {
};
}

if (runningActions.get(threadId) === owner) runningActions.delete(threadId);
if (managed) stopManagedWatcherIfIdle(store);
/** @type {import("../src/shared/ipc").CommandRunResult} */
const result = {
name: resolved.name,
Expand All @@ -631,7 +735,7 @@ async function runOne(store, args) {
exitCode: ran && ran.exitCode != null ? ran.exitCode : null,
timedOut: Boolean(ran && ran.timedOut),
// Re-bounded here too: a swapped runner or a thrown error is not tailed.
log: ran && ran.log ? tailLog(String(ran.log)) : "",
log: tailLog(`${ran && ran.log ? String(ran.log) : ""}${controller.signal.aborted ? "\n[settle] stopped managed action" : ""}`),
durationMs: ran && typeof ran.durationMs === "number" ? ran.durationMs : 0,
at: Date.now(),
cwd,
Expand Down Expand Up @@ -755,6 +859,12 @@ module.exports = {
SUBMODULE_COMMAND,
kickWorktreeSetup,
runOnSettle,
stopManagedOnSettle,
sweepManagedOnSettle,
watchManagedOnSettle,
stopManagedWatcherIfIdle,
managedOwnerSettled,
canStopManaged,
waitForCommand,
pendingSetup,
runCommand,
Expand Down
Loading
Loading