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
82 changes: 52 additions & 30 deletions bin/agent-guild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,25 @@ function portOf(url) {
return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
}

async function health(url, timeoutMs = 1000) {
/** The manager's health, `{ busy: pid }` when the manager its runtime file names for `url` runs but did not answer in time, or null. */
async function reach(url, timeoutMs = 1000) {
const signal = AbortSignal.timeout(timeoutMs);
try {
const res = await fetch(`${url}/api/v1/health`, { signal: AbortSignal.timeout(timeoutMs) });
const res = await fetch(`${url}/api/v1/health`, { signal });
if (!res.ok) return null;
const body = await res.json();
return body?.name === 'agent-guild' ? body : null;
} catch {
return null;
const owner = signal.aborted ? readRuntimeFile() : null;
return owner?.url === url && running(owner.pid) ? { busy: owner.pid } : null;
}
}

async function health(url, timeoutMs = 1000) {
const found = await reach(url, timeoutMs);
return found?.busy ? null : found;
}

function pageUrl(url, token) {
return `${url}/#token=${token}`;
}
Expand Down Expand Up @@ -114,7 +122,7 @@ async function ensureManager({ port = null } = {}) {
// A pinned port is the endpoint to serve, so it is also the one to check;
// a manager found anywhere else is not the one asked for.
const knownUrl = port === null ? baseUrl() : expectedUrl;
const answering = await health(knownUrl);
const answering = await reach(knownUrl);
if (answering) return { url: knownUrl, started: false, version: answering.version };

// Say why no manager can run here rather than start one that fails.
Expand Down Expand Up @@ -167,21 +175,23 @@ async function cmdOpen({ browser, signIn = false }) {
* a successor itself (a manager from before restarts only stops).
*/
async function requestShutdown(url, { restart = false } = {}) {
// `stop` and `restart` are documented as ending every session, so they do
// not ask; the web page's buttons are the ones that confirm first.
const res = await fetch(`${url}/api/v1/shutdown`, {
method: 'POST',
headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
});
if (!res.ok) {
const { error } = await res.json().catch(() => ({}));
throw new Error(`${restart ? 'restart' : 'stop'} failed: ${error?.message ?? `HTTP ${res.status}`}`);
const signal = AbortSignal.timeout(5000);
try {
const res = await fetch(`${url}/api/v1/shutdown`, {
method: 'POST', signal,
headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
});
const body = await res.json().catch(() => ({}));
signal.throwIfAborted();
if (!res.ok) throw new Error(`${restart ? 'restart' : 'stop'} failed: ${body.error?.message ?? `HTTP ${res.status}`}`);
const running = body.running ?? 0;
if (running > 0) console.log(`Ending ${running} running session(s).`);
return { running, restart: body.restart === true };
} catch (err) {
if (signal.aborted) throw new Error(`the session manager did not answer the ${restart ? 'restart' : 'stop'} request within 5 seconds; its outcome could not be confirmed.`);
throw err;
}
const body = await res.json().catch(() => ({}));
const running = body.running ?? 0;
if (running > 0) console.log(`Ending ${running} running session(s).`);
return { running, restart: body.restart === true };
}

function running(pid) {
Expand Down Expand Up @@ -209,13 +219,13 @@ async function waitForExit(pid, timeoutMs = 15000) {

async function cmdStop() {
const url = baseUrl();
const h = await health(url);
const h = await reach(url);
if (!h) {
console.log('Session manager is not running.');
return;
}
await requestShutdown(url);
if (await waitForExit(h.pid)) {
if (await waitForExit(h.busy ?? h.pid)) {
console.log('Session manager stopped.');
return;
}
Expand All @@ -225,21 +235,22 @@ async function cmdStop() {

async function cmdRestart() {
const url = baseUrl();
const before = await health(url);
if (!before) {
const found = await reach(url);
if (!found) {
// Nothing to stop: a restart of a stopped manager is a start.
const { url: started, version } = await ensureManager();
console.log(`Session manager was not running; started Agent Guild ${version} at ${started}.`);
return;
}
const before = found.busy ? { pid: found.busy } : found;
const { restart } = await requestShutdown(url, { restart: true });
if (!restart) {
// A manager from before restarts stops without starting a successor,
// which is the case right after an upgrade: start one here instead.
if (!(await waitForExit(before.pid))) throw new Error('the session manager did not stop, so it could not be restarted.');
// On the port the old one served, which an ephemeral port setting would otherwise lose.
const { url: started, version } = await ensureManager({ port: portOf(url) });
const changed = version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : '';
const changed = before.version && version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : '';
console.log(`Session manager restarted at ${started}${changed}.`);
return;
}
Expand All @@ -250,7 +261,7 @@ async function cmdRestart() {
await new Promise((r) => setTimeout(r, 250));
const now = await health(url, 500);
if (now && now.pid !== before.pid) {
const changed = now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : '';
const changed = before.version && now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : '';
console.log(`Session manager restarted at ${url}${changed}.`);
return;
}
Expand All @@ -261,7 +272,12 @@ async function cmdRestart() {

async function cmdStatus() {
const url = baseUrl();
const h = await health(url);
const h = await reach(url);
if (h?.busy) {
console.log(`Session manager running at ${url} (pid ${h.busy}) did not answer in time.`);
process.exitCode = 1;
return;
}
if (!h) {
console.log('Session manager is not running.');
// With no manager to ask, the boot service is read from systemd directly.
Expand Down Expand Up @@ -294,15 +310,21 @@ async function cmdStatus() {

async function cmdFireworks() {
const url = baseUrl();
if (!(await health(url))) {
if (!(await reach(url))) {
console.log('Session manager is not running.');
process.exitCode = 3;
return;
}
const res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
if (!res.ok) throw new Error(`the session manager answered ${res.status}.`);
const { pages } = await res.json();
console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.');
const signal = AbortSignal.timeout(5000);
try {
const res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', signal, headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
if (!res.ok) throw new Error(`the session manager answered ${res.status}.`);
const { pages } = await res.json();
console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.');
} catch (err) {
if (signal.aborted) throw new Error('the session manager did not answer the fireworks request within 5 seconds; delivery could not be confirmed.');
throw err;
}
}

const MIN_NODE_MAJOR = 22;
Expand Down
6 changes: 4 additions & 2 deletions src/manager/command-resolver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -184,17 +184,19 @@ export function npmSpawnSpec(npm, args, env = process.env, platform = process.pl
function runInOwnGroup(file, args, { env, cwd, timeoutMs, input, maxBuffer, signal }) {
return new Promise((resolve, reject) => {
let child;
// Registered before the spawn: a signal that lands once the command runs must find it.
const end = () => killGroup(child?.pid);
const release = endWithProcess(end);
try {
child = spawn(file, args, { env, cwd, detached: true });
} catch (err) {
release();
return reject(Object.assign(err, { stdout: '', stderr: '' }));
}
let stdout = '';
let stderr = '';
let timedOut = false;
let settled = false;
const end = () => killGroup(child.pid);
const release = endWithProcess(end);
const finish = (err) => {
if (settled) return;
settled = true;
Expand Down
3 changes: 2 additions & 1 deletion src/manager/session-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ export function codexHooksList(resolved, args, { env, platform = process.platfor
const stopped = () => finish(new Error('The manager is stopping'));
const timer = setTimeout(() => finish(new Error('Codex did not answer in time')), timeoutMs);
timer.unref?.();
// Registered before the spawn: a signal that lands once Codex runs must find it.
if (platform !== 'win32') release = endWithProcess(() => killGroup(child?.pid));
try {
const verbatim = typeof spec.args === 'string';
child = spawn(spec.file, verbatim ? [spec.args] : spec.args, {
Expand All @@ -222,7 +224,6 @@ export function codexHooksList(resolved, args, { env, platform = process.platfor
} catch (err) {
return finish(err);
}
if (platform !== 'win32') release = endWithProcess(() => killGroup(child.pid));
signal?.addEventListener('abort', stopped, { once: true });
let stderr = '';
let buffer = '';
Expand Down
6 changes: 4 additions & 2 deletions src/manager/shell-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,21 @@ export function probeLoginShell({ shell, env = process.env, timeoutMs = LOGIN_SH
if (process.platform === 'win32' || !shell) return Promise.resolve({ vars: null, failure: null });
return new Promise((resolve) => {
let child;
// Registered before the spawn: a signal that lands once the shell runs must find it.
const end = () => killGroup(child?.pid);
const release = endWithProcess(end);
try {
child = spawn(shell, LOGIN_SHELL_ARGS, {
detached: true,
stdio: ['ignore', 'pipe', 'ignore'],
env: { ...env, AGENT_GUILD_RESOLVING_ENV: '1' },
});
} catch (err) {
release();
return resolve({ vars: null, failure: `could not start (${err.code || err.message})` });
}
let stdout = '';
let settled = false;
const end = () => killGroup(child.pid);
const release = endWithProcess(end);
const finish = (result) => {
if (settled) return;
settled = true;
Expand Down
Loading
Loading